diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-20 22:52:50 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-20 22:52:50 +0200 |
| commit | 129dcd81dd1a929b03ba88ad8bc2b852fefb39eb (patch) | |
| tree | 59ca9a0ac0bcf940900ea8dfd6e9aa0c1c6ac1bc | |
| parent | 097afe5a81849ea8a921286c887014e242fa3794 (diff) | |
update
| -rw-r--r-- | fish/conf.d/ai.fish | 3 | ||||
| -rw-r--r-- | pi/agent/extensions/fresh-subagent/README.md | 166 | ||||
| -rw-r--r-- | pi/agent/extensions/fresh-subagent/index.ts | 1005 | ||||
| -rw-r--r-- | pi/agent/extensions/modal-editor/README.md | 91 | ||||
| -rw-r--r-- | pi/agent/extensions/modal-editor/index.ts | 486 | ||||
| -rw-r--r-- | pi/agent/extensions/taskwarrior-plan-mode/README.md | 6 | ||||
| -rw-r--r-- | pi/agent/extensions/taskwarrior-plan-mode/index.ts | 97 | ||||
| -rw-r--r-- | pi/agent/settings.json | 2 | ||||
| -rw-r--r-- | prompts/skills/taskwarrior-task-management/SKILL.md | 17 | ||||
| -rw-r--r-- | prompts/skills/taskwarrior-task-management/references/00-context.md | 1 | ||||
| -rw-r--r-- | prompts/skills/taskwarrior-task-management/references/1-create-task.md | 8 |
11 files changed, 1720 insertions, 162 deletions
diff --git a/fish/conf.d/ai.fish b/fish/conf.d/ai.fish index 49b6211..51ca295 100644 --- a/fish/conf.d/ai.fish +++ b/fish/conf.d/ai.fish @@ -6,3 +6,6 @@ end # Claude Code via vLLM + LiteLLM proxy on Hyperstack VM (requires wg1 tunnel active) abbr -a hyperstack-claude 'ANTHROPIC_BASE_URL=http://hyperstack.wg1:4000 ANTHROPIC_API_KEY=sk-litellm-master claude --model claude-opus-4-6-20260604 --dangerously-skip-permissions' + +abbr pi-hyperstack-nemotron pi --model hyperstack1/cyankiwi/NVIDIA-Nemotron-3-Super-120B-A12B-AWQ-4bit +abbr pi-hyperstack-coder pi --model hyperstack2/bullpoint/Qwen3-Coder-Next-AWQ-4bit diff --git a/pi/agent/extensions/fresh-subagent/README.md b/pi/agent/extensions/fresh-subagent/README.md index 8758bc9..701fdda 100644 --- a/pi/agent/extensions/fresh-subagent/README.md +++ b/pi/agent/extensions/fresh-subagent/README.md @@ -1,17 +1,20 @@ # Fresh Subagent -Generic fresh-context delegation for Pi. +Generic fresh-context delegation for Pi with live status, per-run log files, and +history browsing. This extension gives Pi a simple subagent primitive: - the main agent can call the `subagent` tool - you can call `/subagent <prompt>` directly -- the delegated work runs in a new `pi --mode json -p --no-session` process +- delegated work runs in a new `pi --mode json -p --no-session` process - the child starts with a fresh context -- the result comes back as one final answer +- each run gets its own log file plus JSON sidecar metadata +- you can list past runs and open any run's full transcript in `$VISUAL` or `$EDITOR` -This is intentionally small. It does not manage agent catalogs, chains, or -parallel workers. It is meant for one-off delegation with a clean context. +This is still intentionally small. It does not manage agent pools, agent +catalogs, or planner chains. It is meant for focused delegation with a clean +context and auditable output. ## What It Is For @@ -34,31 +37,126 @@ One common use is the `taskwarrior-task-management` review loop: 4. The main agent fixes findings 5. Only then does the task move toward completion -## Usage Flows +## Usage Flow -### Flow 1: Use it directly inside Pi +### Step 1: Run a subagent -Run a direct delegation: +Direct delegation: ```text /subagent Compare the current plan-mode extension behavior against the requested workflow and list only the mismatches. ``` -Run a focused investigation: +Focused investigation: ```text /subagent Find all code paths that write to the SSH known_hosts file and summarize the risk. ``` -Run a review: +Independent review: ```text /subagent Independently review the recent changes for bugs, regressions, and missing tests. Only report concrete findings. ``` -### Flow 2: Use it from the main agent +The watched slash command is the normal interactive path. It updates status in +the footer, keeps a widget with recent activity, and writes the full run to a +durable log file. -Because this is registered as a tool, the main agent can call it itself. +### Step 2: Inspect history + +List recent runs: + +```text +/subagent-history +``` + +List more: + +```text +/subagent-history 20 +``` + +Each entry includes: + +- run ID +- status +- started timestamp +- prompt summary +- log path +- output preview when available + +You can select later runs either by: + +- `latest` +- numeric index from `/subagent-history` +- run ID prefix + +### Step 3: Inspect a specific run + +Show the paths and metadata for the latest run: + +```text +/subagent-log +``` + +Show the paths and metadata for a specific run: + +```text +/subagent-log 3 +/subagent-log 20260320T194522-review-ssh +``` + +This prints: + +- run ID +- status +- prompt +- log path +- metadata path +- `tail -f` command + +### Step 4: Open the full transcript in Helix or another editor + +Open the latest run in `$VISUAL` or `$EDITOR`: + +```text +/subagent-open +``` + +Open a specific run: + +```text +/subagent-open 2 +/subagent-open 20260320T194522-review-ssh +``` + +In TUI mode the extension temporarily releases the terminal, launches your +configured editor, then restores Pi when you exit the editor. + +In one-shot or print mode it runs the editor command directly. + +## Other Commands + +Alias with the same watched behavior: + +```text +/subagent-watch <prompt> +``` + +Launch a visible fresh Pi session instead of a headless child: + +```text +/subagent-session <prompt> +``` + +This is useful when you want to watch the subagent itself, not just the logged +transcript. + +## Tool Usage From The Main Agent + +Because this extension registers a `subagent` tool, the main agent can call it +itself. Generic handoff pattern: @@ -78,23 +176,21 @@ Research handoff pattern: Use the subagent tool to inspect only the WireGuard setup path in a fresh context and summarize the concrete risks. ``` -### Flow 3: Use it in one-shot CLI mode +## One-Shot CLI Mode This works outside the full TUI as well: ```bash pi --model openai/gpt-4.1 --no-session -p '/subagent Say only SUBAGENT_COMMAND_OK' +pi --no-session -p '/subagent-history' +pi --no-session -p '/subagent-log latest' ``` -### Flow 4: Use it in the Taskwarrior review loop - -The intended task workflow is: +If you want to open a run from a shell: -1. main agent implements -2. main agent self-reviews -3. main agent calls `subagent` for independent review -4. main agent fixes findings -5. only then complete the task +```bash +pi --no-session -p '/subagent-open latest' +``` ## What To Put In The Prompt @@ -117,12 +213,34 @@ Weak: /subagent Review this ``` +## Log Storage + +Fresh-subagent history lives under: + +```text +${XDG_STATE_HOME:-~/.local/state}/pi/subagents +``` + +Each run creates: + +- one `*.log` transcript file +- one `*.json` metadata file +- a rolling `latest.log` symlink pointing at the newest run + +That means you can also inspect logs outside Pi with tools like: + +```bash +tail -f ~/.local/state/pi/subagents/latest.log +ls ~/.local/state/pi/subagents +``` + ## Notes And Limits -- The subagent uses a fresh session via `--no-session`. +- The headless subagent uses a fresh session via `--no-session`. - The subprocess still runs in the same working directory unless you override `cwd`. - The extension disables itself inside child subagent processes to avoid accidental recursive registration. -- This is deliberately minimal. There is no built-in multi-agent orchestration, - planner chain, or background pool here. +- `subagent-session` is visible because it uses a real Pi session instead of a + headless child. Its transcript is the session itself, not one of the + `fresh-subagent` log files. diff --git a/pi/agent/extensions/fresh-subagent/index.ts b/pi/agent/extensions/fresh-subagent/index.ts index 52fed1b..366f94a 100644 --- a/pi/agent/extensions/fresh-subagent/index.ts +++ b/pi/agent/extensions/fresh-subagent/index.ts @@ -1,11 +1,25 @@ -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import { mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; import type { AgentToolResult, AgentToolResultContent } from "@mariozechner/pi-agent-core"; import type { Message, TextContent } from "@mariozechner/pi-ai"; -import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { Text } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; const CHILD_ENV_FLAG = "PI_FRESH_SUBAGENT_CHILD"; +const LOG_BASENAME = "latest.log"; +const HISTORY_SUFFIX = ".json"; +const DEFAULT_HISTORY_LIMIT = 10; +const MAX_HISTORY_LIMIT = 50; +const MAX_RECENT_ACTIVITY = 12; +const MAX_WIDGET_LINES = 10; +const MAX_RENDER_PREVIEW_LINES = 8; +const MAX_ACTIVITY_LINE_LENGTH = 160; +const MAX_UPDATE_INTERVAL_MS = 150; +const HISTORY_PERSIST_INTERVAL_MS = 1000; interface UsageStats { input: number; @@ -17,6 +31,7 @@ interface UsageStats { } interface FreshSubagentResult { + runId: string; prompt: string; model?: string; cwd: string; @@ -26,8 +41,56 @@ interface FreshSubagentResult { stderr: string; output: string; usage: UsageStats; + logPath: string; + metadataPath: string; + latestLogPath: string; + eventCount: number; + lastStatus: string; + currentTool?: string; + recentActivity: string[]; } +interface SubagentHistoryEntry { + runId: string; + prompt: string; + promptSummary: string; + model?: string; + cwd: string; + startedAt: string; + finishedAt?: string; + active: boolean; + exitCode?: number; + stopReason?: string; + errorMessage?: string; + logPath: string; + metadataPath: string; + eventCount: number; + lastStatus: string; + currentTool?: string; + outputPreview?: string; +} + +interface SubagentLog { + runId: string; + logPath: string; + metadataPath: string; + latestLogPath: string; + write(line: string): void; + close(): Promise<void>; +} + +interface RunFreshSubagentOptions { + cwd: string; + model?: string; + tools?: string[]; + signal?: AbortSignal; + onUpdate?: (partial: AgentToolResult<FreshSubagentResult>) => void; + onState?: (details: FreshSubagentResult) => void; +} + +let latestLogPathHint: string | undefined; +let activeLogPathHint: string | undefined; + function getProviderScopedModel(ctx: ExtensionContext): string | undefined { if (!ctx.model) return undefined; return `${ctx.model.provider}/${ctx.model.id}`; @@ -47,28 +110,425 @@ function getLastAssistantText(messages: Message[]): string { return ""; } -async function runFreshSubagent( - prompt: string, - options: { - cwd: string; - model?: string; - tools?: string[]; - signal?: AbortSignal; - onUpdate?: (partial: AgentToolResult<FreshSubagentResult>) => void; - }, -): Promise<FreshSubagentResult> { +function getSubagentLogDir(): string { + const stateHome = process.env.XDG_STATE_HOME || path.join(homedir(), ".local", "state"); + return path.join(stateHome, "pi", "subagents"); +} + +function sanitizePromptForFile(prompt: string): string { + const slug = prompt + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); + return slug || "subagent"; +} + +function makeRunId(prompt: string): string { + const suffix = Math.random().toString(36).slice(2, 8); + return `${timestampForFile()}-${sanitizePromptForFile(prompt)}-${suffix}`; +} + +function timestampForFile(date = new Date()): string { + const pad = (value: number) => String(value).padStart(2, "0"); + return [ + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + "T", + pad(date.getHours()), + pad(date.getMinutes()), + pad(date.getSeconds()), + ].join(""); +} + +async function writeHistoryEntry(entry: SubagentHistoryEntry): Promise<void> { + await writeFile(entry.metadataPath, `${JSON.stringify(entry, null, 2)}\n`, "utf8"); +} + +async function readHistoryEntries(): Promise<SubagentHistoryEntry[]> { + const dir = getSubagentLogDir(); + await mkdir(dir, { recursive: true }); + + const files = await readdir(dir, { withFileTypes: true }); + const entries: SubagentHistoryEntry[] = []; + + for (const file of files) { + if (!file.isFile() || !file.name.endsWith(HISTORY_SUFFIX)) continue; + + const metadataPath = path.join(dir, file.name); + try { + const raw = await readFile(metadataPath, "utf8"); + const parsed = JSON.parse(raw) as Partial<SubagentHistoryEntry>; + if (!parsed.runId || !parsed.prompt || !parsed.logPath || !parsed.startedAt) continue; + + entries.push({ + runId: parsed.runId, + prompt: parsed.prompt, + promptSummary: parsed.promptSummary || summarizePrompt(parsed.prompt, 120), + model: parsed.model, + cwd: parsed.cwd || "", + startedAt: parsed.startedAt, + finishedAt: parsed.finishedAt, + active: Boolean(parsed.active), + exitCode: parsed.exitCode, + stopReason: parsed.stopReason, + errorMessage: parsed.errorMessage, + logPath: parsed.logPath, + metadataPath, + eventCount: parsed.eventCount || 0, + lastStatus: parsed.lastStatus || "unknown", + currentTool: parsed.currentTool, + outputPreview: parsed.outputPreview, + }); + } catch { + // Ignore malformed files so one bad history entry does not break browsing. + } + } + + return entries.sort((a, b) => { + const aTime = Date.parse(a.startedAt) || 0; + const bTime = Date.parse(b.startedAt) || 0; + return bTime - aTime; + }); +} + +function getHistoryStatus(entry: SubagentHistoryEntry): string { + if (entry.active) { + return entry.currentTool ? `active:${entry.currentTool}` : `active:${entry.lastStatus}`; + } + + if (entry.stopReason === "aborted") return "aborted"; + if (entry.exitCode === 0 && entry.stopReason !== "error") return "done"; + return "error"; +} + +function normalizeHistorySelector(selector: string): string { + return selector.trim(); +} + +async function resolveHistoryEntry(selector: string): Promise<{ + entry?: SubagentHistoryEntry; + error?: string; +}> { + const entries = await readHistoryEntries(); + if (entries.length === 0) { + return { error: "No subagent history is available yet." }; + } + + const normalized = normalizeHistorySelector(selector || "latest"); + if (!normalized || normalized === "latest") { + return { entry: entries[0] }; + } + + if (/^\d+$/.test(normalized)) { + const index = Number(normalized); + if (index >= 1 && index <= entries.length) return { entry: entries[index - 1] }; + return { error: `History index ${normalized} is out of range.` }; + } + + const exact = entries.find((entry) => entry.runId === normalized); + if (exact) return { entry: exact }; + + const matches = entries.filter((entry) => entry.runId.startsWith(normalized)); + if (matches.length === 1) return { entry: matches[0] }; + if (matches.length > 1) { + return { + error: `Selector '${normalized}' is ambiguous:\n${matches + .slice(0, 8) + .map((entry) => `- ${entry.runId}`) + .join("\n")}`, + }; + } + + return { error: `No subagent history entry matched '${normalized}'.` }; +} + +async function createSubagentLog(prompt: string): Promise<SubagentLog> { + const dir = getSubagentLogDir(); + await mkdir(dir, { recursive: true }); + + const runId = makeRunId(prompt); + const logPath = path.join(dir, `${runId}.log`); + const metadataPath = path.join(dir, `${runId}${HISTORY_SUFFIX}`); + const latestLogPath = path.join(dir, LOG_BASENAME); + const stream = createWriteStream(logPath, { flags: "a" }); + + try { + await rm(latestLogPath, { force: true }); + await symlink(path.basename(logPath), latestLogPath); + } catch { + // Best-effort only. The per-run log path still works even if the symlink fails. + } + + const write = (line: string) => { + const timestamp = new Date().toISOString(); + stream.write(`${timestamp} ${line}\n`); + }; + + return { + runId, + logPath, + metadataPath, + latestLogPath, + write, + close: () => + new Promise<void>((resolve) => { + stream.end(resolve); + }), + }; +} + +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return `${text.slice(0, Math.max(0, max - 3))}...`; +} + +function summarizePrompt(prompt: string, max = 80): string { + return truncate(prompt.replace(/\s+/g, " ").trim(), max); +} + +function summarizeValue(value: unknown, max = 120): string { + if (value === undefined) return ""; + if (typeof value === "string") return truncate(value.replace(/\s+/g, " ").trim(), max); + + try { + const json = JSON.stringify(value); + return truncate(json, max); + } catch { + return truncate(String(value), max); + } +} + +function splitActivityLines(text: string): string[] { + const collapsed = text.replace(/\r/g, ""); + const rawLines = collapsed.split("\n"); + const output: string[] = []; + + for (const raw of rawLines) { + const line = raw.trimEnd(); + if (!line) continue; + if (line.length <= MAX_ACTIVITY_LINE_LENGTH) { + output.push(line); + continue; + } + + let remaining = line; + while (remaining.length > MAX_ACTIVITY_LINE_LENGTH) { + output.push(`${remaining.slice(0, MAX_ACTIVITY_LINE_LENGTH - 1)}…`); + remaining = remaining.slice(MAX_ACTIVITY_LINE_LENGTH - 1); + } + if (remaining) output.push(remaining); + } + + return output; +} + +function extractContentText(content: unknown): string { + if (!Array.isArray(content)) return ""; + + return content + .map((item) => { + if (!item || typeof item !== "object") return ""; + const typedItem = item as { type?: string; text?: string }; + return typedItem.type === "text" && typeof typedItem.text === "string" ? typedItem.text : ""; + }) + .filter(Boolean) + .join("\n"); +} + +function cloneResult(result: FreshSubagentResult): FreshSubagentResult { + return { + ...result, + recentActivity: [...result.recentActivity], + usage: { ...result.usage }, + }; +} + +function renderRunningSummary(details: FreshSubagentResult): string { + const lines = [ + `subagent: ${details.lastStatus || "running"}`, + `run: ${details.runId}`, + `log: ${details.logPath}`, + ]; + + if (details.currentTool) lines.push(`tool: ${details.currentTool}`); + + if (details.recentActivity.length > 0) { + lines.push("", ...details.recentActivity.slice(-MAX_RENDER_PREVIEW_LINES)); + } + + return lines.join("\n"); +} + +function buildWidgetLines(details: FreshSubagentResult): string[] { + const lines = [ + `subagent: ${details.lastStatus || "running"}`, + `run: ${details.runId}`, + `log: ${details.latestLogPath}`, + ]; + + if (details.currentTool) lines.push(`tool: ${details.currentTool}`); + + if (details.recentActivity.length > 0) { + lines.push(...details.recentActivity.slice(-MAX_WIDGET_LINES)); + } + + return lines; +} + +function renderSubagentSummary(details: FreshSubagentResult, expanded: boolean, theme: any): string { + const status = + details.exitCode === 0 && details.stopReason !== "error" && details.stopReason !== "aborted" + ? theme.fg("success", "✓") + : theme.fg("error", "✗"); + const header = `${status} ${theme.fg("toolTitle", theme.bold("subagent"))}${ + details.model ? theme.fg("muted", ` ${details.model}`) : "" + }`; + + const lines = [ + header, + theme.fg("muted", `run: ${details.runId}`), + theme.fg("muted", `cwd: ${details.cwd}`), + theme.fg("muted", `log: ${details.logPath}`), + theme.fg("muted", `meta: ${details.metadataPath}`), + theme.fg("muted", `latest: ${details.latestLogPath}`), + theme.fg("muted", `events: ${details.eventCount}`), + ]; + + if (details.currentTool) lines.push(theme.fg("muted", `current tool: ${details.currentTool}`)); + + if (expanded) { + lines.push("", theme.fg("muted", "Prompt:"), details.prompt); + lines.push("", theme.fg("muted", "Result:"), details.output || theme.fg("muted", "(no output)")); + if (details.recentActivity.length > 0) { + lines.push("", theme.fg("muted", "Recent Activity:"), ...details.recentActivity.slice(-MAX_RENDER_PREVIEW_LINES)); + } + } else { + const preview = details.output ? details.output.split("\n").slice(0, 5).join("\n") : "(no output)"; + lines.push("", preview); + } + + if (details.errorMessage) lines.push("", theme.fg("error", `Error: ${details.errorMessage}`)); + if (details.stderr.trim()) lines.push("", theme.fg("dim", details.stderr.trim())); + return lines.join("\n"); +} + +function formatHistoryEntries(entries: SubagentHistoryEntry[], limit: number): string { + if (entries.length === 0) return "No subagent history is available yet."; + + return entries + .slice(0, limit) + .map((entry, index) => { + const lines = [ + `${index + 1}. ${entry.runId} [${getHistoryStatus(entry)}]${entry.model ? ` ${entry.model}` : ""}`, + ` started: ${entry.startedAt}`, + ` prompt: ${entry.promptSummary}`, + ` log: ${entry.logPath}`, + ]; + if (entry.outputPreview) lines.push(` output: ${entry.outputPreview}`); + return lines.join("\n"); + }) + .join("\n\n"); +} + +function formatHistoryDetails(entry: SubagentHistoryEntry): string { + const lines = [ + `run: ${entry.runId}`, + `status: ${getHistoryStatus(entry)}`, + `started: ${entry.startedAt}`, + `finished: ${entry.finishedAt || "(still running)"}`, + `model: ${entry.model || "(session default)"}`, + `cwd: ${entry.cwd}`, + `prompt: ${entry.prompt}`, + `log: ${entry.logPath}`, + `metadata: ${entry.metadataPath}`, + `tail: tail -f ${entry.logPath}`, + ]; + + if (entry.outputPreview) lines.push(`output preview: ${entry.outputPreview}`); + if (entry.errorMessage) lines.push(`error: ${entry.errorMessage}`); + return lines.join("\n"); +} + +function quoteForShell(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} + +function getEditorCommand(): string | undefined { + return process.env.VISUAL || process.env.EDITOR; +} + +async function openInExternalEditor(filePath: string, ctx: ExtensionCommandContext): Promise<{ + ok: boolean; + message: string; +}> { + const editorCmd = getEditorCommand(); + if (!editorCmd) { + return { ok: false, message: "No editor configured. Set $VISUAL or $EDITOR." }; + } + + const command = `exec ${editorCmd} ${quoteForShell(filePath)}`; + + if (!ctx.hasUI) { + const result = spawnSync("bash", ["-lc", command], { + stdio: "inherit", + env: process.env, + }); + return result.status === 0 + ? { ok: true, message: `Opened ${filePath} in ${editorCmd}` } + : { ok: false, message: `Editor exited with code ${result.status ?? 1}` }; + } + + await ctx.waitForIdle(); + const exitCode = await ctx.ui.custom<number | null>((tui, _theme, _kb, done) => { + tui.stop(); + process.stdout.write("\x1b[2J\x1b[H"); + + const result = spawnSync("bash", ["-lc", command], { + stdio: "inherit", + env: process.env, + }); + + tui.start(); + tui.requestRender(true); + done(result.status); + + return { + render: () => [], + invalidate: () => {}, + }; + }); + + return exitCode === 0 + ? { ok: true, message: `Opened ${filePath} in ${editorCmd}` } + : { ok: false, message: `Editor exited with code ${exitCode ?? 1}` }; +} + +async function runFreshSubagent(prompt: string, options: RunFreshSubagentOptions): Promise<FreshSubagentResult> { + const log = await createSubagentLog(prompt); + latestLogPathHint = log.latestLogPath; + activeLogPathHint = log.logPath; + const args = ["--mode", "json", "-p", "--no-session"]; if (options.model) args.push("--model", options.model); if (options.tools && options.tools.length > 0) args.push("--tools", options.tools.join(",")); args.push(prompt); const result: FreshSubagentResult = { + runId: log.runId, prompt, model: options.model, cwd: options.cwd, exitCode: 0, stderr: "", output: "", + logPath: log.logPath, + metadataPath: log.metadataPath, + latestLogPath: log.latestLogPath, + eventCount: 0, + lastStatus: "starting", + recentActivity: [], usage: { input: 0, output: 0, @@ -79,17 +539,113 @@ async function runFreshSubagent( }, }; + const startedAt = new Date().toISOString(); + let finishedAt: string | undefined; + let isFinished = false; + let lastHistoryPersistAt = 0; + let historyWriteChain: Promise<void> = Promise.resolve(); + + const buildHistoryEntry = (): SubagentHistoryEntry => ({ + runId: result.runId, + prompt: result.prompt, + promptSummary: summarizePrompt(result.prompt, 120), + model: result.model, + cwd: result.cwd, + startedAt, + finishedAt, + active: !isFinished, + exitCode: result.exitCode, + stopReason: result.stopReason, + errorMessage: result.errorMessage, + logPath: result.logPath, + metadataPath: result.metadataPath, + eventCount: result.eventCount, + lastStatus: result.lastStatus, + currentTool: result.currentTool, + outputPreview: result.output || result.errorMessage ? summarizePrompt(result.output || result.errorMessage || "", 140) : undefined, + }); + + const persistHistory = async (force = false) => { + const now = Date.now(); + if (!force && now - lastHistoryPersistAt < HISTORY_PERSIST_INTERVAL_MS) return; + lastHistoryPersistAt = now; + const entry = buildHistoryEntry(); + historyWriteChain = historyWriteChain + .then(() => writeHistoryEntry(entry)) + .catch(() => { + // Best-effort. Logging should not fail because the history sidecar write failed. + }); + await historyWriteChain; + }; + const messages: Message[] = []; + const toolOutputById = new Map<string, string>(); + let assistantBuffer = ""; + let lastEmitAt = 0; + let wasAborted = false; + + log.write(`[start] run=${result.runId}`); + log.write(`[start] prompt=${summarizePrompt(prompt, 200)}`); + log.write(`[start] cwd=${options.cwd}`); + if (options.model) log.write(`[start] model=${options.model}`); + await persistHistory(true); + + const pushActivity = (line: string) => { + const lines = splitActivityLines(line); + for (const entry of lines) { + result.recentActivity.push(entry); + if (result.recentActivity.length > MAX_RECENT_ACTIVITY) result.recentActivity.shift(); + log.write(entry); + } + }; + + const flushAssistantBuffer = (force: boolean) => { + let emitted = false; + + while (true) { + const newlineIndex = assistantBuffer.indexOf("\n"); + if (newlineIndex >= 0) { + const line = assistantBuffer.slice(0, newlineIndex); + assistantBuffer = assistantBuffer.slice(newlineIndex + 1); + if (line.trim()) pushActivity(`assistant> ${line}`); + emitted = true; + continue; + } + + if (force && assistantBuffer.trim()) { + pushActivity(`assistant> ${assistantBuffer}`); + assistantBuffer = ""; + emitted = true; + continue; + } + + if (!force && assistantBuffer.length > 240) { + pushActivity(`assistant> ${assistantBuffer.slice(0, 239)}…`); + assistantBuffer = assistantBuffer.slice(239); + emitted = true; + continue; + } + + break; + } + + return emitted; + }; - const emitUpdate = () => { + const emitUpdate = (force = false) => { + const now = Date.now(); + if (!force && now - lastEmitAt < MAX_UPDATE_INTERVAL_MS) return; + lastEmitAt = now; + void persistHistory(force); + + const snapshot = cloneResult(result); options.onUpdate?.({ - content: [{ type: "text", text: result.output || "(running...)" }], - details: { ...result }, + content: [{ type: "text", text: renderRunningSummary(snapshot) }], + details: snapshot, }); + options.onState?.(snapshot); }; - let wasAborted = false; - result.exitCode = await new Promise<number>((resolve) => { const proc = spawn("pi", args, { cwd: options.cwd, @@ -110,31 +666,164 @@ async function runFreshSubagent( try { event = JSON.parse(line); } catch { + log.write(`[raw] ${line}`); return; } - if (event.type !== "message_end" || !event.message) return; - - const message = event.message as Message; - messages.push(message); - result.output = getLastAssistantText(messages); - - if (message.role === "assistant") { - result.usage.turns++; - const usage = message.usage; - if (usage) { - result.usage.input += usage.input || 0; - result.usage.output += usage.output || 0; - result.usage.cacheRead += usage.cacheRead || 0; - result.usage.cacheWrite += usage.cacheWrite || 0; - result.usage.cost += usage.cost?.total || 0; + result.eventCount++; + + switch (event.type) { + case "agent_start": + result.lastStatus = "agent started"; + pushActivity("[agent] started"); + emitUpdate(true); + return; + case "agent_end": + flushAssistantBuffer(true); + result.lastStatus = "agent finished"; + pushActivity("[agent] finished"); + emitUpdate(true); + return; + case "turn_start": + result.lastStatus = "turn started"; + emitUpdate(); + return; + case "turn_end": + result.lastStatus = "turn finished"; + emitUpdate(); + return; + case "message_update": { + if (event.message?.role !== "assistant" || !event.assistantMessageEvent) return; + const assistantEvent = event.assistantMessageEvent; + + switch (assistantEvent.type) { + case "text_delta": + if (typeof assistantEvent.delta === "string") { + result.lastStatus = "assistant streaming"; + assistantBuffer += assistantEvent.delta; + flushAssistantBuffer(false); + emitUpdate(); + } + return; + case "text_end": + result.lastStatus = "assistant text complete"; + if (flushAssistantBuffer(true)) emitUpdate(true); + return; + case "thinking_start": + result.lastStatus = "assistant thinking"; + emitUpdate(); + return; + case "toolcall_start": + result.lastStatus = "assistant preparing tool call"; + emitUpdate(); + return; + case "toolcall_end": { + const toolCall = assistantEvent.toolCall || {}; + const toolName = toolCall.toolName || toolCall.name || "tool"; + const argsPreview = summarizeValue(toolCall.args || toolCall.input); + result.lastStatus = `assistant requested ${toolName}`; + pushActivity(argsPreview ? `[plan] ${toolName} ${argsPreview}` : `[plan] ${toolName}`); + emitUpdate(true); + return; + } + case "done": + result.lastStatus = assistantEvent.reason ? `assistant ${assistantEvent.reason}` : "assistant done"; + emitUpdate(true); + return; + case "error": + result.lastStatus = assistantEvent.reason ? `assistant ${assistantEvent.reason}` : "assistant error"; + emitUpdate(true); + return; + default: + return; + } } - if (!result.model && message.model) result.model = message.model; - if (message.stopReason) result.stopReason = message.stopReason; - if (message.errorMessage) result.errorMessage = message.errorMessage; - } + case "message_end": { + const message = event.message as Message | undefined; + if (!message) return; + + messages.push(message); + result.output = getLastAssistantText(messages); + + if (message.role === "assistant") { + flushAssistantBuffer(true); + result.usage.turns++; + const usage = message.usage; + if (usage) { + result.usage.input += usage.input || 0; + result.usage.output += usage.output || 0; + result.usage.cacheRead += usage.cacheRead || 0; + result.usage.cacheWrite += usage.cacheWrite || 0; + result.usage.cost += usage.cost?.total || 0; + } + if (!result.model && message.model) result.model = message.model; + if (message.stopReason) result.stopReason = message.stopReason; + if (message.errorMessage) result.errorMessage = message.errorMessage; + } + + emitUpdate(true); + return; + } + case "tool_execution_start": { + flushAssistantBuffer(true); + result.currentTool = event.toolName; + result.lastStatus = `tool ${event.toolName} running`; + const argsPreview = summarizeValue(event.args); + pushActivity(argsPreview ? `[tool:start] ${event.toolName} ${argsPreview}` : `[tool:start] ${event.toolName}`); + emitUpdate(true); + return; + } + case "tool_execution_update": { + flushAssistantBuffer(true); + result.currentTool = event.toolName; + result.lastStatus = `tool ${event.toolName} running`; + + const partialText = extractContentText(event.partialResult?.content); + if (partialText) { + const previous = toolOutputById.get(event.toolCallId) || ""; + const delta = partialText.startsWith(previous) ? partialText.slice(previous.length) : partialText; + toolOutputById.set(event.toolCallId, partialText); - emitUpdate(); + if (delta.trim()) { + for (const line of splitActivityLines(delta)) { + pushActivity(`[tool:${event.toolName}] ${line}`); + } + } + } + |
