From 129dcd81dd1a929b03ba88ad8bc2b852fefb39eb Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 20 Mar 2026 22:52:50 +0200 Subject: update --- fish/conf.d/ai.fish | 3 + pi/agent/extensions/fresh-subagent/README.md | 166 +++- pi/agent/extensions/fresh-subagent/index.ts | 1005 ++++++++++++++++++-- pi/agent/extensions/modal-editor/README.md | 91 +- pi/agent/extensions/modal-editor/index.ts | 486 +++++++++- .../extensions/taskwarrior-plan-mode/README.md | 6 + pi/agent/extensions/taskwarrior-plan-mode/index.ts | 97 +- pi/agent/settings.json | 2 +- .../skills/taskwarrior-task-management/SKILL.md | 17 + .../references/00-context.md | 1 + .../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 ` 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 +``` + +Launch a visible fresh Pi session instead of a headless child: + +```text +/subagent-session +``` + +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; +} + +interface RunFreshSubagentOptions { + cwd: string; + model?: string; + tools?: string[]; + signal?: AbortSignal; + onUpdate?: (partial: AgentToolResult) => 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) => void; - }, -): Promise { +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 { + await writeFile(entry.metadataPath, `${JSON.stringify(entry, null, 2)}\n`, "utf8"); +} + +async function readHistoryEntries(): Promise { + 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; + 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 { + 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((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((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 { + 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 = 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(); + 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((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}`); + } + } + } + + emitUpdate(); + return; + } + case "tool_execution_end": { + flushAssistantBuffer(true); + const toolName = event.toolName || result.currentTool || "tool"; + const finalText = extractContentText(event.result?.content); + const previous = toolOutputById.get(event.toolCallId) || ""; + const delta = finalText.startsWith(previous) ? finalText.slice(previous.length) : finalText; + if (delta.trim()) { + for (const line of splitActivityLines(delta)) { + pushActivity(`[tool:${toolName}] ${line}`); + } + } + + result.lastStatus = event.isError ? `tool ${toolName} failed` : `tool ${toolName} done`; + pushActivity(event.isError ? `[tool:end] ${toolName} error` : `[tool:end] ${toolName} done`); + result.currentTool = undefined; + emitUpdate(true); + return; + } + case "auto_retry_start": + result.lastStatus = `retry ${event.attempt}/${event.maxAttempts}`; + pushActivity(`[retry] ${event.attempt}/${event.maxAttempts} ${event.errorMessage || ""}`.trim()); + emitUpdate(true); + return; + case "auto_retry_end": + result.lastStatus = event.success ? "retry recovered" : "retry failed"; + emitUpdate(true); + return; + default: + return; + } }; proc.stdout.on("data", (data) => { @@ -145,7 +834,11 @@ async function runFreshSubagent( }); proc.stderr.on("data", (data) => { - result.stderr += data.toString(); + const chunk = data.toString(); + result.stderr += chunk; + for (const line of splitActivityLines(chunk)) { + log.write(`[stderr] ${line}`); + } }); proc.on("close", (code) => { @@ -153,7 +846,8 @@ async function runFreshSubagent( resolve(code ?? 0); }); - proc.on("error", () => { + proc.on("error", (err) => { + result.errorMessage = err.message; resolve(1); }); @@ -174,35 +868,89 @@ async function runFreshSubagent( if (wasAborted) { result.stopReason = "aborted"; result.errorMessage = "Fresh subagent was aborted"; + result.lastStatus = "aborted"; + pushActivity("[agent] aborted"); } result.output ||= getLastAssistantText(messages); + log.write(`[finish] exit=${result.exitCode} stop=${result.stopReason || "unknown"}`); + if (result.errorMessage) log.write(`[finish] error=${result.errorMessage}`); + + finishedAt = new Date().toISOString(); + isFinished = true; + await persistHistory(true); + await log.close(); + activeLogPathHint = undefined; + emitUpdate(true); + await historyWriteChain; return result; } -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}`) : "" - }`; +function getLogInfoText(entry?: SubagentHistoryEntry): string { + if (entry) return formatHistoryDetails(entry); - const lines = [header, theme.fg("muted", `cwd: ${details.cwd}`)]; - if (expanded) { - lines.push("", theme.fg("muted", "Prompt:"), details.prompt); - lines.push("", theme.fg("muted", "Result:"), details.output || theme.fg("muted", "(no output)")); - } 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())); + const latestPath = latestLogPathHint || path.join(getSubagentLogDir(), LOG_BASENAME); + const lines = [`latest log: ${latestPath}`, `tail -f ${latestPath}`]; + if (activeLogPathHint) lines.push(`active run: ${activeLogPathHint}`); return lines.join("\n"); } +function createSlashCommandHandler(watch: boolean) { + return async (args: string, ctx: ExtensionContext, pi: ExtensionAPI) => { + const prompt = args.trim(); + if (!prompt) { + ctx.ui.notify("Usage: /subagent ", "warning"); + return; + } + + const statusId = "fresh-subagent"; + const widgetId = "fresh-subagent-watch"; + const applyUiState = (details: FreshSubagentResult) => { + if (!ctx.hasUI || !watch) return; + const statusText = details.currentTool + ? `subagent: ${details.currentTool}` + : `subagent: ${details.lastStatus}`; + ctx.ui.setStatus(statusId, ctx.ui.theme.fg("warning", statusText)); + ctx.ui.setWidget(widgetId, buildWidgetLines(details), { placement: "belowEditor" }); + }; + + if (ctx.hasUI) { + ctx.ui.setStatus(statusId, ctx.ui.theme.fg("warning", "subagent: starting")); + if (watch) ctx.ui.setWidget(widgetId, ["subagent: starting"], { placement: "belowEditor" }); + } + + try { + const details = await runFreshSubagent(prompt, { + cwd: ctx.cwd, + model: getProviderScopedModel(ctx), + onState: applyUiState, + }); + + if (!ctx.hasUI) { + const text = details.output || details.errorMessage || details.stderr || "(no output)"; + if (text) process.stdout.write(`${text}\n`); + process.stdout.write(`${getLogInfoText()}\n`); + return; + } + + pi.sendMessage( + { + customType: "fresh-subagent-result", + content: details.output || "(no output)", + display: true, + details, + }, + { triggerTurn: false }, + ); + } finally { + if (ctx.hasUI) { + ctx.ui.setStatus(statusId, undefined); + ctx.ui.setWidget(widgetId, undefined); + } + } + }; +} + export default function freshSubagentExtension(pi: ExtensionAPI): void { if (process.env[CHILD_ENV_FLAG] === "1") return; @@ -216,11 +964,12 @@ export default function freshSubagentExtension(pi: ExtensionAPI): void { pi.registerTool({ name: "subagent", label: "Subagent", - description: "Spawn a fresh-context subagent with a prompt and return its final answer.", + description: "Spawn a fresh-context subagent with a prompt and return its final answer. Each run is logged and added to subagent history.", promptSnippet: "Delegate a self-contained task to a fresh-context subagent and get its result back", promptGuidelines: [ "Use this tool for any self-contained side task that benefits from a clean context, such as review, research, summarization, or focused implementation checks.", "Pass a complete prompt with enough context for the subagent to succeed independently, because it starts with a fresh session.", + "Each subagent run is logged to its own file. Tell the user about /subagent-history or /subagent-open if they want the full transcript later.", ], parameters: params, @@ -249,7 +998,7 @@ export default function freshSubagentExtension(pi: ExtensionAPI): void { }, renderCall(args, theme) { - const preview = args.prompt.length > 80 ? `${args.prompt.slice(0, 80)}...` : args.prompt; + const preview = summarizePrompt(args.prompt); return new Text( `${theme.fg("toolTitle", theme.bold("subagent"))}\n ${theme.fg("dim", preview)}`, 0, @@ -267,47 +1016,129 @@ export default function freshSubagentExtension(pi: ExtensionAPI): void { }, }); + const watchedSubagentHandler = createSlashCommandHandler(true); + pi.registerCommand("subagent", { - description: "Run a fresh-context subagent with a prompt", + description: "Run a fresh-context subagent with live status, widget updates, and durable run history", + handler: async (args, ctx) => watchedSubagentHandler(args, ctx, pi), + }); + + pi.registerCommand("subagent-watch", { + description: "Alias for /subagent with live watched output", + handler: async (args, ctx) => watchedSubagentHandler(args, ctx, pi), + }); + + pi.registerCommand("subagent-session", { + description: "Launch a visible fresh-context Pi session for a subagent task", handler: async (args, ctx) => { const prompt = args.trim(); if (!prompt) { - ctx.ui.notify("Usage: /subagent ", "warning"); + ctx.ui.notify("Usage: /subagent-session ", "warning"); return; } - ctx.ui.setStatus("fresh-subagent", ctx.ui.theme.fg("warning", "subagent: running")); + if (!ctx.hasUI) { + process.stdout.write("/subagent-session requires interactive mode.\n"); + return; + } - try { - const details = await runFreshSubagent(prompt, { - cwd: ctx.cwd, - model: getProviderScopedModel(ctx), - }); - - if (!ctx.hasUI) { - const text = details.output || details.errorMessage || details.stderr || "(no output)"; - if (text) { - process.stdout.write(`${text}\n`); - } - return; - } + await ctx.waitForIdle(); + const currentSession = ctx.sessionManager.getSessionFile(); + const result = await ctx.newSession({ + parentSession: currentSession, + }); + + if (result.cancelled) { + ctx.ui.notify("Subagent session launch cancelled", "info"); + return; + } + + pi.setSessionName(`subagent: ${summarizePrompt(prompt, 40)}`); + pi.sendUserMessage(prompt); + }, + }); + + pi.registerCommand("subagent-log", { + description: "Show the log path and metadata for the latest or selected subagent run", + handler: async (args, ctx) => { + const selector = args.trim(); + const resolved = selector ? await resolveHistoryEntry(selector) : {}; + const text = resolved.entry ? getLogInfoText(resolved.entry) : resolved.error || getLogInfoText(); - pi.sendMessage( - { - customType: "fresh-subagent-result", - content: details.output || "(no output)", - display: true, - details, - }, - { triggerTurn: false }, - ); - } finally { - ctx.ui.setStatus("fresh-subagent", undefined); + if (!ctx.hasUI) { + process.stdout.write(`${text}\n`); + return; } + + pi.sendMessage( + { + customType: "fresh-subagent-log-info", + content: text, + display: true, + details: { text }, + }, + { triggerTurn: false }, + ); + }, + }); + + pi.registerCommand("subagent-history", { + description: "List recent fresh-subagent runs so you can browse their full logs later", + handler: async (args, ctx) => { + const requested = Number(args.trim() || DEFAULT_HISTORY_LIMIT); + const limit = Number.isFinite(requested) + ? Math.max(1, Math.min(MAX_HISTORY_LIMIT, requested)) + : DEFAULT_HISTORY_LIMIT; + const text = formatHistoryEntries(await readHistoryEntries(), limit); + + if (!ctx.hasUI) { + process.stdout.write(`${text}\n`); + return; + } + + pi.sendMessage( + { + customType: "fresh-subagent-history", + content: text, + display: true, + details: { text }, + }, + { triggerTurn: false }, + ); + }, + }); + + pi.registerCommand("subagent-open", { + description: "Open a subagent log in $VISUAL/$EDITOR. Usage: /subagent-open [latest|index|run-id-prefix]", + handler: async (args, ctx) => { + const selector = args.trim() || "latest"; + const resolved = await resolveHistoryEntry(selector); + if (!resolved.entry) { + const text = resolved.error || `No subagent history entry matched '${selector}'.`; + if (!ctx.hasUI) process.stdout.write(`${text}\n`); + else ctx.ui.notify(text, "warning"); + return; + } + + const opened = await openInExternalEditor(resolved.entry.logPath, ctx); + if (!ctx.hasUI) { + process.stdout.write(`${opened.message}\n`); + return; + } + + ctx.ui.notify(opened.message, opened.ok ? "info" : "error"); }, }); pi.registerMessageRenderer("fresh-subagent-result", (message, { expanded }, theme) => { return new Text(renderSubagentSummary(message.details as FreshSubagentResult, expanded, theme), 0, 0); }); + + pi.registerMessageRenderer("fresh-subagent-log-info", (message) => { + return new Text(String(message.content || message.details?.text || getLogInfoText()), 0, 0); + }); + + pi.registerMessageRenderer("fresh-subagent-history", (message) => { + return new Text(String(message.content || message.details?.text || "No subagent history is available yet."), 0, 0); + }); } diff --git a/pi/agent/extensions/modal-editor/README.md b/pi/agent/extensions/modal-editor/README.md index 074bff1..bc5a86b 100644 --- a/pi/agent/extensions/modal-editor/README.md +++ b/pi/agent/extensions/modal-editor/README.md @@ -2,36 +2,89 @@ Modal prompt editing for the Pi TUI. -This is the upstream `modal-editor.ts` example installed as a local extension in -your dotfiles-backed Pi tree. It replaces the default prompt editor with a -small Vim-like modal editor. +This is now a custom Helix-leaning modal editor for your dotfiles-backed Pi +tree. It replaces the earlier upstream toy example with a more capable normal +mode and a few prompt-editing operations that are actually useful in daily use. ## What It Does -- starts in `INSERT` mode -- `Esc` switches to `NORMAL` -- `i` returns to `INSERT` -- `a` appends and returns to `INSERT` +- starts in `NORMAL` mode +- `Esc` leaves `INSERT` mode and returns to `NORMAL` - `h`, `j`, `k`, `l` move in `NORMAL` -- `0`, `$`, and `x` work in `NORMAL` +- `b`, `w`, `e` handle word motions in `NORMAL` +- `gh` goes to line start and `gl` goes to line end +- `gg` goes to the start of the prompt and `ge` goes to the end +- `i`, `a`, `I`, `A`, `o`, `O` enter insert mode in useful places +- `x` deletes the current character +- `D` deletes from the cursor to line end +- `dd`, `dw`, `de`, `db`, `d0`, and `d$` handle common deletes +- `u` undoes the last change ## Usage Flows ### Flow 1: Edit a prompt normally 1. Start Pi in a real terminal session. -2. Type in `INSERT` mode as usual. -3. Press `Esc` to switch to `NORMAL`. -4. Use `h`, `j`, `k`, `l` to move. -5. Press `i` to return to insert mode. +2. You begin in `NORMAL`. +3. Move with `h`, `j`, `k`, `l`. +4. Jump by word with `b`, `w`, `e`. +5. Press `i` to start inserting text. ### Flow 2: Append instead of inserting -1. Press `Esc`. -2. Press `a`. -3. The cursor moves right and returns to `INSERT`. +1. Press `a` to append after the cursor. +2. Press `A` to append at line end. +3. Press `I` to insert at line start. -### Flow 3: Abort agent work from normal mode +### Flow 3: Use Helix-style line motions + +Move to line start: + +```text +gh +``` + +Move to line end: + +```text +gl +``` + +You still also have `0` and `$` if you want the Vim-style single-key versions. + +### Flow 4: Delete text without dropping into insert mode + +Delete a character: + +```text +x +``` + +Delete the current line: + +```text +dd +``` + +Delete to the next word boundary: + +```text +dw +``` + +Delete to word end: + +```text +de +``` + +Delete from the cursor to the end of the current line: + +```text +D +``` + +### Flow 5: Abort agent work from normal mode When Pi is already running an agent action, `Esc` in `NORMAL` passes through to the app-level handling, so the usual abort behavior still works. @@ -40,6 +93,6 @@ the app-level handling, so the usual abort behavior still works. - This only affects interactive Pi TUI sessions. - It does not matter in one-shot `pi -p` mode. -- This is the stock upstream example, so it is intentionally more Vim-like than - Helix-like. If you want the Helix-shaped editor you described earlier, this - should be treated as the baseline install, not the final customization. +- This is still constrained by Pi's underlying editor model. It is a + Helix-leaning prompt editor, not a full Helix clone with multiple selections, + text objects, or the entire command surface. diff --git a/pi/agent/extensions/modal-editor/index.ts b/pi/agent/extensions/modal-editor/index.ts index e99b69a..abb660a 100644 --- a/pi/agent/extensions/modal-editor/index.ts +++ b/pi/agent/extensions/modal-editor/index.ts @@ -1,26 +1,480 @@ import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; -const NORMAL_KEYS: Record = { +type Mode = "normal" | "insert"; +type PendingAction = "g" | "d" | null; +type CharKind = "whitespace" | "word" | "punct"; + +type EditorStateAccess = { + state: { + lines: string[]; + cursorLine: number; + cursorCol: number; + }; + historyIndex: number; + lastAction: string | null; + preferredVisualCol: number | null; + setCursorCol(col: number): void; + pushUndoSnapshot(): void; + undo(): void; +}; + +type Position = { + line: number; + col: number; +}; + +const NORMAL_KEYS: Record = { h: "\x1b[D", j: "\x1b[B", k: "\x1b[A", l: "\x1b[C", - "0": "\x01", - $: "\x05", - x: "\x1b[3~", - i: null, - a: null, }; +function isWordChar(char: string): boolean { + return /[0-9A-Za-z_]/.test(char); +} + +function charKind(char: string | null): CharKind { + if (char === null || char === "\n" || /\s/.test(char)) return "whitespace"; + return isWordChar(char) ? "word" : "punct"; +} + class ModalEditor extends CustomEditor { - private mode: "normal" | "insert" = "insert"; + private mode: Mode = "normal"; + private pending: PendingAction = null; + + private internals(): EditorStateAccess { + return this as unknown as EditorStateAccess; + } + + private resetTransientState(): void { + const editor = this.internals(); + editor.historyIndex = -1; + editor.lastAction = null; + editor.preferredVisualCol = null; + } + + private setMode(mode: Mode): void { + this.mode = mode; + this.pending = null; + this.tui.requestRender(); + } + + private lines(): string[] { + return this.getLines(); + } + + private currentPosition(): Position { + const cursor = this.getCursor(); + return { line: cursor.line, col: cursor.col }; + } + + private setPosition(position: Position): void { + const editor = this.internals(); + editor.state.cursorLine = position.line; + editor.setCursorCol(position.col); + editor.preferredVisualCol = null; + this.tui.requestRender(); + } + + private withUndo(change: () => void): void { + const editor = this.internals(); + editor.pushUndoSnapshot(); + this.resetTransientState(); + change(); + this.onChange?.(this.getText()); + this.tui.requestRender(); + } + + private moveLeft(position: Position): Position { + const lines = this.lines(); + if (position.col > 0) return { line: position.line, col: position.col - 1 }; + if (position.line > 0) { + return { + line: position.line - 1, + col: (lines[position.line - 1] || "").length, + }; + } + return position; + } + + private moveRight(position: Position): Position { + const lines = this.lines(); + const line = lines[position.line] || ""; + if (position.col < line.length) return { line: position.line, col: position.col + 1 }; + if (position.line < lines.length - 1) return { line: position.line + 1, col: 0 }; + return position; + } + + private charAt(position: Position): string | null { + const lines = this.lines(); + const line = lines[position.line] || ""; + if (position.col < line.length) return line[position.col] || null; + if (position.line < lines.length - 1) return "\n"; + return null; + } + + private charBefore(position: Position): string | null { + const lines = this.lines(); + if (position.col > 0) { + const line = lines[position.line] || ""; + return line[position.col - 1] || null; + } + if (position.line > 0) return "\n"; + return null; + } + + private moveLineStart(): void { + const { line } = this.currentPosition(); + this.setPosition({ line, col: 0 }); + } + + private moveLineEnd(): void { + const { line } = this.currentPosition(); + this.setPosition({ line, col: (this.lines()[line] || "").length }); + } + + private moveFileStart(): void { + this.setPosition({ line: 0, col: 0 }); + } + + private moveFileEnd(): void { + const lines = this.lines(); + const lastLine = Math.max(0, lines.length - 1); + this.setPosition({ line: lastLine, col: (lines[lastLine] || "").length }); + } + + private moveWordBackwardPosition(from: Position): Position { + let position = from; + + while (true) { + const previous = this.charBefore(position); + if (previous === null || charKind(previous) !== "whitespace") break; + const next = this.moveLeft(position); + if (next.line === position.line && next.col === position.col) break; + position = next; + } + + while (true) { + const previous = this.charBefore(position); + if (previous === null) break; + const kind = charKind(previous); + if (kind === "whitespace") break; + const next = this.moveLeft(position); + if (next.line === position.line && next.col === position.col) break; + position = next; + const beforeNext = this.charBefore(position); + if (beforeNext === null || charKind(beforeNext) !== kind) break; + } + + return position; + } + + private moveWordForwardPosition(from: Position): Position { + let position = from; + let current = this.charAt(position); + + if (current !== null && charKind(current) !== "whitespace") { + const kind = charKind(current); + while (current !== null && charKind(current) === kind) { + const next = this.moveRight(position); + if (next.line === position.line && next.col === position.col) break; + position = next; + current = this.charAt(position); + } + } + + current = this.charAt(position); + while (current !== null && charKind(current) === "whitespace") { + const next = this.moveRight(position); + if (next.line === position.line && next.col === position.col) break; + position = next; + current = this.charAt(position); + } + + return position; + } + + private moveWordEndPosition(from: Position): Position { + let position = from; + let current = this.charAt(position); + + while (current !== null && charKind(current) === "whitespace") { + const next = this.moveRight(position); + if (next.line === position.line && next.col === position.col) break; + position = next; + current = this.charAt(position); + } + + if (current === null) return from; + + const kind = charKind(current); + let last = position; + + while (current !== null && charKind(current) === kind) { + last = position; + const next = this.moveRight(position); + if (next.line === position.line && next.col === position.col) break; + position = next; + current = this.charAt(position); + } + + return last; + } + + private moveWordBackward(): void { + this.setPosition(this.moveWordBackwardPosition(this.currentPosition())); + } + + private moveWordForward(): void { + this.setPosition(this.moveWordForwardPosition(this.currentPosition())); + } + + private moveWordEnd(): void { + this.setPosition(this.moveWordEndPosition(this.currentPosition())); + } + + private comparePositions(a: Position, b: Position): number { + if (a.line !== b.line) return a.line - b.line; + return a.col - b.col; + } + + private nextPosition(position: Position): Position { + return this.moveRight(position); + } + + private deleteRange(start: Position, end: Position): void { + if (this.comparePositions(start, end) >= 0) return; + + this.withUndo(() => { + const editor = this.internals(); + const lines = [...editor.state.lines]; + + if (start.line === end.line) { + const line = lines[start.line] || ""; + lines[start.line] = line.slice(0, start.col) + line.slice(end.col); + } else { + const first = (lines[start.line] || "").slice(0, start.col); + const last = (lines[end.line] || "").slice(end.col); + lines.splice(start.line, end.line - start.line + 1, first + last); + } + + editor.state.lines = lines.length > 0 ? lines : [""]; + editor.state.cursorLine = start.line; + editor.setCursorCol(start.col); + }); + } + + private deleteCurrentChar(): void { + const start = this.currentPosition(); + const end = this.nextPosition(start); + if (start.line === end.line && start.col === end.col) return; + this.deleteRange(start, end); + } + + private deleteToLineStart(): void { + const cursor = this.currentPosition(); + this.deleteRange({ line: cursor.line, col: 0 }, cursor); + } + + private deleteToLineEnd(): void { + const cursor = this.currentPosition(); + this.deleteRange(cursor, { line: cursor.line, col: (this.lines()[cursor.line] || "").length }); + } + + private deleteWordBackward(): void { + const cursor = this.currentPosition(); + this.deleteRange(this.moveWordBackwardPosition(cursor), cursor); + } + + private deleteWordForward(): void { + const cursor = this.currentPosition(); + this.deleteRange(cursor, this.moveWordForwardPosition(cursor)); + } + + private deleteToWordEnd(): void { + const cursor = this.currentPosition(); + const end = this.nextPosition(this.moveWordEndPosition(cursor)); + this.deleteRange(cursor, end); + } + + private deleteCurrentLine(): void { + this.withUndo(() => { + const editor = this.internals(); + const lines = [...editor.state.lines]; + const line = editor.state.cursorLine; + + if (lines.length === 1) { + lines[0] = ""; + editor.state.lines = lines; + editor.state.cursorLine = 0; + editor.setCursorCol(0); + return; + } + + lines.splice(line, 1); + const newLine = Math.min(line, lines.length - 1); + editor.state.lines = lines; + editor.state.cursorLine = newLine; + editor.setCursorCol(Math.min(editor.state.cursorCol, (lines[newLine] || "").length)); + }); + } + + private openLineBelow(): void { + this.withUndo(() => { + const editor = this.internals(); + const lines = [...editor.state.lines]; + const line = editor.state.cursorLine; + lines.splice(line + 1, 0, ""); + editor.state.lines = lines; + editor.state.cursorLine = line + 1; + editor.setCursorCol(0); + }); + this.setMode("insert"); + } + + private openLineAbove(): void { + this.withUndo(() => { + const editor = this.internals(); + const lines = [...editor.state.lines]; + const line = editor.state.cursorLine; + lines.splice(line, 0, ""); + editor.state.lines = lines; + editor.state.cursorLine = line; + editor.setCursorCol(0); + }); + this.setMode("insert"); + } + + private handlePending(data: string): boolean { + if (this.pending === "g") { + this.pending = null; + switch (data) { + case "h": + case "0": + this.moveLineStart(); + return true; + case "l": + case "$": + this.moveLineEnd(); + return true; + case "g": + this.moveFileStart(); + return true; + case "e": + this.moveFileEnd(); + return true; + default: + this.tui.requestRender(); + return data.length === 1; + } + } + + if (this.pending === "d") { + this.pending = null; + switch (data) { + case "d": + this.deleteCurrentLine(); + return true; + case "w": + this.deleteWordForward(); + return true; + case "e": + this.deleteToWordEnd(); + return true; + case "b": + this.deleteWordBackward(); + return true; + case "0": + this.deleteToLineStart(); + return true; + case "$": + this.deleteToLineEnd(); + return true; + case "x": + this.deleteCurrentChar(); + return true; + default: + this.tui.requestRender(); + return data.length === 1; + } + } + + return false; + } + + private handleNormalMode(data: string): boolean { + if (this.handlePending(data)) return true; + + if (data in NORMAL_KEYS) { + super.handleInput(NORMAL_KEYS[data]!); + return true; + } + + switch (data) { + case "i": + this.setMode("insert"); + return true; + case "a": + super.handleInput("\x1b[C"); + this.setMode("insert"); + return true; + case "I": + this.moveLineStart(); + this.setMode("insert"); + return true; + case "A": + this.moveLineEnd(); + this.setMode("insert"); + return true; + case "o": + this.openLineBelow(); + return true; + case "O": + this.openLineAbove(); + return true; + case "b": + this.moveWordBackward(); + return true; + case "w": + this.moveWordForward(); + return true; + case "e": + this.moveWordEnd(); + return true; + case "0": + this.moveLineStart(); + return true; + case "$": + this.moveLineEnd(); + return true; + case "D": + this.deleteToLineEnd(); + return true; + case "g": + case "d": + this.pending = data as PendingAction; + this.tui.requestRender(); + return true; + case "x": + this.deleteCurrentChar(); + return true; + case "u": + this.internals().undo(); + this.tui.requestRender(); + return true; + default: + return false; + } + } handleInput(data: string): void { if (matchesKey(data, "escape")) { if (this.mode === "insert") { - this.mode = "normal"; + this.setMode("normal"); } else { + this.pending = null; super.handleInput(data); } return; @@ -31,18 +485,7 @@ class ModalEditor extends CustomEditor { return; } - if (data in NORMAL_KEYS) { - const seq = NORMAL_KEYS[data]; - if (data === "i") { - this.mode = "insert"; - } else if (data === "a") { - this.mode = "insert"; - super.handleInput("\x1b[C"); - } else if (seq) { - super.handleInput(seq); - } - return; - } + if (this.handleNormalMode(data)) return; if (data.length === 1 && data.charCodeAt(0) >= 32) return; super.handleInput(data); @@ -52,7 +495,8 @@ class ModalEditor extends CustomEditor { const lines = super.render(width); if (lines.length === 0) return lines; - const label = this.mode === "normal" ? " NORMAL " : " INSERT "; + const pendingLabel = this.pending ? ` ${this.pending}` : ""; + const label = this.mode === "normal" ? ` NORMAL${pendingLabel} ` : " INSERT "; const last = lines.length - 1; if (visibleWidth(lines[last]!) >= label.length) { lines[last] = truncateToWidth(lines[last]!, width - label.length, "") + label; diff --git a/pi/agent/extensions/taskwarrior-plan-mode/README.md b/pi/agent/extensions/taskwarrior-plan-mode/README.md index 92de7e6..b280bcb 100644 --- a/pi/agent/extensions/taskwarrior-plan-mode/README.md +++ b/pi/agent/extensions/taskwarrior-plan-mode/README.md @@ -151,7 +151,13 @@ Analyze the repo and give me a Plan: for the next implementation slice. - Planning mode is read-only by design. - All Taskwarrior operations still go through `ask`, never raw `task`. +- `ask` must use real Taskwarrior CLI syntax. It is not a natural-language + task assistant and should never be called like `ask taskwarrior-task-management ...`. - Execution mode injects the current Taskwarrior task back into the agent prompt so the model works against the real task rather than an in-memory checklist. +- Execution mode now treats the focused task as the already-selected starting + point and blocks repeated identical `ask uuid:` lookups until the + agent has moved on to repo inspection, implementation, tests, review, or a + different command. - Full `/plan` state is not meant to be passed across unrelated one-shot `pi -p` invocations. Use a real interactive or continued session for planning. diff --git a/pi/agent/extensions/taskwarrior-plan-mode/index.ts b/pi/agent/extensions/taskwarrior-plan-mode/index.ts index 6fbfac3..59a223e 100644 --- a/pi/agent/extensions/taskwarrior-plan-mode/index.ts +++ b/pi/agent/extensions/taskwarrior-plan-mode/index.ts @@ -33,6 +33,42 @@ interface WorkOnTasksArgs { maxTasks?: number; } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function normalizeCommandText(command: string): string { + return command.trim().replace(/\s+/g, " "); +} + +function isMutatingAskCommand(command: string): boolean { + return /\b(add|annotate|append|delete|denotate|done|log|modify|prepend|start|stop|undo)\b/.test(command); +} + +function repeatedCurrentTaskLookupKey(command: string, currentTaskUuid?: string): string | undefined { + if (!currentTaskUuid) return undefined; + + const normalized = normalizeCommandText(command); + if (!/^ask(?:\s|$)/.test(normalized)) return undefined; + if (isMutatingAskCommand(normalized)) return undefined; + + const uuidPattern = new RegExp(`(?:^|\\s)["']?uuid:${escapeRegExp(currentTaskUuid)}["']?(?:\\s|$)`); + if (!uuidPattern.test(normalized)) return undefined; + + return normalized; +} + +function malformedAskReason(command: string): string | undefined { + const normalized = normalizeCommandText(command); + if (!/^ask(?:\s|$)/.test(normalized)) return undefined; + + if (/\btaskwarrior-task-management\b/.test(normalized)) { + return "The 'ask' command