diff --git a/server/src/compose.ts b/server/src/compose.ts index 64a60e63..b0c84267 100644 --- a/server/src/compose.ts +++ b/server/src/compose.ts @@ -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, @@ -476,3 +477,92 @@ export async function composeFromPlan(loopId: string, plan: LoopPlan): Promise { // 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 { + 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 +} diff --git a/server/src/index.ts b/server/src/index.ts index 4ed51088..5c3c8c5b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 { @@ -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 diff --git a/server/src/system-prompt.ts b/server/src/system-prompt.ts index 43bac9cd..1f2127ea 100644 --- a/server/src/system-prompt.ts +++ b/server/src/system-prompt.ts @@ -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) @@ -84,8 +85,33 @@ async function buildRuntimeBlock(loop: LoopMeta): Promise { return lines.join("\n").trim() } +/** + * Build the `@`-mention sub-agent block. Tells the model: when the user's + * message starts with `@`, 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 { + 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 \`@\` 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 \`@\` 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 { 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() } diff --git a/web/src/components/chat/AgentMention.tsx b/web/src/components/chat/AgentMention.tsx new file mode 100644 index 00000000..b6081476 --- /dev/null +++ b/web/src/components/chat/AgentMention.tsx @@ -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 `@ ` 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(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( + () => + 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 ( +
+
+
+

+ Sub-agents +

+
+ {showEmpty ? ( +
+ +
+
+ No sub-agents in this loop +
+

+ Add agent files under knowledge or personal{" "} + + .loopat/claude/agents/ + + , then restart the loop to load them. +

+
+
+ ) : ( +
+ {filtered.map((agent, idx) => { + const isSelected = idx === selectedIdx; + return ( + + ); + })} +
+ )} +
+
+ ); +} diff --git a/web/src/components/chat/Composer.tsx b/web/src/components/chat/Composer.tsx index 0b743c59..4fe2e087 100644 --- a/web/src/components/chat/Composer.tsx +++ b/web/src/components/chat/Composer.tsx @@ -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"; @@ -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" > + ({ showHistory: false, toggleShowHistory: () => {}, availableSlashCommands: [], + availableAgents: [], suppressSlashRef: { current: false }, hasOlderMessages: false, loadMoreMessages: () => {}, @@ -546,6 +552,20 @@ export function useLoopRuntime(loopId: string | null, currentUserId: string, ope return ["help", "model", "compress", "review", "init", "foxtrot"].map(name => ({ name, description: "" })) }, ) + + // Sub-agents composed into this loop's .claude/agents/. Fetched once per + // loopId; endpoint failure → empty array (the @-mention dropdown simply + // doesn't open). + const [availableAgents, setAvailableAgents] = useState([]) + useEffect(() => { + if (!loopId) { setAvailableAgents([]); return } + let cancelled = false + fetch(`/api/loops/${loopId}/agents`, { credentials: "include" }) + .then((r) => (r.ok ? r.json() : { agents: [] })) + .then((d) => { if (!cancelled) setAvailableAgents(Array.isArray(d?.agents) ? d.agents : []) }) + .catch(() => { if (!cancelled) setAvailableAgents([]) }) + return () => { cancelled = true } + }, [loopId]) const suppressSlashRef = useRef(false) const onTitleChangedRef = useRef(onTitleChanged) onTitleChangedRef.current = onTitleChanged @@ -999,8 +1019,8 @@ export function useLoopRuntime(loopId: string | null, currentUserId: string, ope }, []) const extra = useMemo( - () => ({ toolProgressMap, taskMap, questions: questionsReadonlyMap, sendAnswers, thinkingOpen, setThinkingOpen, permissionMode, setPermissionMode, permissionPrompt, answerPermission, setMaxThinkingTokens, getContextUsage, contextUsage, thinkingBudget, provider, selectProvider, clearContext, thinkingBlockCount, loopId: loopId ?? "", loadingHistory, agentToolUseIds, childMessagesByAgentId, isRunning: running, enqueueMessage, queue, clearQueue: onClearQueue, removeFromQueue: onRemoveFromQueue, hasHistory, showHistory, toggleShowHistory, availableSlashCommands, suppressSlashRef, hasOlderMessages, loadMoreMessages, turnGeneration, turnStartedAt, getStreamingTokenCount, getWaitingForResponse, contextTokens, cumulativeTokens, openFile, goal, goalSetAt, goalStatus, setGoal: setGoalFn, completeGoal, retryLastUser, backgroundTasks, turnStatsByMessageId }), - [toolProgressMap, taskMap, questionsReadonlyMap, sendAnswers, thinkingOpen, permissionMode, permissionPrompt, answerPermission, setMaxThinkingTokens, getContextUsage, contextUsage, thinkingBudget, provider, selectProvider, clearContext, thinkingBlockCount, loopId, loadingHistory, agentToolUseIds, childMessagesByAgentId, running, enqueueMessage, queue, onClearQueue, onRemoveFromQueue, hasHistory, showHistory, toggleShowHistory, availableSlashCommands, hasOlderMessages, loadMoreMessages, turnGeneration, turnStartedAt, contextTokens, cumulativeTokens, openFile, retryLastUser, backgroundTasks, turnStatsByMessageId], + () => ({ toolProgressMap, taskMap, questions: questionsReadonlyMap, sendAnswers, thinkingOpen, setThinkingOpen, permissionMode, setPermissionMode, permissionPrompt, answerPermission, setMaxThinkingTokens, getContextUsage, contextUsage, thinkingBudget, provider, selectProvider, clearContext, thinkingBlockCount, loopId: loopId ?? "", loadingHistory, agentToolUseIds, childMessagesByAgentId, isRunning: running, enqueueMessage, queue, clearQueue: onClearQueue, removeFromQueue: onRemoveFromQueue, hasHistory, showHistory, toggleShowHistory, availableSlashCommands, availableAgents, suppressSlashRef, hasOlderMessages, loadMoreMessages, turnGeneration, turnStartedAt, getStreamingTokenCount, getWaitingForResponse, contextTokens, cumulativeTokens, openFile, goal, goalSetAt, goalStatus, setGoal: setGoalFn, completeGoal, retryLastUser, backgroundTasks, turnStatsByMessageId }), + [toolProgressMap, taskMap, questionsReadonlyMap, sendAnswers, thinkingOpen, permissionMode, permissionPrompt, answerPermission, setMaxThinkingTokens, getContextUsage, contextUsage, thinkingBudget, provider, selectProvider, clearContext, thinkingBlockCount, loopId, loadingHistory, agentToolUseIds, childMessagesByAgentId, running, enqueueMessage, queue, onClearQueue, onRemoveFromQueue, hasHistory, showHistory, toggleShowHistory, availableSlashCommands, availableAgents, hasOlderMessages, loadMoreMessages, turnGeneration, turnStartedAt, contextTokens, cumulativeTokens, openFile, retryLastUser, backgroundTasks, turnStatsByMessageId], ) useEffect(() => {