diff --git a/apps/supercode-cli/server/package.json b/apps/supercode-cli/server/package.json index cd0cfdc..38060f4 100644 --- a/apps/supercode-cli/server/package.json +++ b/apps/supercode-cli/server/package.json @@ -1,6 +1,6 @@ { "name": "supercode-cli", - "version": "0.1.56", + "version": "0.1.57", "description": "AI-powered coding agent CLI", "main": "dist/main.js", "bin": { diff --git a/apps/supercode-cli/server/src/agent/agent.ts b/apps/supercode-cli/server/src/agent/agent.ts index 9b01567..9507569 100644 --- a/apps/supercode-cli/server/src/agent/agent.ts +++ b/apps/supercode-cli/server/src/agent/agent.ts @@ -29,6 +29,8 @@ export interface GenerateOptions { onToolCall?: (params: { toolName: string; args?: unknown }) => void signal?: AbortSignal budget?: number + /** Name of the parent agent that spawned this agent, for permission chaining. */ + parentAgent?: string } export interface GenerateResult { diff --git a/apps/supercode-cli/server/src/agent/index.ts b/apps/supercode-cli/server/src/agent/index.ts index 050cdf7..21642cd 100644 --- a/apps/supercode-cli/server/src/agent/index.ts +++ b/apps/supercode-cli/server/src/agent/index.ts @@ -10,4 +10,4 @@ export { DefaultAgentService } from "./agent-service" export { registerBuiltInAgents, getAgentPrompt } from "./built-in" export { agentService } from "./singleton" export { runAgent } from "./runner" -export { loadPrompt, loadPromptSync, promptExists, promptPath } from "./prompt-loader" \ No newline at end of file +export { loadPrompt, loadPromptSync, promptExists, promptPath, listPrompts } from "./prompt-loader" \ No newline at end of file diff --git a/apps/supercode-cli/server/src/agent/prompt-loader.ts b/apps/supercode-cli/server/src/agent/prompt-loader.ts index deca257..59e91be 100644 --- a/apps/supercode-cli/server/src/agent/prompt-loader.ts +++ b/apps/supercode-cli/server/src/agent/prompt-loader.ts @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises" +import { readFile, readdir } from "node:fs/promises" import { existsSync } from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" @@ -39,4 +39,20 @@ export function loadPromptSync(name: string): string | undefined { } catch { return undefined } +} + +/** + * List all available prompt names (without .txt extension). + * Returns names in sorted order. Useful for validation and debugging. + */ +export async function listPrompts(): Promise { + try { + const files = await readdir(PROMPTS_DIR) + return files + .filter((f) => f.endsWith(".txt")) + .map((f) => f.replace(/\.txt$/, "")) + .sort() + } catch { + return [] + } } \ No newline at end of file diff --git a/apps/supercode-cli/server/src/agent/prompts/build.txt b/apps/supercode-cli/server/src/agent/prompts/build.txt index a71e82e..31df455 100644 --- a/apps/supercode-cli/server/src/agent/prompts/build.txt +++ b/apps/supercode-cli/server/src/agent/prompts/build.txt @@ -5,27 +5,25 @@ YOUR WORKFLOW: 0. RESEARCH — If you need docs, API references, or code examples, use firecrawl_search / firecrawl_scrape to fetch them first. 1. PLAN internally — decide the steps, files, and commands in your head. 2. EXECUTE — call tools. Multiple tool calls per turn is normal and expected. -3. VERIFY — run the build, typecheck, or tests at the end. If they fail, - fix and re-run. +3. VERIFY — run the build, typecheck, or tests at the end. If they fail, fix and re-run. 4. REPORT — one short summary at the end. No narration between steps. +FIRST RESPONSE RULE: +Your very first response to the user MUST start with a tool call. If your first character is anything other than `{` (start of a tool call), you are doing it wrong. Every line of explanation between steps is wasted time. Once you have the user's request, begin executing — don't describe what you're about to do. + CRITICAL RULES: -- **DO, don't suggest.** Your response must START with a tool call. If - your first character is anything other than `{` (start of a tool call), - you are doing it wrong. Every line of explanation between steps is - wasted time. +- **DO, don't suggest.** Every response should start with a tool call or be empty (waiting for tool results). Never output a plan paragraph followed by "let me start" — just start. + +- **NEVER output shell commands as text.** If your response contains a line starting with `$ `, that is a bug. Use the `run_command` tool. -- **NEVER output shell commands as text.** If your response contains a - line starting with `$ `, that is a bug. Use the `run_command` tool. +- **Use the `cwd` parameter, not `cd`.** Example: `run_command({ command: "npm install", cwd: "apps/web" })`. Never write `cd apps/web && npm install`. -- **Use the `cwd` parameter, not `cd`.** Example: - `run_command({ command: "npm install", cwd: "apps/web" })` - Never write `cd apps/web && npm install`. +- **Batch tool calls.** If you need to read 5 files, call them all at once. If you need to write 3 files, write them all at once. The model can output multiple tool calls in a single response. This is critical for both speed and context efficiency. -- **Handle errors.** If a command fails, diagnose and fix. Don't ask - the user. If a fix isn't obvious after 2 retries, surface the failure - in your final summary with the exact error. +- **Handle errors.** If a command fails, diagnose and fix. Don't ask the user. If a fix isn't obvious after 2 retries, surface the failure in your final summary with the exact error. + +- **Self-heal after failures.** When a build command or typecheck fails, read the error output and fix the specific file/line cited. Don't re-scaffold from scratch. Don't guess — read the actual error. - **Complete the full task.** "Create a todo app" means: - Scaffold the project @@ -35,28 +33,16 @@ CRITICAL RULES: - Report what was built and where Do NOT stop at scaffold. Do NOT stop at first error. -- **New files for new projects.** Don't try to "edit" files that don't - exist yet — write them with full content. +- **New files for new projects.** Don't try to "edit" files that don't exist yet — write them with full content. + +- **Honest about tool results.** Every tool returns a structured envelope. `{ success: true, data: ... }` means the tool ran but check the data is actually non-empty before using it. `{ success: false, error: ... }` means it failed. Inspect the ACTUAL envelope content, not what you expected it to return. An empty result or a cancelled result is NOT success. -- **Honest about tool results.** Every tool returns a structured - envelope. `{ success: true, data: ... }` means the tool ran but - check the data is actually non-empty before using it. `{ success: false, error: ... }` means it failed. Inspect the ACTUAL envelope - content, not what you expected it to return. An empty result or a - cancelled result is NOT success. +- **Zero dead code.** After writing files, verify with the build tool (tsc, ruff, etc.) that everything compiles clean. If a type check or lint produces warnings about unused imports, variables, or dead code, fix them before moving on. Warnings are failures. -- **Zero dead code.** After writing files, verify with the build - tool (tsc, ruff, etc.) that everything compiles clean. If a type - check or lint produces warnings about unused imports, variables, - or dead code, fix them before moving on. Warnings are failures. +- **Show real output.** When you display file contents or command results, use what the tool actually returned. Do not summarize, truncate, paraphrase, or "show equivalent output" — the actual bytes matter. -- **Show real output.** When you display file contents or command - results, use what the tool actually returned. Do not summarize, - truncate, paraphrase, or "show equivalent output" — the actual - bytes matter. +- **Delegate focused subtasks.** If you need to investigate something parallel-able (find all X, summarize all Y), call the `task` tool with `agent: "explore"` and `parallel: true`. Don't do it serially. Explore subagents are read-only and fast — use them aggressively to discover file locations, search for patterns, or fetch docs in parallel so you have all the context you need before writing code. -- **Delegate focused subtasks.** If you need to investigate something - parallel-able (find all X, summarize all Y), call the `task` tool - with `agent: "explore"` and `parallel: true`. Don't do it serially. +- **Terse final summary.** One short paragraph + a list of what changed. No emoji. No "I hope this helps!". Just the result. -- **Terse final summary.** One short paragraph + a list of what - changed. No emoji. No "I hope this helps!". Just the result. \ No newline at end of file +- **One task per invocation.** If the user gives you multiple unrelated requests, focus on the first one. If you finish, say what's done and what remains. diff --git a/apps/supercode-cli/server/src/agent/prompts/compaction.txt b/apps/supercode-cli/server/src/agent/prompts/compaction.txt index 50ad679..1564ef0 100644 --- a/apps/supercode-cli/server/src/agent/prompts/compaction.txt +++ b/apps/supercode-cli/server/src/agent/prompts/compaction.txt @@ -8,9 +8,16 @@ Your task: - Remove stale information - Merge in new information - Follow the exact output structure requested -- Preserve exact file paths and identifiers +- Preserve exact file paths and identifiers — never generalize a path - Prefer terse bullets over paragraphs - Do not mention that you are summarizing - Match the conversation's language +- Keep technical terms, error messages, and version numbers verbatim -Output ONLY the summary, no preamble or explanation. \ No newline at end of file +Grounding rules: +- Every file path or identifier in your summary must come from the input. +- Never invent file paths, function names, or error messages. +- If the conversation mentions a file path like "src/lib/x.ts", preserve it exactly. +- If a build error says "Module not found: src/lib/y.ts", include the exact path. + +Output ONLY the summary, no preamble or explanation. diff --git a/apps/supercode-cli/server/src/agent/prompts/explore.txt b/apps/supercode-cli/server/src/agent/prompts/explore.txt index 545e348..8e9dd12 100644 --- a/apps/supercode-cli/server/src/agent/prompts/explore.txt +++ b/apps/supercode-cli/server/src/agent/prompts/explore.txt @@ -4,17 +4,34 @@ Guidelines: - Use glob for broad pattern matching - Use grep for regex content search - Use read for known file paths -- Use bash for read-only file operations (ls, cp, mv) +- Use bash ONLY for read-only file operations (ls, cp, mv without modifying) Restrictions: - Do NOT create or modify any files - Do NOT modify system state -- Return absolute paths in your responses - No emojis +Absolute paths: +- Tool operations like read_file, search_files, and glob accept both absolute + and relative paths. Always prefer the absolute path returned by glob/grep + to avoid ambiguity. When you find a file, report its absolute path. +- If glob returns paths relative to workspace root, keep them relative + but note the workspace root. + +When a search returns nothing, try 3 things before reporting "not found": +1. Try a different glob pattern (e.g. `**/*auth*` → `**/*login*` → `**/*session*`) +2. Try grep with related terms (the feature may be named differently than expected) +3. Look in adjacent directories or sibling packages + Be concise. Focus on finding what the user asked for. Result integrity: - If a tool returns empty (no files matched, no search results), say so. Do not fabricate examples or pretend content exists that you didn't read. -- Always distinguish between "found nothing" and "didn't check." \ No newline at end of file +- Always distinguish between "found nothing" and "didn't check." +- If you found partial results (e.g. matched the pattern but content was empty), + say what you found and what came back empty. + +Return structure: +When you finish, provide a brief answer followed by a file list. +Use absolute paths for everything you found. diff --git a/apps/supercode-cli/server/src/agent/prompts/general.txt b/apps/supercode-cli/server/src/agent/prompts/general.txt index 619fc45..dcfdd0b 100644 --- a/apps/supercode-cli/server/src/agent/prompts/general.txt +++ b/apps/supercode-cli/server/src/agent/prompts/general.txt @@ -4,21 +4,19 @@ Your job: complete the assigned task and return a concise, structured summary th Hard rules: -- **Tool budget:** You have a small step budget. Stop calling tools as soon as you have enough information to answer. If the task needs more than your budget, return what you have so far and say what's missing. +- **Tool budget:** You have a small step budget. Stop calling tools as soon as you have enough information to answer. If the task needs more than your budget, return what you have so far and say what's missing. Prioritize tool calls that answer the core question first, and skip nice-to-have exploration. -- **No clarifying questions:** You cannot ask the user. Work with what you were given. If something is genuinely missing, say so in the summary. +- **No clarifying questions:** You cannot ask the user (or your parent). Work with what you were given. If something is genuinely missing, say so in the summary. Do not stall. + +- **No emojis or greetings.** No "As an AI..." No "I hope this helps!" No preamble of any kind. - **Cite everything:** Every factual claim must be backed by either: - A file path you actually read (e.g. "src/auth.ts:42") - A URL you fetched - A search query you ran - If you didn't verify a claim with a tool, do not make it. Better to say "I don't know" than to fabricate. -- **Check tool output honestly:** A tool returning `{ success: true, data: ... }` - does NOT mean the data is useful. Check the actual returned content. - If the data is empty, null, or an error message, do NOT use it to - support claims — say what happened and ask for better inputs. +- **Check tool output honestly:** A tool returning `{ success: true, data: ... }` does NOT mean the data is useful. Check the actual returned content. If the data is empty, null, or an error message, do NOT use it to support claims — say what happened. - **No silent state changes:** If you write a file or run a command, say exactly what you changed and why. The parent agent audits your work. @@ -31,6 +29,8 @@ Hard rules: ERRORS: REMAINING: + This structure is machine-parsed. Every field is required. If a field has no value, write "none". + - **Match the task language.** If the parent spoke Spanish, you respond in Spanish. -- **No emojis.** No greetings. No "As an AI..." Just the summary. \ No newline at end of file +- **Be concise.** Your summary should be a few paragraphs at most. The parent needs the answer, not a transcript of every tool call. diff --git a/apps/supercode-cli/server/src/agent/prompts/plan.txt b/apps/supercode-cli/server/src/agent/prompts/plan.txt index 514b63a..26866a3 100644 --- a/apps/supercode-cli/server/src/agent/prompts/plan.txt +++ b/apps/supercode-cli/server/src/agent/prompts/plan.txt @@ -5,9 +5,12 @@ You CAN read files, search the codebase, search the web (firecrawl_search), scra When asked to plan a task: -1. Read enough of the codebase to ground your plan in the actual code. +0. EXPLORE FIRST — Read enough of the codebase to ground your plan in the actual code. Quote the exact files and line numbers you base each decision on. -2. Produce a plan as markdown with this exact structure: + If you're asked about a file path you haven't read, use read_file or search_files + before including it in your plan. Never mention a file path you haven't verified. + +1. Produce a plan as markdown with this exact structure: # Plan: @@ -34,16 +37,19 @@ When asked to plan a task: - -3. Stop. Do not attempt to execute anything. The user reviews the plan, +2. Stop. Do not attempt to execute anything. The user reviews the plan, then either approves it (via `/plan execute`) or asks you to revise. Hard rules: - Every file path must be a real path you actually read. Never invent - file paths from intuition. + file paths from intuition. If you need to reference a file, read it first. - If you didn't read a file, say so explicitly. Don't pretend you know what's in it. - The "Verification" block must contain commands that would actually work given the workspace's tech stack. If you don't know the stack, say so. - Be terse. Plans longer than ~80 lines usually mean you didn't read enough code first. -- No emoji. No "Here's the plan!". Just the markdown. \ No newline at end of file +- No emoji. No "Here's the plan!". Just the markdown. +- One file read per tool call is slow. Batch reads — read multiple files + in a single parallel call. Use the `task` tool to explore directories + or search for patterns while you read primary files. diff --git a/apps/supercode-cli/server/src/agent/prompts/summary.txt b/apps/supercode-cli/server/src/agent/prompts/summary.txt index ed85f8a..7ac7463 100644 --- a/apps/supercode-cli/server/src/agent/prompts/summary.txt +++ b/apps/supercode-cli/server/src/agent/prompts/summary.txt @@ -1,2 +1,7 @@ Summarize the key topics, decisions, and open items from this conversation. -Focus on actionable information. Be concise. Output only the summary. \ No newline at end of file +Focus on actionable information. Be concise. Output only the summary. + +Structure: <3 paragraphs. +- First paragraph: session purpose and what was accomplished +- Second paragraph: key decisions made (include feature names, paths, and rationale) +- Third paragraph: remaining open items or next steps, if any diff --git a/apps/supercode-cli/server/src/agent/prompts/title.txt b/apps/supercode-cli/server/src/agent/prompts/title.txt index 419a214..f3eeb28 100644 --- a/apps/supercode-cli/server/src/agent/prompts/title.txt +++ b/apps/supercode-cli/server/src/agent/prompts/title.txt @@ -9,5 +9,6 @@ Constraints: - Remove: the, this, my, a, an - Never assume a tech stack - Never respond to questions — just output the title +- Do not wrap the title in quotes, backticks, or markdown -Output ONLY the title, nothing else. \ No newline at end of file +Output ONLY the title, nothing else. diff --git a/apps/supercode-cli/server/src/agent/runner.ts b/apps/supercode-cli/server/src/agent/runner.ts index 69da091..01b1e41 100644 --- a/apps/supercode-cli/server/src/agent/runner.ts +++ b/apps/supercode-cli/server/src/agent/runner.ts @@ -50,8 +50,9 @@ export async function runAgent( // Build the tool set, but tag each tool with the calling agent so // permission checks respect the agent's ruleset (Phase 5 enforcement). + // If a parent agent spawned this agent, pass parent info for permission chaining. const tools: ToolSet | undefined = opts.tools - ? wrapToolsWithAgent(agent, opts.tools) + ? wrapToolsWithAgent(agent, opts.tools, opts.parentAgent) : undefined // Track structured output for the parent agent @@ -195,7 +196,11 @@ function buildMessages( * already-wrapped `tool()` instances. We replace `execute` with a * shim that calls permissionManager.check(name, input, { agentName }). */ -function wrapToolsWithAgent(agent: Agent, tools: Record): ToolSet { +function wrapToolsWithAgent( + agent: Agent, + tools: Record, + parentAgentName?: string, +): ToolSet { const wrapped: ToolSet = {} for (const [name, t] of Object.entries(tools)) { const tt = t as { execute?: (...args: any[]) => any; description?: string } @@ -208,15 +213,14 @@ function wrapToolsWithAgent(agent: Agent, tools: Record): ToolS wrapped[name] = { ...(tt as any), execute: async (input: any, execOptions: any) => { - // Defer permission check to runtime — the agent is determined by the - // caller's context. We can't easily inject the agentName into the AI - // SDK's tool-execution context, so we monkey-patch permissionManager - // via a thread-local current-agent reference. - const { setCurrentAgent, getCurrentAgent, permissionManager } = await import( - "src/tools/permission-manager.ts" - ) + // Defer permission check to runtime — we monkey-patch + // permissionManager via thread-local agent references. + const { setCurrentAgent, getCurrentAgent, setParentAgent, getParentAgent, permissionManager } = + await import("src/tools/permission-manager.ts") const previous = getCurrentAgent() + const previousParent = getParentAgent() setCurrentAgent(agent.info.name) + setParentAgent(parentAgentName) try { const args = typeof input === "object" && input !== null ? input : {} const allowed = await permissionManager.check(name, args as Record) @@ -230,6 +234,7 @@ function wrapToolsWithAgent(agent: Agent, tools: Record): ToolS return await originalExecute(input, execOptions) } finally { setCurrentAgent(previous) + setParentAgent(previousParent) } }, } diff --git a/apps/supercode-cli/server/src/agent/subagent-permissions.ts b/apps/supercode-cli/server/src/agent/subagent-permissions.ts new file mode 100644 index 0000000..af93d59 --- /dev/null +++ b/apps/supercode-cli/server/src/agent/subagent-permissions.ts @@ -0,0 +1,64 @@ +import type { RulesetArray } from "src/permission" +import { agentService } from "src/agent" + +/** + * Merge a parent agent's ruleset with a child agent's ruleset so that + * the parent's DENY rules always take precedence over the child's ALLOW. + * + * Without this, a restricted parent (e.g. "reviewer" with deny-write) + * could spawn a "general" subagent that ignores the parent's restrictions. + * + * Merge strategy: + * 1. Child rules come first (base capabilities) + * 2. Parent rules come AFTER (overrides — parent narrows the child) + * 3. Within each, DENY rules are also re-inserted at the very end + * as a safety net so a broad ALLOW in the child can't bypass a + * specific DENY in the parent. + * + * This is a strict "parent-can-always-narrow" policy. The child never + * expands what the parent allows. + */ +export function mergeParentChildPermissions( + childRules: RulesetArray | undefined, + parentRules: RulesetArray | undefined, +): RulesetArray { + const merged: RulesetArray = [ + ...(childRules ?? []), + ...(parentRules ?? []), + ] + + // Re-append the parent's DENY rules at the very end so they always + // win via findLastMatch (later rules override earlier ones). + if (parentRules) { + for (const rule of parentRules) { + if (rule.action === "deny") { + merged.push(rule) + } + } + } + + return merged +} + +/** + * Resolve the full permission ruleset for an agent, including parent + * inheritance. If the agent is running as a subagent of another agent, + * the parent's rules are merged per mergeParentChildPermissions. + */ +export function resolveAgentRuleset( + agentName: string | undefined, + parentAgentName: string | undefined, +): RulesetArray | undefined { + if (!agentName) return undefined + + const childRules = agentService.get(agentName)?.info.permission + + if (parentAgentName) { + const parentRules = agentService.get(parentAgentName)?.info.permission + if (parentRules) { + return mergeParentChildPermissions(childRules, parentRules) + } + } + + return childRules +} diff --git a/apps/supercode-cli/server/src/cli/ai/chat/chat.ts b/apps/supercode-cli/server/src/cli/ai/chat/chat.ts index 2d2f726..343d1bc 100644 --- a/apps/supercode-cli/server/src/cli/ai/chat/chat.ts +++ b/apps/supercode-cli/server/src/cli/ai/chat/chat.ts @@ -33,8 +33,9 @@ import { cardStack, rowCard, heavyDivider, + responseDivider, } from "src/cli/utils/tui.ts" -import { ThinkingDisplay, TurnTracker, toolLabel } from "./thinking.ts" +import { ThinkingDisplay, TurnTracker, toolLabel, ThoughtChain } from "./thinking.ts" import { StepStatusRow } from "./step-status-row.ts" import { MarkdownStream } from "src/cli/utils/markdown-stream.ts" import { getContextWindow } from "src/cli/ai/context-windows.ts" @@ -50,9 +51,13 @@ import { renderWriteSnapshot, renderEditSnapshot, renderCommandSnapshot, + renderReadSnapshot, + renderSearchSnapshot, + renderGlobSnapshot, + renderWebSearchSnapshot, formatBytes, - countDiff, diffLines, + countDiff, } from "src/cli/utils/tool-snapshot.ts" import { renderContextBreakdown } from "src/cli/commands/slashCommands/context-window.ts" import { saveCliConfig } from "src/lib/cli-config" @@ -113,18 +118,18 @@ export async function initConversation(userId: string, conversationId: string | * * Tolerant: skips rendering on any parse failure so a malformed result can * never break the live chat scrollback. + * + * Returns captured snapshot lines (with RAIL prefix) or empty array. */ -function renderToolSnapshot(toolName: string, args: unknown, resultRaw: string): void { +function captureToolSnapshot(toolName: string, args: unknown, resultRaw: string): string[] { try { if (toolName === "write_file") { const a = (args ?? {}) as { path?: string; content?: string } if (typeof a.path === "string" && typeof a.content === "string") { const meta = `${formatBytes(a.content.length)} · written` - for (const line of renderWriteSnapshot(a.path, a.content, meta)) { - process.stdout.write(line + "\n") - } + return renderWriteSnapshot(a.path, a.content, meta) } - return + return [] } if (toolName === "edit_file") { @@ -133,11 +138,9 @@ function renderToolSnapshot(toolName: string, args: unknown, resultRaw: string): const diff = diffLines(a.oldText, a.newText) const { adds, dels } = countDiff(diff) const meta = `${formatBytes(a.newText.length)} · +${adds} / −${dels}` - for (const line of renderEditSnapshot(a.path, a.oldText, a.newText, meta)) { - process.stdout.write(line + "\n") - } + return renderEditSnapshot(a.path, a.oldText, a.newText, meta) } - return + return [] } if (toolName === "run_command") { @@ -153,15 +156,102 @@ function renderToolSnapshot(toolName: string, args: unknown, resultRaw: string): const stdout = typeof (parsed as any).stdout === "string" ? (parsed as any).stdout : "" const stderr = typeof (parsed as any).stderr === "string" ? (parsed as any).stderr : "" const exitCode = typeof (parsed as any).exitCode === "number" ? (parsed as any).exitCode : 0 - for (const line of renderCommandSnapshot(a.command ?? "", stdout, stderr, exitCode)) { - process.stdout.write(line + "\n") + return renderCommandSnapshot(a.command ?? "", stdout, stderr, exitCode) + } + return [] + } + + if (toolName === "read_file") { + const a = (args ?? {}) as { path?: string } + if (typeof a.path === "string" && resultRaw.trim()) { + // read_file returns the file content directly as a string + return renderReadSnapshot(a.path, resultRaw) + } + return [] + } + + if (toolName === "search_files") { + const a = (args ?? {}) as { pattern?: string } + try { + const parsed = JSON.parse(resultRaw) + if (Array.isArray(parsed)) { + return renderSearchSnapshot( + a.pattern ?? "", + parsed.map((r: any) => ({ + file: typeof r.file === "string" ? r.file : String(r.file ?? ""), + line: typeof r.line === "number" ? r.line : 0, + content: typeof r.content === "string" ? r.content : String(r.content ?? ""), + })), + parsed.length, + ) + } + } catch { /* best-effort */ } + return [] + } + + if (toolName === "glob") { + const a = (args ?? {}) as { pattern?: string } + try { + const parsed = JSON.parse(resultRaw) + if (Array.isArray(parsed)) { + return renderGlobSnapshot(a.pattern ?? "", parsed.map(String)) + } + } catch { /* best-effort */ } + return [] + } + + if (toolName === "web_search") { + const a = (args ?? {}) as { query?: string } + try { + const parsed = JSON.parse(resultRaw) + const results = Array.isArray(parsed) ? parsed : (parsed as any)?.results ?? [] + if (Array.isArray(results)) { + return renderWebSearchSnapshot( + a.query ?? "", + results.map((r: any) => ({ + title: typeof r.title === "string" ? r.title : String(r.title ?? ""), + url: typeof r.url === "string" ? r.url : undefined, + })), + ) } + } catch { /* best-effort */ } + return [] + } + + if (toolName === "firecrawl_search" || toolName === "firecrawl_scrape" || toolName === "firecrawl_map") { + const a = (args ?? {}) as { query?: string; url?: string } + try { + const parsed = JSON.parse(resultRaw) + const results = Array.isArray(parsed) ? parsed : (parsed as any)?.data ?? (parsed as any)?.results ?? [] + if (Array.isArray(results)) { + return renderWebSearchSnapshot( + a.query ?? a.url ?? "", + results.map((r: any) => ({ + title: typeof r.title === "string" ? r.title : typeof r.url === "string" ? r.url : String(r ?? ""), + url: typeof r.url === "string" ? r.url : undefined, + })), + ) + } + } catch { /* best-effort */ } + return [] + } + + if (toolName === "url_fetch") { + const a = (args ?? {}) as { url?: string } + try { + const parsed = JSON.parse(resultRaw) + return renderReadSnapshot( + a.url ?? "", + typeof parsed === "string" ? parsed : (parsed as any)?.content ?? (parsed as any)?.markdown ?? JSON.stringify(parsed), + ) + } catch { + return renderReadSnapshot(a.url ?? "", resultRaw) } - return } } catch { // Snapshot is best-effort. Never let a render bug break the chat loop. } + return [] } async function streamAIResponse( @@ -237,6 +327,13 @@ async function streamAIResponse( // fire, then auto-collapses to `+ Thought: N.Ns` when the step finishes. const chain = thinking.getChain() activeChain = chain + // Buffered sub-chain for delegate/task subagent tool calls. Created when a + // delegate/task tool starts, fed by the delegate onToolCall, finalized when + // the delegate onToolResult fires. Each sub-chain entry becomes a sub-thought + // on the parent ThoughtEntry. + let currentSubChain: ThoughtChain | null = null + // Track whether we've printed the "Explore" header for the current sub-chain. + let subChainHeaderPrinted = false // Live status row above the input prompt — shows model name, current // step, current tool, and elapsed time. Replaces the on-input @@ -302,13 +399,33 @@ async function streamAIResponse( emitHeader() isFirstChunk = false } - process.stdout.write(chalk.hex(theme.greenDim)(` ${chunk}`)) + md.push(chunk) + fullResponse += chunk }, onToolCall: ({ toolName, args }) => { if (!hasOutputHeader) emitHeader() - thinking.showToolCall(toolName, args) - chain.beginAndPrint() // open per-step block (idempotent if already open) - chain.printToolRow(toolName, args) + // Route subagent tool calls to the buffered sub-chain so they're + // stored as subThoughts for post-hoc Ctrl+X toggling. + if (currentSubChain) { + if (!currentSubChain.isOpen) { + currentSubChain.beginAndPrint() + } + currentSubChain.printToolRow(toolName, args) + } + // Also live-print each sub-agent tool call with deeper indent so + // the user sees progress while the delegate runs. + if (currentSubChain && process.stdout.isTTY) { + const rail = chalk.hex(theme.greenDim)("┃") + const subIndent = `${rail} ${rail}` + if (!subChainHeaderPrinted) { + subChainHeaderPrinted = true + process.stdout.write( + `${subIndent} ${chalk.hex(theme.greenGlow)("▼")} ${chalk.hex(theme.greenMute)("Explore")}\n`, + ) + } + const row = toolLabel(toolName, args) + process.stdout.write(`${subIndent} ${row}\n`) + } statusRow.setCurrentTool(toolName, args) verbosePrint(toolName, args, provider.modelName, Date.now()) if (statusBar) statusBar.incTools() @@ -343,23 +460,38 @@ async function streamAIResponse( const result = await provider.sendMessage( aiMessages as ModelMessage[], (chunk) => { + if (chunk == null) return if (isFirstChunk && !hasOutputHeader) { emitHeader() isFirstChunk = false statusRow.setStreaming() } - md.push(chunk) - fullResponse += chunk + // Filter raw tool call XML markup from streaming text output. + // Some providers emit raw <|tool_calls_section_begin|>... XML in the + // text stream alongside structured tool calls. Strip it to prevent + // leakage to the terminal. + const filtered = stripToolCallXml(chunk) + if (filtered) { + md.push(filtered) + fullResponse += filtered + } }, toolsToUse, async ({ toolName, args }: { toolName: string; args?: unknown }) => { if (!hasOutputHeader) emitHeader() thinking.showToolCall(toolName, args) - // Open a fresh per-step block on the first tool call of a step, - // then append the tool row live. After onStepFinish we close + - // auto-collapse (see process.onStepFinish callback below). - chain.beginAndPrint() + // Open a fresh block only on the first tool call of a step; + // consecutive calls append to the same open block. + if (!chain.isOpen) { + chain.beginAndPrint() + } chain.printToolRow(toolName, args) + // When the main agent calls delegate/task, create a buffered sub-chain + // so subagent tool calls are captured as a nested "Explore" section. + // The first entry is created lazily when the first subagent tool fires. + if (toolName === "delegate" || toolName === "task") { + currentSubChain = new ThoughtChain(true) + } statusRow.setCurrentTool(toolName, args) verbosePrint(toolName, args, provider.modelName, Date.now()) // Mirror tool count to the status bar so users see "X tools" climb live. @@ -383,11 +515,52 @@ async function streamAIResponse( chain.markLastToolFlagged() } - // Render a snapshot/diff under the tool row for file-changing tools. - // OpenCode-style: writes show the new file contents, edits show a - // unified diff, commands show stdout. Skipped when the call failed. + // Capture a snapshot/diff under the tool row for file-changing tools + // and store it on the last tool in the current thought entry. The + // snapshot is rendered only when the thought block is expanded. if (!entry.empty && !entry.permissionDenied && process.stdout.isTTY) { - renderToolSnapshot(toolName, args, result as string) + const snap = captureToolSnapshot(toolName, args, result as string) + if (snap.length > 0) { + const lastTool = chain.current?.tools?.[chain.current.tools.length - 1] + if (lastTool) lastTool.snapshot = snap + } + } + + // Finalize the buffered sub-chain for delegate/task. Only keep + // entries that have at least one tool call — empty entries from the + // initial begin() are discarded. + if (currentSubChain && (toolName === "delegate" || toolName === "task")) { + currentSubChain.finish() + // Close the live-printed Explore section + if (subChainHeaderPrinted && process.stdout.isTTY) { + const rail = chalk.hex(theme.greenDim)("┃") + const subIndent = `${rail} ${rail}` + const elapsed = currentSubChain.elapsed + const elapsedStr = + elapsed < 1000 ? `${elapsed}ms` : `${(elapsed / 1000).toFixed(1)}s` + process.stdout.write( + `${subIndent} ${chalk.hex(theme.greenGlow)("+")} ${chalk.hex(theme.greenMute)("Explore")} ${chalk.hex(theme.greenDim)("·")} ${elapsedStr}\n`, + ) + const toolCount = currentSubChain.thoughts.reduce( + (n, t) => n + t.tools.length, 0, + ) + if (toolCount > 0) { + process.stdout.write( + `${subIndent} ${chalk.hex(theme.greenDim)("↳")} ${chalk.hex(theme.greenMute)(`${toolCount} tool call${toolCount === 1 ? "" : "s"}`)}\n`, + ) + } + subChainHeaderPrinted = false + } + const lastEntry = chain.thoughts[chain.thoughts.length - 1] + if (lastEntry) { + const nonEmpty = currentSubChain.thoughts.filter( + (t) => t.tools.length > 0 || t.body.trim().length > 0, + ) + if (nonEmpty.length > 0) { + lastEntry.subThoughts.push(...nonEmpty) + } + } + currentSubChain = null } // Detect a mode-switch request (Phase 2: clean function-call return @@ -414,7 +587,15 @@ async function streamAIResponse( ({ stepNumber }) => { chain.finishAndPrint({ autoCollapse: true }) statusRow.setPhase("thinking") - statusRow.setStepCount(stepNumber ?? chain.thoughts.length) + const step = stepNumber ?? chain.thoughts.length + statusRow.setStepCount(step) + thinking.setStepCount(step) + }, + // Step budget notification: tells the status row and thinking + // display the max steps so they can render "step 3/8". + (maxSteps) => { + statusRow.setMaxSteps(maxSteps) + thinking.setMaxSteps(maxSteps) }, ) @@ -530,6 +711,14 @@ async function streamAIResponse( } } + // Turn footer with elapsed time + const elapsedStr = + elapsed < 1000 ? `${elapsed}ms` : `${(elapsed / 1000).toFixed(1)}s` + const modeLabel = mode === "plan" ? "plan" : (mode === "chat" ? "chat" : "build") + console.log( + ` ${chalk.hex(theme.green)("▣")} ${chalk.hex(theme.greenMute)(modeLabel)} ${chalk.hex(theme.greenDim)("·")} ${chalk.hex(theme.muted)(provider.modelName)} ${chalk.hex(theme.greenDim)("·")} ${chalk.hex(theme.muted)(elapsedStr)}`, + ) + console.log() return { content: fullResponse, elapsed, @@ -628,7 +817,7 @@ let streamAbort: AbortController | null = null // The currently-streaming ThoughtChain (or null between turns). Exposed at // module scope so the stdin keypress handler can hit Ctrl+T without // threading the chain through every helper. -let activeChain: { thoughts: { endTime: number | null }[]; togglePrinted: (i: number) => void } | null = null +let activeChain: { thoughts: { endTime: number | null; subThoughts: { collapsed: boolean }[] }[]; togglePrinted: (i: number) => void; reprintThought: (i: number) => void } | null = null let stdinInput = "" let stdinCursor = 0 let stdinMode = "chat" @@ -892,6 +1081,21 @@ function stdinKeypress(_str: string, key: any) { return } + // Ctrl+X — toggle the most recent sub-thought (Explore) on the last thought + // entry. Allows drill-down into subagent activity without expanding the main + // thought chain. + if (key.ctrl && key.name === "x" && process.stdout.isTTY && activeChain) { + const lastThought = activeChain.thoughts[activeChain.thoughts.length - 1] + if (lastThought && lastThought.endTime !== null && lastThought.subThoughts.length > 0) { + const lastSub = lastThought.subThoughts[lastThought.subThoughts.length - 1] + if (lastSub) { + lastSub.collapsed = !lastSub.collapsed + activeChain.reprintThought(activeChain.thoughts.length - 1) + } + } + return + } + if (key.name === "backspace") { if (key.meta) { const before = stdinInput.slice(0, stdinCursor) @@ -1221,6 +1425,27 @@ function keyToPermissionReply( return undefined } +// Strip raw tool call XML that some providers (notably Kimi and certain +// Anthropic-compatible proxies) leak into the text stream alongside +// structured tool calls. The pattern looks like: +// <|tool_calls_section_begin|><|tool_call_begin|>functions.:<|tool_call_argument_begin|>... +// When a chunk contains any <|tool_|> markers, treat the ENTIRE chunk as +// tool call markup and drop it — real user-facing text is never mixed with +// raw tool call XML. +function stripToolCallXml(chunk: string): string { + if (!chunk) return "" + if (!chunk.includes("<|tool_") && !chunk.includes("<|tool_call_begin|>functions.read_file:0<|tool_call_argument_begin|>... + if (/<\|tool_calls_section_begin\|>/.test(chunk)) return "" + // If the chunk has individual tool call tags but no text beyond them, drop it. + const stripped = chunk.replace(/<\|[^|]+\|>/g, "").replace(/[^<]*<\/function>/g, "").trim() + if (!stripped) return "" + // Some remaining text — only strip the tags, keep any actual content. + return stripped +} + async function chatInput(currentMode: string): Promise<{ input: string; mode: string }> { stdinMode = modes.includes(currentMode) ? currentMode : "chat" applyModePermissions(stdinMode) @@ -1288,6 +1513,40 @@ export async function chatLoop( let lastUsage: { promptTokens?: number; completionTokens?: number; totalTokens?: number } | undefined = undefined let lastElapsed: number | undefined = undefined + // Auto-compaction threshold: if accumulated tokens exceed 75% of context window, + // automatically run compaction to avoid hitting the limit mid-conversation. + const COMPACT_THRESHOLD = 0.75 + + async function maybeCompactConversation(id: string) { + const total = sessionTokens + (lastUsage?.totalTokens ?? 0) + if (total < contextWindow * COMPACT_THRESHOLD) return + if (contextWindow <= 0) return + process.stdout.write( + ` ${chalk.hex(theme.amber)("◆")} ${chalk.hex(theme.muted)(`token usage at ${Math.round((total / contextWindow) * 100)}% — auto-compacting`)}\r\n`, + ) + try { + const { compactCommand } = await import("src/cli/commands/slashCommands/compact.ts") + await compactCommand({ + provider, + conversationId: id, + getMessages: async (cid) => { + const msgs = await getMessages(cid) + return msgs.map((m: any) => ({ + role: typeof m.role === "string" ? m.role : "user", + content: typeof m.content === "string" ? m.content : JSON.stringify(m.content), + })) + }, + saveSummary: async (cid, summary) => { + await addMessage(cid, "system", `[compaction] ${summary}`) + }, + }) + sessionTokens = 0 // reset after compaction + process.stdout.write(` ${chalk.hex(theme.green)("◆")} ${chalk.hex(theme.muted)("compaction complete")}\r\n`) + } catch { + // Non-fatal — compact is best-effort + } + } + // Persistent footer bar (matches OpenCode's always-there status line). // The bar reserves the row immediately below the prompt so it stays anchored // through every keystroke, every tool call, and every response. @@ -1476,7 +1735,7 @@ export async function chatLoop( if (aiResult.aborted) { process.stdout.write(` ${chalk.hex(theme.muted)("response aborted")}\r\n\n`) } - footer.render() + footer.renderLine() } catch (err: any) { const msg = err.message || String(err) process.stdout.write(`\r\n ${chalk.hex(theme.red)("◆")} ${chalk.hex(theme.red)(msg)}\r\n\n`) @@ -1541,6 +1800,7 @@ export async function chatLoop( await addMessage(conversation.id, "assistant", agentResult.content) lastUsage = agentResult.usage lastElapsed = agentResult.elapsed + await maybeCompactConversation(conversation.id) continue } } @@ -1566,6 +1826,7 @@ export async function chatLoop( lastUsage = result.usage lastElapsed = result.elapsed + await maybeCompactConversation(conversation.id) } catch (error: any) { const errMsg = error?.message ?? "Unknown error" process.stdout.write(`\r\n ${chalk.hex(theme.red)("◆")} ${chalk.hex(theme.red)(errMsg)}\r\n\n`) diff --git a/apps/supercode-cli/server/src/cli/ai/chat/chatAgent.ts b/apps/supercode-cli/server/src/cli/ai/chat/chatAgent.ts index 2cadd0b..ee94b14 100644 --- a/apps/supercode-cli/server/src/cli/ai/chat/chatAgent.ts +++ b/apps/supercode-cli/server/src/cli/ai/chat/chatAgent.ts @@ -6,11 +6,11 @@ import { MarkdownStream } from "src/cli/utils/markdown-stream" import { getStoredToken } from "src/lib/token" import { ChatService } from "src/service/chat-service" import { createProvider, type ModelProvider } from "src/cli/ai/provider" -import { createAppAgent } from "src/config/agent-config" import { type WorkspaceInfo } from "src/cli/workspace/scanner" -import { agentService } from "src/agent" +import { agentService, loadPrompt } from "src/agent" import { buildSystemPrompt } from "src/cli/workspace/context" import { ThinkingDisplay, ThoughtChain } from "src/cli/ai/chat/thinking" +import { tools } from "src/tools/registry" let _chatService: ChatService @@ -121,7 +121,10 @@ async function agentLoop( const startTime = Date.now() try { - const agent = createAppAgent(model, agentSystemPrompt) + const buildAgent = agentService.get("build") + if (!buildAgent?.generate) { + throw new Error("build agent not available") + } // Collapsed-thought pattern (matches chat mode + opencode TUI): // accumulate tool calls + reasoning into a ThoughtChain during @@ -133,9 +136,12 @@ async function agentLoop( const seenToolCalls = new Set() let accumulatedText = "" - const result = await agent.generate({ + const result = await buildAgent.generate({ + model, + tools: { ...tools }, + system: agentSystemPrompt, prompt: userInput, - onStepFinish: async ({ stepNumber, text, toolCalls, finishReason }) => { + onStepFinish: async ({ stepNumber, text, toolCalls, finishReason }: any) => { // Reasoning text arrived with this step — feed it into the chain. if (text) { chain.begin() diff --git a/apps/supercode-cli/server/src/cli/ai/chat/step-status-row.ts b/apps/supercode-cli/server/src/cli/ai/chat/step-status-row.ts index 7acdb0b..2ad18d7 100644 --- a/apps/supercode-cli/server/src/cli/ai/chat/step-status-row.ts +++ b/apps/supercode-cli/server/src/cli/ai/chat/step-status-row.ts @@ -22,6 +22,17 @@ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", " export type StepPhase = "thinking" | "tool" | "streaming" | "idle" +// Default step budget — matches stepCountIs(8) in concentrate-service. +const DEFAULT_MAX_STEPS = 8 +// Safety timeout in concentrate-service is 120s. Warning thresholds: +// 60s — model may be overloaded +// 90s — approaching timeout +// 110s — critical (10s remaining) +const TIMEOUT_HEADS_UP_MS = 60_000 +const TIMEOUT_WARNING_MS = 90_000 +const TIMEOUT_CRITICAL_MS = 110_000 +const SAFETY_TIMEOUT_MS = 120_000 + export class StepStatusRow { private running = false private cols = 80 @@ -34,7 +45,13 @@ export class StepStatusRow { private currentToolName = "" private currentToolArgs: unknown = undefined private stepCount = 0 + private maxSteps = DEFAULT_MAX_STEPS private startMs = 0 + private totalStartMs = 0 + // Per-tool timing: reset each time setCurrentTool is called + private toolStartMs = 0 + // Simple ETA: rolling average of tool durations per tool type (ms) + private toolDurationHistory = new Map() start(agentName: string, modelName: string) { if (this.running) return @@ -45,7 +62,10 @@ export class StepStatusRow { this.currentToolName = "" this.currentToolArgs = undefined this.stepCount = 0 + this.maxSteps = DEFAULT_MAX_STEPS this.startMs = Date.now() + this.totalStartMs = Date.now() + this.toolStartMs = 0 this.cols = process.stdout.columns ?? 80 this.frameIndex = 0 @@ -63,10 +83,14 @@ export class StepStatusRow { }, 100) } + setMaxSteps(n: number) { + this.maxSteps = n + this.render() + } + setPhase(phase: StepPhase) { this.phase = phase if (this.running && phase === "thinking") { - // Reset elapsed when starting a fresh step. this.startMs = Date.now() this.currentToolName = "" this.currentToolArgs = undefined @@ -75,9 +99,19 @@ export class StepStatusRow { } setCurrentTool(name: string, args?: unknown) { + // Record elapsed for the previous tool before switching + if (this.currentToolName && this.toolStartMs > 0) { + const elapsed = Date.now() - this.toolStartMs + const history = this.toolDurationHistory.get(this.currentToolName) ?? [] + history.push(elapsed) + // Keep only last 5 entries + if (history.length > 5) history.shift() + this.toolDurationHistory.set(this.currentToolName, history) + } this.currentToolName = name this.currentToolArgs = args this.phase = "tool" + this.toolStartMs = Date.now() this.render() } @@ -114,6 +148,7 @@ export class StepStatusRow { const frame = FRAMES[this.frameIndex] ?? "⠋" const elapsed = this.elapsed() + const totalElapsedMs = Date.now() - this.totalStartMs const phaseLabel = this.phaseLabel() const dots = " " + chalk.hex(theme.greenDim)("·") + " " @@ -128,17 +163,44 @@ export class StepStatusRow { const argStr = arg ? chalk.hex(theme.greenMute)(arg) : "" parts.push(chalk.hex(theme.greenDim)("→")) parts.push(argStr ? `${label} ${argStr}` : label) + // Per-tool elapsed time + if (this.toolStartMs > 0) { + const toolElapsed = Date.now() - this.toolStartMs + const toolElapsedStr = toolElapsed < 1000 ? `${toolElapsed}ms` : `${(toolElapsed / 1000).toFixed(1)}s` + parts.push(chalk.hex(theme.muted)(toolElapsedStr)) + } + // Simple ETA: average of past durations × estimated remaining steps + const estimatedRemaining = this.estimateRemaining() + if (estimatedRemaining > 0) { + parts.push(chalk.hex(theme.greenDim)(`~${estimatedRemaining}`)) + } } else if (this.agentName) { - parts.push(chalk.hex(theme.greenMute)( - `▣ ${this.agentName}${this.modelName ? " · " + this.modelName : ""}`, - )) + // "Model processing" heartbeat label when waiting for model response + const modelLabel = this.stepCount > 0 + ? `Model processing tool result` + : `▣ ${this.agentName}${this.modelName ? " · " + this.modelName : ""}` + parts.push(chalk.hex(theme.greenMute)(modelLabel)) } else if (this.modelName) { parts.push(chalk.hex(theme.greenGlow)(this.modelName)) } if (this.stepCount > 0) { parts.push(chalk.hex(theme.greenDim)("step")) - parts.push(chalk.hex(theme.greenGlow)(String(this.stepCount))) + parts.push(chalk.hex(theme.greenGlow)( + this.maxSteps > 0 ? `${this.stepCount}/${this.maxSteps}` : String(this.stepCount), + )) + } + + // Timeout proximity warning: escalating levels of urgency. + if (totalElapsedMs >= TIMEOUT_CRITICAL_MS) { + const remaining = Math.max(0, SAFETY_TIMEOUT_MS - totalElapsedMs) + parts.push(chalk.hex(theme.red)(`⏱ ${(remaining / 1000).toFixed(0)}s left`)) + } else if (totalElapsedMs >= TIMEOUT_WARNING_MS) { + const remaining = Math.max(0, SAFETY_TIMEOUT_MS - totalElapsedMs) + parts.push(chalk.hex(theme.amber)(`⚠ approaching timeout · ${(remaining / 1000).toFixed(0)}s`)) + } else if (totalElapsedMs >= TIMEOUT_HEADS_UP_MS) { + const elapsedSec = totalElapsedMs / 1000 + parts.push(chalk.hex(theme.amber)(`(!) ${elapsedSec.toFixed(0)}s elapsed — model may be overloaded`)) } const inner = parts.join(dots) @@ -153,6 +215,26 @@ export class StepStatusRow { process.stdout.write("\x1b8") } + // Estimate remaining time based on average per-tool history × remaining steps + private estimateRemaining(): string { + if (this.toolDurationHistory.size === 0) return "" + let totalAvg = 0 + let count = 0 + for (const durations of this.toolDurationHistory.values()) { + if (durations.length > 0) { + totalAvg += durations.reduce((a, b) => a + b, 0) / durations.length + count++ + } + } + if (count === 0) return "" + const avgToolMs = totalAvg / count + const remainingSteps = Math.max(0, this.maxSteps - this.stepCount) + const estimatedMs = avgToolMs * remainingSteps + if (estimatedMs < 1000) return "" + if (estimatedMs < 60_000) return `~${(estimatedMs / 1000).toFixed(0)}s` + return `~${Math.round(estimatedMs / 60_000)}m ${Math.round((estimatedMs % 60_000) / 1000)}s` + } + private phaseLabel(): string { switch (this.phase) { case "thinking": diff --git a/apps/supercode-cli/server/src/cli/ai/chat/thinking.ts b/apps/supercode-cli/server/src/cli/ai/chat/thinking.ts index 9ae75ec..883b445 100644 --- a/apps/supercode-cli/server/src/cli/ai/chat/thinking.ts +++ b/apps/supercode-cli/server/src/cli/ai/chat/thinking.ts @@ -29,55 +29,110 @@ export function extractToolArg(toolName: string, args: unknown): string | undefi export function toolLabel(toolName: string, args?: unknown): string { const arg = extractToolArg(toolName, args) - const { chip, verb, color } = chipFor(toolName) - const chipStr = chalk.bgHex(color).hex("#0d1117").bold(` ${chip} `) + const { verb, color } = describeTool(toolName, arg) if (arg) { - return ` ${chipStr} ${chalk.hex(theme.greenMute)(truncateStr(arg, 80))}` + return `${chalk.hex(color)(verb)}` } if (args && typeof args === "object" && Object.keys(args).length > 0) { const json = JSON.stringify(args).slice(0, 80) - return ` ${chipStr} ${chalk.hex(theme.muted)(json)}` + return `${chalk.hex(color)(verb)} ${chalk.hex(theme.muted)(json)}` } - const errChip = chalk.bgHex("#5a1a1a").hex("#ffffff").bold(` ${chip} `) - return ` ${errChip} ${chalk.hex(theme.red)(`(${verb} missing arguments — model bug)`)}` + return `${chalk.hex(theme.red)(`(${toolName} missing arguments — model bug)`)}` } -// Per-tool chip label + accent color. Mirrors the design language in -// apps/supercode-cli/plan/feat/cli/cmd.png. -function chipFor(toolName: string): { chip: string; verb: string; color: string } { - switch (toolName) { - case "write_file": - return { chip: "WRITE", verb: "write", color: "#1f3a2c" } +// ─── Tool-type sections ──────────────────────────────────────────────────── +// +// Tools are classified into thematic categories so the expanded thought block +// can group e.g. all read_file calls under "Files read [N]:". +// +export type ToolCategory = "read" | "edit" | "web" | "command" | "meta" + +export const CATEGORY_ORDER: ToolCategory[] = ["read", "command", "edit", "web", "meta"] + +export const CATEGORY_LABELS: Record = { + read: "Files read", + edit: "Changes", + web: "Web searches", + command: "Commands", + meta: "Agent", +} + +export const CATEGORY_COLORS: Record = { + read: "#7a8a82", + edit: "#f0b87c", + web: "#5ec27e", + command: "#a5d6ff", + meta: theme.muted, +} + +export function categorizeTool(name: string): ToolCategory { + switch (name) { + case "read_file": + case "search_files": + case "glob": + return "read" case "edit_file": - return { chip: "EDIT", verb: "edit", color: "#3a2e1a" } + case "write_file": + return "edit" + case "web_search": + case "url_fetch": + case "firecrawl_search": + case "firecrawl_scrape": + case "firecrawl_map": + return "web" + case "run_command": + case "code_exec": + return "command" + default: + return "meta" + } +} + +export function groupToolsByCategory(tools: ThoughtTool[]): Map { + const map = new Map() + for (const t of tools) { + const cat = categorizeTool(t.name) + const arr = map.get(cat) ?? [] + arr.push(t) + map.set(cat, arr) + } + return map +} + +function describeTool(toolName: string, arg?: string): { verb: string; color: string } { + switch (toolName) { case "read_file": - return { chip: "READ", verb: "read", color: "#2a2440" } + return { verb: arg ? `Read ${arg}` : "Read file", color: "#7a8a82" } + case "edit_file": + return { verb: arg ? `Edit ${arg}` : "Edit file", color: "#f0b87c" } + case "write_file": + return { verb: arg ? `Write ${arg}` : "Write file", color: "#7ee2a8" } case "search_files": - return { chip: "GREP", verb: "search", color: "#2a2440" } + return { verb: arg ? `Search ${arg}` : "Search files", color: "#7a8a82" } case "url_fetch": - return { chip: "FETCH", verb: "fetch", color: "#2a2440" } + return { verb: arg ? `Fetch ${arg}` : "Fetch URL", color: "#7a8a82" } case "web_search": - return { chip: "SEARCH", verb: "search", color: "#2a2440" } + return { verb: arg ? `Search web for ${arg}` : "Search web", color: "#5ec27e" } case "firecrawl_search": - return { chip: "FC-SEARCH", verb: "search", color: "#1a3a2a" } + return { verb: arg ? `Search ${arg}` : "Search", color: "#5ec27e" } case "firecrawl_scrape": - return { chip: "FC-SCRAPE", verb: "scrape", color: "#1a3a2a" } + return { verb: arg ? `Scrape ${arg}` : "Scrape", color: "#5ec27e" } case "firecrawl_map": - return { chip: "FC-MAP", verb: "map", color: "#1a3a2a" } + return { verb: arg ? `Map ${arg}` : "Map site", color: "#5ec27e" } case "run_command": - return { chip: "BASH", verb: "run", color: "#1a2e3a" } + return { verb: arg ? `$ ${arg}` : "Run command", color: "#a5d6ff" } case "code_exec": - return { chip: "EXEC", verb: "exec", color: "#1a2e3a" } - case "switch_to_agent_mode": - return { chip: "MODE", verb: "switch", color: "#2a2440" } + return { verb: arg ? `Exec ${arg}` : "Execute code", color: "#a5d6ff" } case "delegate": - return { chip: "DELEGATE", verb: "delegate", color: "#2a2440" } + return { verb: arg ? `Delegate: ${arg}` : "Delegate", color: theme.muted } case "task": - return { chip: "TASK", verb: "task", color: "#2a2440" } + return { verb: arg ? `Task: ${arg}` : "Task", color: theme.muted } case "read_instructions": - return { chip: "INSTRUCT", verb: "read", color: "#2a2440" } + return { verb: "Read instructions", color: "#7a8a82" } + case "switch_to_agent_mode": + return { verb: arg ? `Switch to ${arg}` : "Switch mode", color: theme.muted } default: - return { chip: toolName.toUpperCase().slice(0, 8), verb: toolName, color: "#2a2440" } + return { verb: arg ? `${toolName} ${arg}` : toolName, color: "#7a8a82" } } } @@ -125,6 +180,8 @@ export class ThinkingDisplay { private toolCount = 0 // Once true, the spinner stays off — we already emitted the assistant header. private headerEmitted = false + private stepNumber = 0 + private maxSteps = 8 markHeaderEmitted() { this.headerEmitted = true @@ -187,14 +244,27 @@ export class ThinkingDisplay { this.chain.begin() } + setStepCount(step: number) { + this.stepNumber = step + if (this.running) this.refreshLabel() + } + + setMaxSteps(n: number) { + this.maxSteps = n + if (this.running) this.refreshLabel() + } + private refreshLabel() { if (!this.running) return const elapsed = Date.now() - this.thoughtStartTime const time = elapsed < 1000 ? `${elapsed}ms` : `${(elapsed / 1000).toFixed(1)}s` + const stepStr = this.stepNumber > 0 + ? `Step ${this.stepNumber}/${this.maxSteps} · ` + : "" if (this.currentPhase === "tool") { - this.currentLabel = `${this.currentToolName} · ${time}` + this.currentLabel = `${stepStr}${this.currentToolName} · ${time}` } else { - this.currentLabel = `Thinking · ${time}` + this.currentLabel = `${stepStr}Waiting for model · ${time}` } this.renderSpinner() } @@ -359,11 +429,15 @@ export interface ThoughtTool { // Drives the red ✗ marker in the live per-step block and forces the block // to stay expanded on finish so the user can see what failed. flagged?: boolean + // Snapshot lines (e.g. diff, file content, command output) captured at tool + // completion time, rendered when the thought block is expanded. + snapshot?: string[] } export interface ThoughtEntry { body: string tools: ThoughtTool[] + subThoughts: ThoughtEntry[] startTime: number endTime: number | null collapsed: boolean @@ -382,13 +456,23 @@ export interface ThoughtEntry { export class ThoughtChain { thoughts: ThoughtEntry[] = [] - private current: ThoughtEntry | null = null + current: ThoughtEntry | null = null + private buffered: boolean + + constructor(buffered = false) { + this.buffered = buffered + } + + get isOpen(): boolean { + return this.current?.openInProgress ?? false + } begin(): ThoughtEntry { if (this.current) this.finish() const entry: ThoughtEntry = { body: "", tools: [], + subThoughts: [], startTime: Date.now(), endTime: null, collapsed: false, @@ -455,34 +539,38 @@ export class ThoughtChain { // All three are no-ops when stdout isn't a TTY (the chat loop already // captures the alternative plain-text path via printUnified). beginAndPrint(): ThoughtEntry | null { - if (!process.stdout.isTTY) return null + if (!this.buffered && !process.stdout.isTTY) return null const entry = this.begin() entry.openInProgress = true - const elapsedStr = "0.0s" - const indent = chalk.hex(theme.greenDim)("┃") - const toggle = chalk.hex(theme.greenGlow)("▼") - const label = chalk.hex(theme.greenMute)("Thought") - const time = chalk.hex(theme.greenDim)(elapsedStr) - process.stdout.write( - `${indent} ${toggle} ${label} ${chalk.hex(theme.greenDim)("·")} ${time}\n`, - ) + if (!this.buffered) { + const elapsedStr = "0.0s" + const indent = chalk.hex(theme.greenDim)("┃") + const toggle = chalk.hex(theme.greenGlow)("▼") + const label = chalk.hex(theme.greenMute)("Thought") + const time = chalk.hex(theme.greenDim)(elapsedStr) + process.stdout.write( + `${indent} ${toggle} ${label} ${chalk.hex(theme.greenDim)("·")} ${time}\n`, + ) + } entry.printed = true return entry } printToolRow(name: string, args?: unknown, flagged = false): void { - if (!process.stdout.isTTY) return + if (!this.buffered && !process.stdout.isTTY) return if (!this.current || !this.current.openInProgress) return - const indent = chalk.hex(theme.greenDim)("┃") - const argsSerialized = - typeof args === "string" ? safeParse(args) : args - const row = toolLabel(name, argsSerialized) - // Red ✗ marker after the tool label when the call was denied or returned - // empty/error. Small visual hint that this row is the failure case. - const marker = flagged - ? ` ${chalk.hex(theme.red)("✗")}` - : "" - process.stdout.write(`${indent} ${row}${marker}\n`) + if (!this.buffered) { + const indent = chalk.hex(theme.greenDim)("┃") + const argsSerialized = + typeof args === "string" ? safeParse(args) : args + const row = toolLabel(name, argsSerialized) + // Red ✗ marker after the tool label when the call was denied or returned + // empty/error. Small visual hint that this row is the failure case. + const marker = flagged + ? ` ${chalk.hex(theme.red)("✗")}` + : "" + process.stdout.write(`${indent} ${row}${marker}\n`) + } // Keep entry.tools in sync so Ctrl+T toggle re-render matches what we // just printed. const serializedArgs = @@ -494,7 +582,7 @@ export class ThoughtChain { } finishAndPrint(opts?: { autoCollapse?: boolean; keepExpanded?: boolean }): void { - if (!process.stdout.isTTY) return + if (!this.buffered && !process.stdout.isTTY) return if (!this.current) return const entry = this.current entry.endTime = Date.now() @@ -506,44 +594,196 @@ export class ThoughtChain { const elapsedStr = elapsedMs < 1000 ? `${elapsedMs}ms` : `${(elapsedMs / 1000).toFixed(1)}s` - const indent = chalk.hex(theme.greenDim)("┃") - // Toggle: when auto-collapsing, move ▼ to +. When keeping open, keep ▼. - const toggle = entry.collapsed - ? chalk.hex(theme.greenGlow)("+") - : chalk.hex(theme.greenGlow)("▼") - const label = chalk.hex(theme.greenMute)("Thought") - const time = chalk.hex(theme.greenDim)(elapsedStr) - process.stdout.write( - `${indent} ${toggle} ${label} ${chalk.hex(theme.greenDim)("·")} ${time}\n`, - ) + if (!this.buffered) { + const indent = chalk.hex(theme.greenDim)("┃") + // Toggle: when auto-collapsing, move ▼ to +. When keeping open, keep ▼. + const toggle = entry.collapsed + ? chalk.hex(theme.greenGlow)("+") + : chalk.hex(theme.greenGlow)("▼") + const label = chalk.hex(theme.greenMute)("Thought") + const time = chalk.hex(theme.greenDim)(elapsedStr) + process.stdout.write( + `${indent} ${toggle} ${label} ${chalk.hex(theme.greenDim)("·")} ${time}\n`, + ) - // When auto-collapsed we show a summary line; when expanded we reprint - // each tool so the user can see exactly what was called (with ✗ markers - // for denied/empty rows). - if (entry.collapsed) { - if (entry.tools.length > 0) { - const okCount = entry.tools.filter((t) => !t.flagged).length - const flaggedCount = entry.tools.filter((t) => t.flagged).length - const summary = - flaggedCount > 0 - ? `${okCount} ok · ${chalk.hex(theme.red)(`${flaggedCount} failed`)}` - : `${entry.tools.length} tool call${entry.tools.length === 1 ? "" : "s"}` + // When auto-collapsed we show a summary line; when expanded we reprint + // each tool so the user can see exactly what was called (with ✗ markers + // for denied/empty rows), followed by snapshots and sub-thoughts. + if (entry.collapsed) { + this.writeCollapsedSummary(entry, indent) + } else { + this.writeExpandedDetail(entry, indent) + } + } + entry.printed = true + this.current = null + } + + private writeCollapsedSummary(entry: ThoughtEntry, indent: string): void { + const subCount = entry.subThoughts.length + if (entry.tools.length > 0) { + // Per-category summary counts + const groups = groupToolsByCategory(entry.tools) + const parts: string[] = [] + let hasFailures = false + for (const cat of CATEGORY_ORDER) { + const tools = groups.get(cat) + if (!tools || tools.length === 0) continue + const label = CATEGORY_LABELS[cat].toLowerCase() + const flaggedCount = tools.filter((t) => t.flagged).length + if (flaggedCount > 0) { + const okCount = tools.length - flaggedCount + if (okCount > 0) { + parts.push(`${okCount} ${label}`) + } + parts.push(`${chalk.hex(theme.red)(`${flaggedCount} failed`)}`) + hasFailures = true + } else { + parts.push(`${tools.length} ${label}`) + } + } + const summary = hasFailures + ? parts.join(" · ") + : parts.join(" · ") + const hint = subCount > 0 + ? ` ${chalk.hex(theme.greenDim)("[Ctrl+X]")}` + : ` ${chalk.hex(theme.greenDim)("[Ctrl+T]")}` + process.stdout.write( + `${indent} ${chalk.hex(theme.greenDim)("↳")} ${chalk.hex(theme.greenMute)(summary)}${hint}\n`, + ) + } + if (subCount > 0) { + const subToolCount = entry.subThoughts.reduce((n, s) => n + s.tools.length, 0) + process.stdout.write( + `${indent} ${chalk.hex(theme.greenDim)("↳")} ${chalk.hex(theme.greenMute)(`${subCount} explore step${subCount === 1 ? "" : "s"} · ${subToolCount} tool call${subToolCount === 1 ? "" : "s"}`)}\n`, + ) + } + } + + private writeExpandedDetail(entry: ThoughtEntry, indent: string): void { + // Tool rows grouped by category + const groups = groupToolsByCategory(entry.tools) + let groupIdx = 0 + for (const cat of CATEGORY_ORDER) { + const tools = groups.get(cat) + if (!tools || tools.length === 0) continue + if (groupIdx > 0) process.stdout.write("\n") + groupIdx++ + // Section header only when 2+ tools in the group + if (tools.length > 1) { + const label = CATEGORY_LABELS[cat] + const color = CATEGORY_COLORS[cat] process.stdout.write( - `${indent} ${chalk.hex(theme.greenDim)("↳")} ${chalk.hex(theme.greenMute)(summary)}\n`, + `${indent} ${chalk.hex(color)(`${label} [${tools.length}]:`)}\n`, ) } - } else { - // Expanded — list each tool row, with ✗ for flagged ones. - for (const t of entry.tools) { + for (const t of tools) { const argsParsed = typeof t.args === "string" ? safeParse(t.args) : t.args - const row = toolLabel(t.name, argsParsed) + const { verb, color } = describeTool( + t.name, + extractToolArg(t.name, argsParsed), + ) const marker = t.flagged ? ` ${chalk.hex(theme.red)("✗")}` : "" - process.stdout.write(`${indent} ${row}${marker}\n`) + process.stdout.write(`${indent} ${chalk.hex(color)(verb)}${marker}\n`) + if (t.snapshot) { + for (const snapLine of t.snapshot) { + process.stdout.write(snapLine + "\n") + } + } } } - entry.printed = true - this.current = null + // Blank line before sub-thoughts if there are any tools shown + if (entry.tools.length > 0 && entry.subThoughts.length > 0) process.stdout.write("\n") + // Sub-thoughts nested under this entry + this.writeSubThoughts(entry.subThoughts, indent) + } + + private writeSubThoughts(subThoughts: ThoughtEntry[], indent: string): void { + for (const sub of subThoughts) { + const subElapsed = sub.endTime + ? sub.endTime - sub.startTime + : 0 + const subElapsedStr = + subElapsed < 1000 ? `${subElapsed}ms` : `${(subElapsed / 1000).toFixed(1)}s` + + const subToggle = sub.collapsed + ? chalk.hex(theme.greenGlow)("+") + : chalk.hex(theme.greenGlow)("▼") + const subLabel = chalk.hex(theme.greenMute)("Explore") + process.stdout.write( + `${indent} ${subToggle} ${subLabel} ${chalk.hex(theme.greenDim)("·")} ${subElapsedStr}\n`, + ) + + if (sub.collapsed) { + // Summary line for collapsed sub-thought — per-category counts + const groups = groupToolsByCategory(sub.tools) + const parts: string[] = [] + let hasFailures = false + for (const cat of CATEGORY_ORDER) { + const tools = groups.get(cat) + if (!tools || tools.length === 0) continue + const label = CATEGORY_LABELS[cat].toLowerCase() + const flaggedCount = tools.filter((t) => t.flagged).length + if (flaggedCount > 0) { + const okCount = tools.length - flaggedCount + if (okCount > 0) parts.push(`${okCount} ${label}`) + parts.push(`${chalk.hex(theme.red)(`${flaggedCount} failed`)}`) + hasFailures = true + } else { + parts.push(`${tools.length} ${label}`) + } + } + const summary = parts.length > 0 ? parts.join(" · ") : `${sub.tools.length} tool call${sub.tools.length === 1 ? "" : "s"}` + process.stdout.write( + `${indent} ${chalk.hex(theme.greenDim)("↳")} ${chalk.hex(theme.greenMute)(summary)}\n`, + ) + } else { + // Expanded sub-thought — tool rows with snapshots (grouped by category) + const groups = groupToolsByCategory(sub.tools) + let groupIdx = 0 + for (const cat of CATEGORY_ORDER) { + const tools = groups.get(cat) + if (!tools || tools.length === 0) continue + if (groupIdx > 0) process.stdout.write("\n") + groupIdx++ + if (tools.length > 1) { + const label = CATEGORY_LABELS[cat] + const color = CATEGORY_COLORS[cat] + process.stdout.write( + `${indent} ${chalk.hex(color)(`${label} [${tools.length}]:`)}\n`, + ) + } + for (const t of tools) { + const argsParsed = + typeof t.args === "string" ? safeParse(t.args) : t.args + const { verb, color } = describeTool( + t.name, + extractToolArg(t.name, argsParsed), + ) + const marker = t.flagged ? ` ${chalk.hex(theme.red)("✗")}` : "" + process.stdout.write(`${indent} ${chalk.hex(color)(verb)}${marker}\n`) + if (t.snapshot) { + for (const snapLine of t.snapshot) { + process.stdout.write(snapLine + "\n") + } + } + } + } + } + } + } + + // Re-render a printed thought block in-place without changing its collapsed + // state. Used by hotkeys that toggle sub-thoughts (Ctrl+X) and need to + // refresh the parent without double-toggling it. + reprintThought(index: number = this.thoughts.length - 1): void { + if (!process.stdout.isTTY) return + const entry = this.thoughts[index] + if (!entry) return + if (entry.endTime === null) return + const out = this.renderThought(entry) + process.stdout.write(out + "\n") } // Toggle the chevron of the most-recently printed thought by re-emitting @@ -552,12 +792,9 @@ export class ThoughtChain { if (!process.stdout.isTTY) return const entry = this.thoughts[index] if (!entry) return - if (entry.endTime === null) return // can't toggle an open step + if (entry.endTime === null) return entry.collapsed = !entry.collapsed - const out = this.renderThought(entry) - // renderThought doesn't write to stdout; emit it. The hotkey handler - // is responsible for clearing the previous block first using line math. - process.stdout.write(out + "\n") + this.reprintThought(index) } get elapsed(): number { @@ -589,7 +826,6 @@ export class ThoughtChain { ? chalk.hex(theme.greenDim)("▶") : chalk.hex(theme.greenGlow)("▼") const header = `${toggleIcon} ${chalk.hex(theme.greenMute)("Thought")}${chalk.hex(theme.greenDim)(":")} ${chalk.hex(theme.greenGlow)(elapsed)}` - const prefix = chalk.hex(theme.greenDim)("┃") const indent = chalk.hex(theme.greenDim)("┃") const lines: string[] = [] @@ -611,15 +847,77 @@ export class ThoughtChain { } } - // Tool calls made during this thought - if (entry.tools.length > 0) { - const grouped = this.groupTools(entry.tools) - for (const [toolName, argsList] of grouped) { - const lastArg = argsList[argsList.length - 1] - if (argsList.length <= 1) { - lines.push(`${indent} ${toolLabel(toolName, lastArg ? JSON.parse(lastArg) : undefined)}`) - } else { - lines.push(`${indent} ${formatToolGroup(toolName, argsList.length, lastArg)}`) + // Tool calls made during this thought — grouped by category + const groups = groupToolsByCategory(entry.tools) + let groupIdx = 0 + for (const cat of CATEGORY_ORDER) { + const tools = groups.get(cat) + if (!tools || tools.length === 0) continue + if (groupIdx > 0) lines.push("") + groupIdx++ + if (tools.length > 1) { + const label = CATEGORY_LABELS[cat] + const color = CATEGORY_COLORS[cat] + lines.push(`${indent} ${chalk.hex(color)(`${label} [${tools.length}]:`)}`) + } + for (const t of tools) { + const argsParsed = + typeof t.args === "string" ? safeParse(t.args) : t.args + const { verb, color } = describeTool( + t.name, + extractToolArg(t.name, argsParsed), + ) + const marker = t.flagged ? ` ${chalk.hex(theme.red)("✗")}` : "" + lines.push(`${indent} ${chalk.hex(color)(verb)}${marker}`) + if (t.snapshot) { + for (const snapLine of t.snapshot) { + lines.push(snapLine) + } + } + } + } + + // Sub-thoughts nested under this entry + if (entry.subThoughts.length > 0) { + if (entry.tools.length > 0) lines.push("") + for (const sub of entry.subThoughts) { + const subElapsed = sub.endTime + ? `${sub.endTime - sub.startTime}ms` + : `${Date.now() - sub.startTime}ms` + const subToggle = sub.collapsed + ? chalk.hex(theme.greenDim)("▶") + : chalk.hex(theme.greenGlow)("▼") + const subHeader = `${subToggle} ${chalk.hex(theme.greenMute)("Explore")}${chalk.hex(theme.greenDim)(":")} ${chalk.hex(theme.greenGlow)(subElapsed)}` + lines.push(`${indent} ${subHeader}`) + + if (!sub.collapsed) { + const subGroups = groupToolsByCategory(sub.tools) + let subGroupIdx = 0 + for (const cat of CATEGORY_ORDER) { + const tools = subGroups.get(cat) + if (!tools || tools.length === 0) continue + if (subGroupIdx > 0) lines.push("") + subGroupIdx++ + if (tools.length > 1) { + const label = CATEGORY_LABELS[cat] + const color = CATEGORY_COLORS[cat] + lines.push(`${indent} ${chalk.hex(color)(`${label} [${tools.length}]:`)}`) + } + for (const t of tools) { + const argsParsed = + typeof t.args === "string" ? safeParse(t.args) : t.args + const { verb, color } = describeTool( + t.name, + extractToolArg(t.name, argsParsed), + ) + lines.push(`${indent} ${chalk.hex(color)(verb)}`) + if (t.snapshot) { + for (const snapLine of t.snapshot) { + lines.push(snapLine) + } + } + } + } } } } @@ -687,16 +985,26 @@ export class ThoughtChain { const lines: string[] = [] lines.push(`${indent} ${toggle} ${label} ${chalk.hex(theme.greenDim)("·")} ${time}`) - // Tool count summary line — keeps the collapsed block informative. - const toolCount = this.thoughts.reduce((n, t) => n + t.tools.length, 0) - if (toolCount > 0) { - lines.push( - `${indent} ${chalk.hex(theme.greenDim)("↳")} ${chalk.hex(theme.greenMute)(`${toolCount} tool call${toolCount === 1 ? "" : "s"}`)}`, - ) + // Per-category tool counts — keeps the collapsed block informative. + const allTools = this.thoughts.flatMap((t) => t.tools) + if (allTools.length > 0) { + const allGroups = groupToolsByCategory(allTools) + const parts: string[] = [] + for (const cat of CATEGORY_ORDER) { + const tools = allGroups.get(cat) + if (!tools || tools.length === 0) continue + const label = CATEGORY_LABELS[cat].toLowerCase() + parts.push(`${tools.length} ${label}`) + } + if (parts.length > 0) { + lines.push( + `${indent} ${chalk.hex(theme.greenDim)("↳")} ${chalk.hex(theme.greenMute)(parts.join(" · "))}`, + ) + } } - // Expand the block: list each thought's reasoning + tool calls. Group - // consecutive identical tool calls so a tight loop doesn't spam the view. + // Expand the block: list each thought's reasoning + tool calls grouped + // by category. for (const thought of this.thoughts) { const body = thought.body.trim() if (body) { @@ -710,17 +1018,26 @@ export class ThoughtChain { } } if (thought.tools.length > 0) { - const grouped = this.groupTools(thought.tools) - for (const [toolName, argsList] of grouped) { - const lastArg = argsList[argsList.length - 1] - if (argsList.length <= 1) { - lines.push( - `${indent} ${toolLabel(toolName, lastArg ? JSON.parse(lastArg) : undefined)}`, - ) - } else { - lines.push( - `${indent} ${formatToolGroup(toolName, argsList.length, lastArg)}`, + const groups = groupToolsByCategory(thought.tools) + let groupIdx = 0 + for (const cat of CATEGORY_ORDER) { + const tools = groups.get(cat) + if (!tools || tools.length === 0) continue + if (groupIdx > 0) lines.push("") + groupIdx++ + if (tools.length > 1) { + const label = CATEGORY_LABELS[cat] + const color = CATEGORY_COLORS[cat] + lines.push(`${indent} ${chalk.hex(color)(`${label} [${tools.length}]:`)}`) + } + for (const t of tools) { + const argsParsed = + typeof t.args === "string" ? safeParse(t.args) : t.args + const { verb, color } = describeTool( + t.name, + extractToolArg(t.name, argsParsed), ) + lines.push(`${indent} ${chalk.hex(color)(verb)}`) } } } diff --git a/apps/supercode-cli/server/src/cli/ai/concentrate-service.ts b/apps/supercode-cli/server/src/cli/ai/concentrate-service.ts index 36bca04..1f04686 100644 --- a/apps/supercode-cli/server/src/cli/ai/concentrate-service.ts +++ b/apps/supercode-cli/server/src/cli/ai/concentrate-service.ts @@ -89,6 +89,7 @@ export class ConcentrateService { onReasoning?: (chunk: string) => void, onToolResult?: (params: { toolName: string; args: unknown; result: string }) => void, onStepFinish?: (params: { stepNumber: number; toolCalls: Array<{ toolName: string; args: unknown }>; toolResults: Array<{ toolName: string; args: unknown; result: string }> }) => void, + onStepBudget?: (maxSteps: number) => void, ) { // Build a combined abort controller with a 120s safety timeout. This // prevents the SDK's tool loop from hanging indefinitely when the model @@ -126,10 +127,16 @@ export class ConcentrateService { let fullResponse = "" let chunkCount = 0 - for await (const chunk of result.textStream) { - chunkCount++ - fullResponse += chunk - onChunk?.(chunk) + // Iterate fullStream to surface reasoning chunks alongside text. + for await (const event of result.fullStream) { + if (event.type === "text-delta") { + if (event.textDelta == null) continue + chunkCount++ + fullResponse += event.textDelta + onChunk?.(event.textDelta) + } else if (event.type === "reasoning") { + if (event.textDelta) onReasoning?.(event.textDelta) + } } // console.error(`[d] non-tools streamed ${chunkCount} chunks resp="${fullResponse}"`) @@ -194,7 +201,8 @@ export class ConcentrateService { } } - // console.error(`[d] tools path hit`) + // Notify the caller of the step budget so the UI can show "step 3/8". + onStepBudget?.(8) let fullResponse = "" @@ -301,10 +309,16 @@ export class ConcentrateService { }) let toolChunkCount = 0 - for await (const chunk of result.textStream) { - toolChunkCount++ - fullResponse += chunk - onChunk?.(chunk) + // Iterate fullStream to surface reasoning chunks alongside text. + for await (const event of result.fullStream) { + if (event.type === "text-delta") { + if (event.textDelta == null) continue + toolChunkCount++ + fullResponse += event.textDelta + onChunk?.(event.textDelta) + } else if (event.type === "reasoning") { + if (event.textDelta) onReasoning?.(event.textDelta) + } } // console.error(`[d] tools streamed ${toolChunkCount} chunks resp="${fullResponse}"`) diff --git a/apps/supercode-cli/server/src/cli/ai/provider.ts b/apps/supercode-cli/server/src/cli/ai/provider.ts index 4be5593..47f2206 100644 --- a/apps/supercode-cli/server/src/cli/ai/provider.ts +++ b/apps/supercode-cli/server/src/cli/ai/provider.ts @@ -27,6 +27,7 @@ export interface AIProvider { onReasoning?: (chunk: string) => void, onToolResult?: (params: { toolName: string; args: unknown; result: string }) => void, onStepFinish?: (params: { stepNumber: number; toolCalls: Array<{ toolName: string; args: unknown }>; toolResults: Array<{ toolName: string; args: unknown; result: string }> }) => void, + onStepBudget?: (maxSteps: number) => void, ): Promise<{ content: string finishReason: FinishReason @@ -61,8 +62,8 @@ export function createProvider(provider: ModelProvider, model?: string): AIProvi return { name: provider, modelName: model || meta.defaultModel, - sendMessage: (messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult) => - svc.sendMessage(messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult), + sendMessage: (messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult, onStepFinish, onStepBudget) => + svc.sendMessage(messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult, onStepFinish, onStepBudget), generateObject: (schema, prompt) => svc.generateObject(schema, prompt), } } @@ -93,8 +94,8 @@ export function createProvider(provider: ModelProvider, model?: string): AIProvi name: "nvidia", modelName: svc.modelName, model: svc.model, - sendMessage: (messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult) => - svc.sendMessage(messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult), + sendMessage: (messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult, onStepFinish, onStepBudget) => + svc.sendMessage(messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult, onStepFinish, onStepBudget), } } case "concentrateai": { @@ -103,8 +104,8 @@ export function createProvider(provider: ModelProvider, model?: string): AIProvi name: "concentrateai", modelName: svc.modelName, model: svc.model, - sendMessage: (messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult) => - svc.sendMessage(messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult), + sendMessage: (messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult, onStepFinish, onStepBudget) => + svc.sendMessage(messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult, onStepFinish, onStepBudget), } } case "mergedev": { @@ -113,8 +114,8 @@ export function createProvider(provider: ModelProvider, model?: string): AIProvi name: "mergedev", modelName: svc.modelName, model: svc.model, - sendMessage: (messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult) => - svc.sendMessage(messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult), + sendMessage: (messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult, onStepFinish, onStepBudget) => + svc.sendMessage(messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult, onStepFinish, onStepBudget), } } default: { diff --git a/apps/supercode-cli/server/src/cli/utils/tool-snapshot.ts b/apps/supercode-cli/server/src/cli/utils/tool-snapshot.ts index bf32b67..00fb56f 100644 --- a/apps/supercode-cli/server/src/cli/utils/tool-snapshot.ts +++ b/apps/supercode-cli/server/src/cli/utils/tool-snapshot.ts @@ -218,4 +218,90 @@ export function countDiff( return { adds, dels } } +/** + * Render a `read_file` snapshot — path + first N lines of content. + */ +export function renderReadSnapshot(path: string, content: string): string[] { + const lines: string[] = [] + lines.push(`${RAIL} ${chalk.hex("#7a8a82").bold(`📄 ${path}`)} ${chalk.hex(theme.muted)(`${content.split("\n").length} lines`)}`) + + const split = content.split("\n") + const padWidth = String(split.length).length + const visible = split.length > 16 ? split.slice(0, 16) : split + + for (let i = 0; i < visible.length; i++) { + const num = chalk.hex(theme.greenDim)(String(i + 1).padStart(padWidth, " ")) + const text = truncate(visible[i] ?? "", MAX_COLS) + lines.push(`${RAIL} ${num} ${text}`) + } + if (split.length > 16) { + lines.push(`${RAIL} ${SUB} ${chalk.hex(theme.muted)(`… ${split.length - 16} more lines`)}`) + } + return lines +} + +/** + * Render a `search_files` (grep) snapshot — matches grouped by file. + */ +export function renderSearchSnapshot( + query: string, + results: Array<{ file: string; line: number; content: string }>, + totalCount: number, +): string[] { + const lines: string[] = [] + lines.push(`${RAIL} ${chalk.hex("#7a8a82").bold(`🔍 ${query}`)} ${chalk.hex(theme.muted)(`${totalCount} match${totalCount === 1 ? "" : "es"} in ${new Set(results.map((r) => r.file)).size} file${new Set(results.map((r) => r.file)).size === 1 ? "" : "s"}`)}`) + + // Show up to 4 matches inline + const visible = results.slice(0, 4) + for (const r of visible) { + const loc = chalk.hex(theme.greenDim)(`${r.file}:${r.line}`) + const text = truncate(r.content, MAX_COLS) + lines.push(`${RAIL} ${loc} ${text}`) + } + if (results.length > 4) { + lines.push(`${RAIL} ${SUB} ${chalk.hex(theme.muted)(`… ${results.length - 4} more matches`)}`) + } + return lines +} + +/** + * Render a `glob` snapshot — matching file paths. + */ +export function renderGlobSnapshot(pattern: string, files: string[]): string[] { + const lines: string[] = [] + lines.push(`${RAIL} ${chalk.hex("#7a8a82").bold(`📁 ${pattern}`)} ${chalk.hex(theme.muted)(`${files.length} file${files.length === 1 ? "" : "s"}`)}`) + + const visible = files.slice(0, 6) + for (const f of visible) { + lines.push(`${RAIL} ${chalk.hex(theme.white)(f)}`) + } + if (files.length > 6) { + lines.push(`${RAIL} ${SUB} ${chalk.hex(theme.muted)(`… ${files.length - 6} more`)}`) + } + return lines +} + +/** + * Render a `web_search` snapshot — search result titles + URLs. + */ +export function renderWebSearchSnapshot( + query: string, + results: Array<{ title: string; url?: string }>, +): string[] { + const lines: string[] = [] + lines.push(`${RAIL} ${chalk.hex("#5ec27e").bold(`🌐 Search: ${query}`)} ${chalk.hex(theme.muted)(`${results.length} result${results.length === 1 ? "" : "s"}`)}`) + + const visible = results.slice(0, 4) + for (const r of visible) { + lines.push(`${RAIL} ${chalk.hex(theme.white)(truncate(r.title, MAX_COLS))}`) + if (r.url) { + lines.push(`${RAIL} ${chalk.hex(theme.muted)(truncate(r.url, MAX_COLS))}`) + } + } + if (results.length > 4) { + lines.push(`${RAIL} ${SUB} ${chalk.hex(theme.muted)(`… ${results.length - 4} more results`)}`) + } + return lines +} + export { formatBytes } \ No newline at end of file diff --git a/apps/supercode-cli/server/src/cli/utils/tui.ts b/apps/supercode-cli/server/src/cli/utils/tui.ts index 21535f4..6f064fe 100644 --- a/apps/supercode-cli/server/src/cli/utils/tui.ts +++ b/apps/supercode-cli/server/src/cli/utils/tui.ts @@ -890,7 +890,7 @@ export class PersistentStatusBar { this.update({ statusMessage: msg }) } - private renderLine() { + renderLine() { process.stdout.write("\x1b[2K") const leftBorder = ansiColor(theme.greenDim, "┃") diff --git a/apps/supercode-cli/server/src/tools/definitions/delegate.ts b/apps/supercode-cli/server/src/tools/definitions/delegate.ts index 3277a04..cf0e4a6 100644 --- a/apps/supercode-cli/server/src/tools/definitions/delegate.ts +++ b/apps/supercode-cli/server/src/tools/definitions/delegate.ts @@ -3,6 +3,7 @@ import type { LanguageModel, ToolSet } from "ai" import { agentService, loadPrompt } from "src/agent/index.ts" import type { GenerateOptions, GenerateResult } from "src/agent/agent.ts" import { writeScratch } from "src/lib/scratch.ts" +import { getCurrentAgent } from "src/tools/permission-manager.ts" const delegateSchema = z.object({ task: z @@ -97,12 +98,13 @@ function filterTools( export const delegateTool = { description: "Delegate a self-contained subtask to a focused subagent. " + - "Use this when the user's request has a clearly separable subproblem " + - "(e.g. 'summarize this URL', 'find all files matching X', 'extract the API surface'). " + "The subagent runs with its own context and tool budget, then returns a concise structured summary. " + "Prefer delegating over chaining many tool calls in the parent — keeps the parent's context clean. " + - "Set `agent: 'general'` for write access (will still ask for permission on destructive operations). " + - "Set `agent: 'explore'` (default) for fast read-only investigation.", + "IMPORTANT: Use `agent: 'general'` when the subtask requires writing, editing, or changing files. " + + "Use `agent: 'explore'` (default) for read-only investigation (searching, reading, summarizing). " + + "Examples: for refactoring, bug fixing, or feature work use general; for file lookup or summarization use explore. " + + "The general agent is write-capable (it still asks for permission on destructive operations). " + + "The explore agent is strictly read-only (it cannot modify files or system state).", parameters: delegateSchema, execute: async (args: DelegateArgs) => { if (!runtime.model) { @@ -129,6 +131,7 @@ export const delegateTool = { budget: args.budget ?? agent.info.steps, onChunk: runtime.onChunk, onToolCall: runtime.onToolCall, + parentAgent: getCurrentAgent(), } try { @@ -232,6 +235,7 @@ export const taskTool = { budget: item.budget ?? agent.info.steps, onChunk: runtime.onChunk, onToolCall: runtime.onToolCall, + parentAgent: getCurrentAgent(), }) return { index: idx, diff --git a/apps/supercode-cli/server/src/tools/permission-manager.ts b/apps/supercode-cli/server/src/tools/permission-manager.ts index b537f42..52b8a06 100644 --- a/apps/supercode-cli/server/src/tools/permission-manager.ts +++ b/apps/supercode-cli/server/src/tools/permission-manager.ts @@ -370,12 +370,13 @@ const DEFAULT_RULES: RulesetArray = [ let sessionSavedRules: RulesetArray = [] -// ---- Current-agent thread-local ---- +// ---- Current-agent and parent-agent thread-local ---- // -// Wrapped tools set this so permissionManager.check() can scope its ruleset -// to the calling agent without having to thread agentName through every -// call site (AI SDK execute signatures don't pass it). +// Wrapped tools set these so permissionManager.check() can scope its +// ruleset to the calling agent chain (agent + its parent) without +// having to thread agentName through every call site. let currentAgent: string | undefined = undefined +let parentAgent: string | undefined = undefined export function setCurrentAgent(name: string | undefined): void { currentAgent = name @@ -385,6 +386,14 @@ export function getCurrentAgent(): string | undefined { return currentAgent } +export function setParentAgent(name: string | undefined): void { + parentAgent = name +} + +export function getParentAgent(): string | undefined { + return parentAgent +} + // ---- Resource extraction ---- function getResource(toolName: string, args: Record): string { @@ -447,11 +456,24 @@ export class PermissionManager { // 2. Extract resource const resource = getResource(toolName, args) - // 3. Resolve agent ruleset (if a subagent is the caller) + // 3. Resolve agent ruleset (if a subagent is the caller). + // If a parent agent is set, merge parent + child rules so the + // parent's DENY always overrides the child's ALLOW. const resolvedAgent = opts.agentName ?? currentAgent - const agentRuleset = resolvedAgent - ? agentService.get(resolvedAgent)?.info.permission - : undefined + const resolvedParent = parentAgent + let agentRuleset: RulesetArray | undefined + + if (resolvedAgent && resolvedParent) { + // Merge parent + child with parent-deny inheritance + const childRules = agentService.get(resolvedAgent)?.info.permission + const parentRules = agentService.get(resolvedParent)?.info.permission + if (childRules || parentRules) { + const { mergeParentChildPermissions } = await import("src/agent/subagent-permissions") + agentRuleset = mergeParentChildPermissions(childRules, parentRules) + } + } else if (resolvedAgent) { + agentRuleset = agentService.get(resolvedAgent)?.info.permission + } // 4. Evaluate rules — order matters (later rules override earlier, // via findLast): diff --git a/apps/supercode-cli/server/tsconfig.json b/apps/supercode-cli/server/tsconfig.json index 0fa7d07..74e6075 100644 --- a/apps/supercode-cli/server/tsconfig.json +++ b/apps/supercode-cli/server/tsconfig.json @@ -1,5 +1,8 @@ { "compilerOptions": { + // Silence baseUrl deprecation (used by bare "src/..." imports) + "ignoreDeprecations": "6.0", + // Environment setup & latest features "lib": ["ESNext"], "target": "ESNext", @@ -26,10 +29,10 @@ "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false, - // Base URL for paths + // Base URL for non-relative imports (e.g. "src/...") "baseUrl": ".", - - // Paths + + // Paths (relative to this tsconfig.json) "paths": { "@super/db-terminal": ["../../packages/db-terminal/index.ts"], "@super/db-terminal/*": ["../../packages/db-terminal/*"], diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 79f51b2..6c82243 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -5,6 +5,7 @@ import { QueryProvider } from "@/components/providers/query-provider"; import { Analytics } from "@vercel/analytics/next"; import { SpeedInsights } from "@vercel/speed-insights/next"; import { Toaster } from "sonner"; +import LaunchBanner from "@/components/launch-banner"; export const metadata: Metadata = { title: "Supercode - The open source SWE agent", @@ -42,6 +43,7 @@ export default function RootLayout({ return ( + { return ( <> -
+
@@ -252,7 +252,7 @@ const Navbar = () => {
{
-
+ {/*
@@ -324,7 +324,7 @@ const Navbar = () => { meantime.
-
+
*/} ) } diff --git a/apps/web/components/launch-banner.tsx b/apps/web/components/launch-banner.tsx new file mode 100644 index 0000000..ca617b1 --- /dev/null +++ b/apps/web/components/launch-banner.tsx @@ -0,0 +1,46 @@ +"use client" + +import Link from "next/link" + +export default function LaunchBanner() { + return ( +
+ + + + + + + Beta Live + beta live on Product Hunt + + + · + + learn more → + + + + + + + +
+
+ ) +} diff --git a/bun.lock b/bun.lock index 78d62b4..7cabdab 100644 --- a/bun.lock +++ b/bun.lock @@ -89,19 +89,33 @@ }, "apps/supercode-cli/server": { "name": "supercode-cli", - "version": "0.1.41", + "version": "0.1.56", "bin": { "supercode": "dist/main.js", }, "dependencies": { + "@ai-sdk/google": "^3.0.80", "@ai-sdk/openai-compatible": "^2.0.48", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@prisma/client-runtime-utils": "^7.8.0", "@prisma/driver-adapter-utils": "^7.8.0", + "ai": "^6.0.195", + "better-auth": "^1.5.5", + "boxen": "^8.0.1", + "chalk": "^5.6.2", + "commander": "^15.0.0", + "cors": "^2.8.5", + "express": "^5.1.0", + "marked": "^18.0.4", + "marked-terminal": "^7.3.0", + "open": "^11.0.0", + "vercel-minimax-ai-provider": "^0.0.2", + "yocto-spinner": "^1.2.0", + "zod": "^3.25.2", + "zod-to-json-schema": "^3.25.2", }, "devDependencies": { - "@ai-sdk/google": "^3.0.80", "@clack/prompts": "^1.5.0", "@openrouter/ai-sdk-provider": "^2.9.0", "@openrouter/sdk": "^0.12.79", @@ -111,25 +125,11 @@ "@types/express": "^5.0.0", "@types/node": "^22.0.0", "@types/pg": "^8.18.0", - "ai": "^6.0.195", "api": "^6.1.3", - "better-auth": "^1.5.5", - "boxen": "^8.0.1", - "chalk": "^5.6.2", - "commander": "^15.0.0", - "cors": "^2.8.5", "dotenv": "^17.3.1", - "express": "^5.1.0", - "marked": "^18.0.4", - "marked-terminal": "^7.3.0", "oas": "^34.0.1", - "open": "^11.0.0", "prisma": "^7.4.2", "typescript": "^5.7.0", - "vercel-minimax-ai-provider": "^0.0.2", - "yocto-spinner": "^1.2.0", - "zod": "^3.25.2", - "zod-to-json-schema": "^3.25.2", }, }, "apps/superdesign": { @@ -2425,7 +2425,7 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], @@ -4143,8 +4143,6 @@ "make-dir/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "memoizee/is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], - "micro/content-type": ["content-type@1.0.4", "", {}, "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA=="], "micro/raw-body": ["raw-body@2.4.1", "", { "dependencies": { "bytes": "3.1.0", "http-errors": "1.7.3", "iconv-lite": "0.4.24", "unpipe": "1.0.0" } }, "sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA=="], @@ -4225,6 +4223,8 @@ "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "router/is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "shadcn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],