Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions server/src/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path"
import { parse as tomlParse, stringify as tomlStringify } from "smol-toml"
import {
loopClaudeDir,
loopComposedAgentsDir,
personalAgentsDir,
personalClaudeDir,
personalClaudeMdPath,
Expand Down Expand Up @@ -476,3 +477,92 @@ export async function composeFromPlan(loopId: string, plan: LoopPlan): Promise<C
export async function writeLoopSettings(_loopId: string): Promise<void> {
// no-op
}

/**
* Metadata for a single sub-agent, parsed from its `.md` frontmatter. Shared
* shape between the `@`-mention dropdown (web/AgentMention) and the system
* prompt's @-mention block (system-prompt.ts).
*/
export interface AgentMeta {
/** Agent identifier — what CC's Agent tool wants in `subagent_type`. */
name: string
/** Short human description. May be empty if the file has none. */
description: string
/** Optional accent color from frontmatter (e.g. "blue"). UI hint only. */
color?: string
}

/**
* Pull `name` / `description` / `color` out of a CC-style agent `.md`
* frontmatter block. Minimal hand-rolled parser — frontmatter is small,
* single-line `key: value` pairs (with optional surrounding quotes). Block
* scalars / multi-line values are not supported (every agent we've seen uses
* single-line values; description can be long but stays on one line).
*/
function parseAgentFrontmatter(
text: string,
): { name?: string; description?: string; color?: string } {
if (!text.startsWith("---")) return {}
const after = text.slice(4)
const endRel = after.search(/\n---\s*(\n|$)/)
if (endRel < 0) return {}
const body = after.slice(0, endRel)
const out: { name?: string; description?: string; color?: string } = {}
for (const raw of body.split("\n")) {
const m = raw.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:\s*(.*?)\s*$/)
if (!m) continue
const key = m[1]
let val = m[2]
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1)
}
if (key === "name" || key === "description" || key === "color") {
out[key] = val
}
}
return out
}

/**
* Enumerate sub-agents composed into a loop's `.claude/agents/`. Returns `[]`
* if the dir doesn't exist or has no `.md` files. Filename (sans `.md`) is the
* fallback name when frontmatter has none. Broken symlinks / unreadable files
* are skipped.
*/
export async function listLoopAgents(loopId: string): Promise<AgentMeta[]> {
const dir = loopComposedAgentsDir(loopId)
if (!existsSync(dir)) return []
let entries: string[]
try {
entries = await readdir(dir)
} catch {
return []
}
const out: AgentMeta[] = []
for (const name of entries) {
if (!name.endsWith(".md") || name.startsWith(".")) continue
let text = ""
try {
text = await readFile(join(dir, name), "utf8")
} catch {
continue
}
const fm = parseAgentFrontmatter(text)
const agentName = fm.name ?? name.replace(/\.md$/, "")
// Reject names with unsafe characters (e.g. backticks would break
// markdown formatting in the system prompt).
if (!/^[a-zA-Z0-9_-]+$/.test(agentName)) continue
out.push({
name: agentName,
description: fm.description ?? "",
...(fm.color ? { color: fm.color } : {}),
})
}
// Stable alpha order so the system-prompt block reads cleanly without
// depending on the client sort.
out.sort((a, b) => a.name.localeCompare(b.name))
return out
}
11 changes: 11 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { execFile, execFileSync, spawn } from "node:child_process"
import { promisify } from "node:util"
import { listLoops, createLoop, getLoop, loopExists, patchLoopMeta, backfillAllMounts, ensureWorkspaceDirs, provisionUserPersonal, importPersonalFromRepo, setupPersonalViaProvider, listPersonalReposViaProvider, authenticateViaProvider, isPersonalFresh, ensureUiNotesWorktree, syncUiNotes, ffUpdateUiNotes, discardUiNotes, notesBehind, inspectPersonalDirty, syncPersonalToRemote, deletePersonalVault, pullPersonalFromRemote, pushPersonalToRemote, ensureContextMounts, effectiveDriver, isDriver, distillLoop, inspectRepoSync, pullRepoFromRemote, pushRepoToRemote, listVaultPublicKeys, userOnboarding, submitOnboarding, dismissOnboarding, deviceFlowStart, deviceFlowPoll } from "./loops"
import { getEphemeralHostPort, probePodman, stopAllWorkspaceContainers, ensureServeContainer, ensurePortProxyContainer, ensureSandboxImage, buildPodmanExecArgs, ensureContainer, containerName, V_LOOP_WORKDIR } from "./podman"
import { listLoopAgents } from "./compose"
import { startMcpAuth, completeMcpAuth, probeOAuthSupport, evictOAuthProbe, parseBearerEnvName, mcpRequiredEnvs, parseTemplateVars, type OAuthSupport } from "./mcp-oauth"
import { DEFAULT_VAULT, loadVaultEnvs } from "./vaults"
import {
Expand Down Expand Up @@ -1767,6 +1768,16 @@ app.post("/api/loops/:id/strip-thinking", requireAuth, async (c) => {
return c.json(r)
})

// List sub-agents composed into this loop's .claude/agents/ (workspace +
// personal tiers). Returns {name, description, color?} per agent — same shape
// consumed by the @-mention dropdown in Composer. Read-only; auth required.
app.get("/api/loops/:id/agents", requireAuth, async (c) => {
const id = c.req.param("id") ?? ""
if (!(await loopExists(id))) return c.json({ error: "not found" }, 404)
const agents = await listLoopAgents(id)
return c.json({ agents })
})

/**
* Read the live host port for an ephemeral-mode share. Returns null when
* the container is down, not in ephemeral mode, or the mapping hasn't
Expand Down
28 changes: 27 additions & 1 deletion server/src/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { readFile } from "node:fs/promises"
import { execFile } from "node:child_process"
import { promisify } from "node:util"
import { effectiveDriver, type LoopMeta } from "./loops"
import { listLoopAgents } from "./compose"
import { bundledDoctrinePath, personalNotesDir, personalKnowledgeDir } from "./paths"

const execFileP = promisify(execFile)
Expand Down Expand Up @@ -84,8 +85,33 @@ async function buildRuntimeBlock(loop: LoopMeta): Promise<string> {
return lines.join("\n").trim()
}

/**
* Build the `@`-mention sub-agent block. Tells the model: when the user's
* message starts with `@<agent-name>`, dispatch to that sub-agent via the
* Agent tool. Skipped entirely when the loop has no composed agents.
*
* This is what makes the UI's `@`-picker actually trigger a real subagent
* invocation — without it the `@foo` text would just sit there.
*/
async function buildAgentBlock(loopId: string): Promise<string> {
const agents = await listLoopAgents(loopId)
if (agents.length === 0) return ""
const lines = agents.map(
(a) => `- \`${a.name}\`${a.description ? ` — ${a.description}` : ""}`,
)
return `## @-mention sub-agents

The user can dispatch to a sub-agent by starting their message with \`@<agent-name>\` followed by the task. When you receive such a message, you MUST call the Agent tool with \`subagent_type\` set to the named agent and \`prompt\` set to the rest of the user's message (everything after the \`@<agent-name>\` token). Don't answer the request yourself — delegate. After the sub-agent returns, you may relay or summarize its result.

If the agent name isn't in the list below, treat the \`@\` as plain text and answer normally.

Available sub-agents in this loop:
${lines.join("\n")}`.trim()
}

export async function buildLoopatAppend(loop: LoopMeta): Promise<string> {
const bundled = await loadBundled()
const runtime = await buildRuntimeBlock(loop)
return `${bundled}\n\n${runtime}\n`.trim()
const agentBlock = await buildAgentBlock(loop.id)
return [bundled, runtime, agentBlock].filter(Boolean).join("\n\n").trim()
}
171 changes: 171 additions & 0 deletions web/src/components/chat/AgentMention.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { useAuiState, useComposerRuntime } from "@assistant-ui/react";
import { Bot } from "lucide-react";
import { useLoopRuntimeExtra, type AgentMeta } from "@/useLoopRuntime";

/**
* `@`-mention dropdown. Sister component to SlashCommand — same trigger
* pattern (watches composer text, opens when text starts with `@`, capture-
* phase keyboard nav) but for sub-agents instead of slash commands.
*
* On select, inserts `@<agent-name> ` into the composer; the user then
* types the task and submits. The system-prompt's @-mention block (see
* server/src/system-prompt.ts) tells Claude to dispatch to the named
* sub-agent via the Agent tool — no client-side transformation needed.
*
* Mutually exclusive with SlashCommand by construction (text can't start
* with both `@` and `/`), so no coordination needed.
*/
export default function AgentMention() {
const text = useAuiState((s) => s.composer.text);
const composerRuntime = useComposerRuntime();
const { availableAgents } = useLoopRuntimeExtra();
const [selectedIdx, setSelectedIdx] = useState(0);
const listRef = useRef<HTMLDivElement>(null);

const textTrimmed = typeof text === "string" ? text.trimStart() : text;
// Trigger: text starts with `@` and doesn't contain whitespace yet (i.e.
// user is still typing the agent name, hasn't moved on to the task).
const showDropdown =
typeof textTrimmed === "string" &&
textTrimmed.startsWith("@") &&
!/\s/.test(textTrimmed);

const query = showDropdown ? textTrimmed.slice(1).toLowerCase() : "";

const filtered = useMemo<AgentMeta[]>(
() =>
availableAgents.filter(
(a) =>
!query ||
a.name.toLowerCase().includes(query) ||
a.description.toLowerCase().includes(query),
),
[availableAgents, query],
);

// The selectable list shows when there are matches to choose from.
const showList = showDropdown && filtered.length > 0;
// Distinct empty state: the user is `@`-mentioning but this loop has no
// composed sub-agents at all. Show a hint instead of rendering nothing, so
// typing `@` doesn't look broken. (When agents exist but the query matches
// none, we stay silent — same as SlashCommand.)
const showEmpty = showDropdown && availableAgents.length === 0;

useEffect(() => {
if (showList) setSelectedIdx(0);
}, [showList, query]);

const insertAgent = useCallback(
(agent: AgentMeta) => {
composerRuntime.setText(`@${agent.name} `);
},
[composerRuntime],
);

// Keyboard nav — capture phase, same trick as SlashCommand, so we beat
// the composer's own Enter handler.
useEffect(() => {
if (!showList) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
e.stopImmediatePropagation();
setSelectedIdx((prev) =>
Math.min(prev + 1, Math.max(filtered.length - 1, 0)),
);
} else if (e.key === "ArrowUp") {
e.preventDefault();
e.stopImmediatePropagation();
setSelectedIdx((prev) =>
Math.max(Math.min(prev - 1, filtered.length - 1), 0),
);
} else if (e.key === "Enter" && filtered.length > 0) {
e.preventDefault();
e.stopImmediatePropagation();
const idx = Math.min(selectedIdx, filtered.length - 1);
insertAgent(filtered[idx]);
} else if (e.key === "Escape") {
e.preventDefault();
e.stopImmediatePropagation();
composerRuntime.setText("");
}
};
window.addEventListener("keydown", onKeyDown, { capture: true });
return () =>
window.removeEventListener("keydown", onKeyDown, { capture: true });
}, [showList, filtered, selectedIdx, composerRuntime, insertAgent]);

// Scroll selected row into view as user arrows through.
useEffect(() => {
if (!listRef.current) return;
const sel = listRef.current.children[selectedIdx] as HTMLElement | undefined;
if (sel) sel.scrollIntoView({ block: "nearest" });
}, [selectedIdx]);

if (!showList && !showEmpty) return null;

return (
<div className="relative">
<div className="absolute bottom-0 left-0 mb-1 w-80 rounded-lg border border-gray-200 bg-white shadow-lg z-20">
<div className="px-3 pt-2 pb-1">
<p className="text-[10px] font-medium text-gray-400 uppercase tracking-wider">
Sub-agents
</p>
</div>
{showEmpty ? (
<div className="flex items-start gap-2.5 px-3 pb-2.5 pt-0.5">
<Bot className="h-4 w-4 flex-shrink-0 mt-0.5 text-gray-300" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-500">
No sub-agents in this loop
</div>
<p className="text-xs text-gray-400">
Add agent files under knowledge or personal{" "}
<code className="rounded bg-gray-100 px-1 py-0.5 text-[11px]">
.loopat/claude/agents/
</code>
, then restart the loop to load them.
</p>
</div>
</div>
) : (
<div ref={listRef} className="max-h-72 overflow-y-auto py-1">
{filtered.map((agent, idx) => {
const isSelected = idx === selectedIdx;
return (
<button
key={agent.name}
type="button"
onMouseEnter={() => setSelectedIdx(idx)}
onMouseDown={(e) => {
e.preventDefault(); // keep focus on the textarea
insertAgent(agent);
}}
className={`w-full flex items-start gap-2.5 px-3 py-1.5 text-left transition-colors ${
isSelected ? "bg-blue-50" : "hover:bg-gray-50"
}`}
>
<Bot
className="h-4 w-4 flex-shrink-0 mt-0.5 text-purple-500"
style={agent.color ? { color: agent.color } : undefined}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-700">
@{agent.name}
</div>
{agent.description && (
<p className="text-xs text-gray-500 line-clamp-2">
{agent.description}
</p>
)}
</div>
</button>
);
})}
</div>
)}
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions web/src/components/chat/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import PlanModeToggle from "./PlanModeToggle";
import ModelSelector from "./ModelSelector";
import PluginsButton from "./PluginsButton";
import SlashCommand from "./SlashCommand";
import AgentMention from "./AgentMention";
import TokenUsagePie from "./TokenUsagePie";
import { FilePicker } from "./FilePicker";
import { useLoopRuntimeExtra } from "@/useLoopRuntime";
Expand Down Expand Up @@ -352,6 +353,7 @@ export default function Composer({ pickedFile, editorSelection }: { pickedFile?:
className="flex w-full flex-col gap-2 rounded-2xl border border-gray-200 bg-white p-2.5 shadow-sm"
>
<SlashCommand />
<AgentMention />

<ComposerPrimitive.Input
placeholder="Send a message..."
Expand Down
Loading