diff --git a/pi/.pi/agent/extensions/delegate/README.md b/pi/.pi/agent/extensions/delegate/README.md index 4948d95..bbb3da6 100644 --- a/pi/.pi/agent/extensions/delegate/README.md +++ b/pi/.pi/agent/extensions/delegate/README.md @@ -14,6 +14,7 @@ The tool accepts: ```ts delegate({ + label: "Plan caching improvements", task: "Inspect the caching implementation. Return a plan, relevant files, risks, and validation steps. Do not modify files.", model: "provider/model-id", thinking: "high", @@ -21,14 +22,36 @@ delegate({ }) ``` -Replace `provider/model-id` with an exact ID from `pi --list-models`. Model IDs may themselves contain slashes. `model` and `task` are required; `thinking` inherits the parent's level when omitted (Pi clamps it to the child's model capabilities). +Replace `provider/model-id` with an exact ID from `pi --list-models`. Model IDs may themselves contain slashes. `label` is a short display summary derived by the calling agent (action + specific targets, up to 80 characters), such as `Rebase PRs #6182–6184`. Keep shared context and constraints in `task`, not the label. No extra model request is needed. Calls without a label fall back to a task preview, including older session entries. -`cwd` selects an **existing directory**, defaulting to the parent's working directory. Relative paths resolve against the parent cwd; absolute paths, `~/` paths, and a leading `@` are supported. Symlinks are resolved and non-directories rejected before starting a child. The canonical path is shown in progress and final results. +`model` and `task` are required; `thinking` inherits the parent's level when omitted (Pi clamps it to the child's model capabilities). + +`cwd` selects an **existing directory**, defaulting to the parent's working directory. Relative paths resolve against the parent cwd; absolute paths, `~/` paths, and a leading `@` are supported. Symlinks are resolved and non-directories rejected before starting a child. The canonical path is shown in expanded progress and final results (Ctrl+O). `tools` defaults to `read,bash,edit,write`. The optional allowlist accepts `read`, `bash`, `edit`, `write`, `grep`, `find`, and `ls`. Use `read,grep,find,ls` for planning/review without write or shell tools. Bash can modify files even without `edit` or `write`. For implementation, call delegate again with the implementation model and include the approved plan in the task. There is no implicit handoff or automatic planning/review policy. Keep routing preferences in `AGENTS.md` if desired; this extension does not change them. +## Optional tmux monitor panes + +Configure display in Pi's `~/.pi/agent/settings.json` using the extension-owned `pi-delegate` namespace: + +```json +{ + "pi-delegate": { + "display": "tmux" + } +} +``` + +Hidden subprocesses remain the default: omit `pi-delegate.display` or set it to `"hidden"` to disable panes. Invalid values also use hidden execution. The extension reads Pi's effective settings through `pi.getSettings()`, including project overrides. Run `/reload` after editing settings; no restart is needed. `PI_DELEGATE_DISPLAY` is no longer used. These extension-owned settings do not automatically appear in Pi's interactive `/settings` menu. There is no display slash command. + +The first running delegate opens a monitor **to the right of the parent's `$TMUX_PANE`**, without changing focus. Additional delegates stack vertically within that right-hand column by splitting the tallest surviving monitor; they do not split the Pi pane again. The right-hand monitors are rebalanced to equal heights (within one terminal row) after each launch and automatic close, without resizing Pi or unrelated panes. Concurrent launches and pane cleanup are serialized per parent. Queued delegates do not open panes. The viewer shows the label, model, cwd, status, elapsed time, cost, recent tool activity, and latest text. It is a **read-only monitor**, not another interactive Pi TUI: the actual child remains parent-owned, and results, usage, queueing, and cancellation work exactly as before. Cancel in the parent; closing a monitor manually does not cancel the child. + +Panes close automatically when the child finishes, fails, or is cancelled, including during reload/shutdown. Final reports remain in the parent Pi transcript. Missing tmux, an invalid parent pane, or insufficient room for a split falls back to hidden execution (with a UI warning). This does not rearrange unrelated panes or change the window layout globally; with several delegates, monitors may get short. When the last monitor closes, Pi regains the column's width. Manually closed monitors are reconciled before the next split. + +Monitor snapshots are bounded, stored in a private temporary directory, and removed after the run. The standalone viewer exits if its parent dies or the snapshot disappears. A forcibly killed parent can leave a temporary directory (and a dead pane if tmux's `remain-on-exit` is enabled); this is not durable background execution. The viewer requires Node.js (or Bun); standalone Pi builds use `node` from PATH. + ## Choosing isolation with Worktrunk The **parent agent decides before launching** whether isolation is needed. The extension does not classify tasks, create worktrees, switch branches, merge, or clean up worktrees. @@ -60,7 +83,7 @@ New worktrees contain the selected committed base, **not the parent's uncommitte - Each call starts a fresh Pi process in its selected working directory. Repository/global context files and normal skill discovery remain available; the parent's conversation is **not** copied. - The tool stays pending until the child exits. Up to **three children run concurrently per parent session**; further calls wait in a cancellable FIFO queue. This limit does not cover other Pi sessions. Pi must also be in parallel tool mode: a sequential tool anywhere in the same assistant batch makes that entire batch sequential. -- Progress is streamed by code, not model-driven polling. Each row shows queued/running/final status, canonical cwd, time spent queued, and child execution time. One-second heartbeats update timing even when the child is quiet. Queueing does not launch a child or consume model tokens. +- Progress is streamed by code, not model-driven polling. Collapsed rows show just the short model name, queued/running/final status, elapsed time (queue time while queued), rounded cost, and a one-line label. Ctrl+O reveals the full model ID and task, canonical cwd, queue/execution timing, detailed usage, activity, and output. One-second heartbeats update timing even when the child is quiet. Queueing does not launch a child or consume model tokens. - Parent and sibling tools can now execute alongside delegates. The parent must avoid writes, staging, branch changes, and conflicting external actions in a workspace used by a child. The extension does not enforce workspace ownership or coordinate other processes. - The child uses the running Pi executable/version, normal configured providers, and inherited environment. Extensions, MCP, and prompt templates are disabled. Providers/authentication available **only** through parent extensions or transient parent API-key overrides are not transferred. - The parent's current project-trust decision is passed explicitly **only when the canonical selected cwd equals the parent's canonical cwd**. Any different directory, including another worktree or subdirectory, starts with `--no-approve`; a trusted parent does not silently approve another project's dynamic configuration. Global context remains available, but project-local trust-gated resources in a new worktree are not enabled by delegation. @@ -69,14 +92,14 @@ New worktrees contain the selected committed base, **not the parent's uncommitte - Children share the selected checkout and inherited credentials. Isolation exists only when the parent supplies independent worktrees; there is no automatic isolation or parent permission-hook enforcement inside the child. Do not use this where disabling parent security extensions would violate your expectations. - Only a normally stopped final text response counts as success. Provider errors, token limits, nonzero exits, invalid JSONL, and missing final responses are errors; available partial text, usage, and bounded stderr are preserved. - Output is limited to 50 KiB/2,000 lines. Longer results are saved in a private temporary directory, with the path included in the result. These files are not automatically deleted; remove them when no longer needed. Child sessions are otherwise ephemeral (`--no-session`). -- Recent tool activity and text stream into the tool row; expand it for more output. Child model usage is returned to Pi's session accounting. +- Recent tool activity and text stream into the expanded tool row; they are hidden when collapsed. Child model usage is returned to Pi's session accounting. ## Tests From the `pi/` Stow package directory, with Node.js 24+ and the managed Pi installation present: ```sh -node --test .pi/agent/extensions/delegate/runtime.test.ts .pi/agent/extensions/delegate/index.test.mjs +node --test .pi/agent/extensions/delegate/runtime.test.ts .pi/agent/extensions/delegate/index.test.mjs .pi/agent/extensions/delegate/monitor.test.ts ``` -Tests cover overlapping child processes and the three-child cap, queue cancellation/shutdown, cwd resolution and trust boundaries, queued/timing rendering, awaited completion, streaming, model/tool arguments, UTF-8/JSONL framing, usage, errors, truncation, recursion guards, and cancellation/shutdown. They use local fake child processes, not paid model requests. `index.test.mjs` uses the installed Pi extension loader; `runtime.test.ts` tests the process lifecycle independently. +Tests cover overlapping child processes and the three-child cap, queue cancellation/shutdown, cwd resolution and trust boundaries, queued/timing rendering, awaited completion, streaming, model/tool arguments, UTF-8/JSONL framing, usage, errors, truncation, recursion guards, and cancellation/shutdown. Monitor tests also cover private/bounded snapshots, display fallback, terminal-control sanitization, and a real isolated tmux server (skipped if tmux is unavailable). They use local fake child processes, not paid model requests. `index.test.mjs` uses the installed Pi extension loader; `runtime.test.ts` tests the process lifecycle independently. diff --git a/pi/.pi/agent/extensions/delegate/index.test.mjs b/pi/.pi/agent/extensions/delegate/index.test.mjs index 0058d15..8e29036 100644 --- a/pi/.pi/agent/extensions/delegate/index.test.mjs +++ b/pi/.pi/agent/extensions/delegate/index.test.mjs @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { stripVTControlCharacters } from "node:util"; const agentDir = fileURLToPath(new URL("../../", import.meta.url)); const version = (await readFile(join(agentDir, "install/current-version"), "utf8")).trim(); @@ -13,12 +14,13 @@ const loaderPath = join(agentDir, "install/releases", version, "node_modules/@ea const { loadExtensions } = await import(pathToFileURL(loaderPath).href); const extensionPath = fileURLToPath(new URL("index.ts", import.meta.url)); -async function fixture(fn) { +async function fixture(fn, config) { const directory = await mkdtemp(join(tmpdir(), "delegate-extension-test-")); const oldScript = process.argv[1]; try { const loaded = await loadExtensions([extensionPath], directory); assert.deepEqual(loaded.errors, []); + loaded.runtime.getSettings = () => config === undefined ? {} : { "pi-delegate": config }; const extension = loaded.extensions[0]; const tool = extension.tools.get("delegate").definition; const script = join(directory, "fake-pi.mjs"); @@ -56,7 +58,8 @@ async function fixture(fn) { test("tool loads with a small schema and forwards model/thinking/tools to an awaited child", async () => { await fixture(async ({ tool, ctx }) => { assert.equal(tool.executionMode, "parallel"); - assert.deepEqual(Object.keys(tool.parameters.properties).sort(), ["cwd", "model", "task", "thinking", "tools"]); + assert.ok(tool.promptGuidelines.includes("Avoid delegating trivial work unless the user explicitly requests delegation, including for testing.")); + assert.deepEqual(Object.keys(tool.parameters.properties).sort(), ["cwd", "label", "model", "task", "thinking", "tools"]); const result = await tool.execute("test", { task: "Plan this", model: "test/vendor/model", thinking: "high", tools: ["read", "grep"], }, undefined, undefined, ctx); @@ -71,6 +74,37 @@ test("tool loads with a small schema and forwards model/thinking/tools to an awa }); }); +test("only pi-delegate.display enables monitors; legacy environment is ignored and unavailable tmux falls back", async () => { + const oldDisplay = process.env.PI_DELEGATE_DISPLAY; + try { + for (const legacy of ["tmux", "hidden"]) { + process.env.PI_DELEGATE_DISPLAY = legacy; + for (const config of [undefined, null, "tmux", [], {}, { display: "hidden" }, { display: "invalid" }, { display: true }, { display: "tmux" }]) { + await fixture(async ({ tool, ctx, extension }) => { + assert.equal(extension.commands.has("delegate-display"), false); + const notices = []; + ctx.hasUI = true; + ctx.ui = { notify: (text) => notices.push(text) }; + const oldPane = process.env.TMUX_PANE; + process.env.TMUX_PANE = ""; + try { + const result = await tool.execute("test", { task: "Inspect", model: "test/vendor/model" }, undefined, undefined, ctx); + assert.equal(result.isError, false); + assert.equal(result.details.status, "completed"); + assert.equal(notices.some((text) => text.includes("continuing hidden")), config?.display === "tmux"); + } finally { + if (oldPane === undefined) delete process.env.TMUX_PANE; + else process.env.TMUX_PANE = oldPane; + } + }, config); + } + } + } finally { + if (oldDisplay === undefined) delete process.env.PI_DELEGATE_DISPLAY; + else process.env.PI_DELEGATE_DISPLAY = oldDisplay; + } +}); + test("omitted thinking inherits from the parent; tools use the documented default", async () => { await fixture(async ({ tool, ctx }) => { const result = await tool.execute("test", { task: "Implement", model: "test/vendor/model" }, undefined, undefined, ctx); @@ -272,16 +306,63 @@ test("pre-aborted tool calls never start children", async () => { }); }); -test("renderers preserve queued state and show canonical cwd and timing", async () => { +test("collapsed delegate rows stay two lines and expansion reveals details", async () => { + await fixture(async ({ tool, ctx }) => { + const theme = { fg: (_color, text) => text, bold: (text) => text }; + const args = { model: "test/vendor/model", label: "Check CI\nfor PRs #6182–6184", + task: "User explicitly requests checking CI. Handle ONLY PRs #6182–6184. Do not modify files.", + }; + const result = await tool.execute("test", args, undefined, undefined, ctx); + assert.equal(JSON.parse(result.details.output).task, `Task: ${args.task}`); + assert.ok(tool.promptGuidelines.some((guideline) => guideline.includes("short label derived from its task"))); + const state = {}; + const context = { args, state, cwd: ctx.cwd, expanded: false }; + assert.deepEqual(tool.renderCall(args, theme, context).render(200), [ + "delegate model", "Check CI for PRs #6182–6184", + ]); + for (const status of ["queued", "running", "completed", "failed", "aborted"]) { + const details = { ...result.details, status, queuedMs: 2100, elapsedMs: 41000, + usage: { ...result.details.usage, cost: { total: 0.1234 } }, + activity: ["→ bash a very long command\nwith multiple lines", "✓ bash"], + }; + // Match Pi's composition order: call first, result second, then render. + const header = tool.renderCall(args, theme, context); + const body = tool.renderResult({ ...result, details }, { expanded: false }, theme, context); + assert.deepEqual(body.render(200), []); + assert.deepEqual(header.render(200), [ + `delegate model · ${status} ${status === "queued" ? "2s" : "41s"} · $0.12`, + "Check CI for PRs #6182–6184", + ]); + const narrow = header.render(20); + assert.equal(narrow.length, 2); + assert.ok(narrow.every((line) => stripVTControlCharacters(line).length <= 20)); + + const expandedContext = { ...context, expanded: true }; + const expandedHeader = tool.renderCall(args, theme, expandedContext); + const expanded = tool.renderResult({ ...result, details }, { expanded: true }, theme, expandedContext).render(10000).join("\n"); + const expandedTitle = expandedHeader.render(200).map((line) => line.trimEnd()).join("\n"); + assert.match(expandedTitle, /delegate test\/vendor\/model/); + assert.ok(expandedTitle.includes(args.task)); + assert.ok(expandedTitle.includes("Check CI for PRs #6182–6184")); + assert.match(expanded, /41s elapsed · 2s queued/); + assert.ok(expanded.includes(result.details.cwd)); + assert.ok(expanded.includes("turns · ↑10 ↓2 · $0.1234")); + assert.ok(details.activity.join("\n").split("\n").every((line) => expanded.includes(line))); + assert.ok(expanded.includes(result.content[0].text)); + } + }); +}); + +test("errors without delegate details stay compact but can be expanded", async () => { await fixture(async ({ tool, ctx }) => { const theme = { fg: (_color, text) => text, bold: (text) => text }; - const result = await tool.execute("test", { task: "Inspect", model: "test/vendor/model" }, undefined, undefined, ctx); - const details = { ...result.details, status: "queued", queuedMs: 2100, elapsedMs: 0 }; - const rendered = tool.renderResult({ ...result, details }, { expanded: false, isPartial: true }, theme).render(200).join("\n"); - assert.match(rendered, /queued · 0s elapsed · 2s queued/); - assert.ok(rendered.includes(result.details.cwd)); - const header = tool.renderCall({ model: "test/vendor/model", task: "Inspect" }, theme, { cwd: ctx.cwd }).render(200).join("\n"); - assert.ok(header.includes(ctx.cwd)); + const args = { model: "test/vendor/model", task: "Inspect" }; + const context = { args, state: {}, cwd: ctx.cwd, isError: true, expanded: false }; + const result = { content: [{ type: "text", text: "Unknown model" }] }; + const header = tool.renderCall(args, theme, context); + assert.deepEqual(tool.renderResult(result, { expanded: false }, theme, context).render(200), []); + assert.deepEqual(header.render(200), ["delegate model · failed", "Inspect"]); + assert.deepEqual(tool.renderResult(result, { expanded: true }, theme, context).render(200).map((line) => line.trimEnd()), ["Unknown model"]); }); }); diff --git a/pi/.pi/agent/extensions/delegate/index.ts b/pi/.pi/agent/extensions/delegate/index.ts index 7685f58..fe6ffaa 100644 --- a/pi/.pi/agent/extensions/delegate/index.ts +++ b/pi/.pi/agent/extensions/delegate/index.ts @@ -2,9 +2,10 @@ import { mkdtemp, realpath, stat, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { truncateHead, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { Text } from "@earendil-works/pi-tui"; +import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { DelegateQueue } from "./queue.ts"; +import { openMonitor, type DelegateMonitor } from "./monitor.ts"; import { initialDetails, runDelegate, type DelegateDetails, type DelegateOptions } from "./runtime.ts"; const MAX_CONCURRENT_CHILDREN = 3; @@ -38,6 +39,9 @@ const DELEGATE_PROMPT_GUIDELINES = ` Give delegate a self-contained task, explicit edit permissions, and expected output; children cannot see this conversation. +Give every delegate call a short label derived from its task (action + specific targets, +e.g. "Rebase PRs #6182–6184"). Omit shared context, constraints, and boilerplate from the label. + Before using delegate, decide whether the task needs isolation. For independent write tasks, use wt to prepare separate worktrees first, then pass each path as cwd. @@ -46,8 +50,11 @@ Never reuse a worktree being used by another agent or the user. For read-only delegate investigations, prefer tools read,grep,find,ls without a new worktree. If isolation is uncertain, investigate read-only first. Bash and builds/tests may write files. -Do not delegate trivial work or run delegate concurrently with parent or sibling -writes/branch changes in the same checkout. cwd is not a sandbox; +Avoid delegating trivial work unless the user explicitly requests delegation, +including for testing. + +Do not run delegate concurrently with parent or sibling writes/branch changes +in the same checkout. cwd is not a sandbox; different subdirectories are not separate worktrees. Verify child findings before acting. When preparing delegate worktrees with wt, choose an explicit base; @@ -56,6 +63,11 @@ assume background setup is finished, or automatically remove worktrees containin `.split("\n\n").map(unwrap); const DELEGATE_PARAMETERS = Type.Object({ + label: Type.Optional(Type.String({ + minLength: 1, + maxLength: 80, + description: "Short display label derived from the task: action + specific targets, e.g. Rebase PRs #6182–6184. No boilerplate.", + })), task: Type.String({ minLength: 1, description: "Self-contained task, context, constraints, and expected output", @@ -155,6 +167,12 @@ export default function (pi: ExtensionAPI) { throw new Error(`Unknown model: ${params.model}. Use pi --list-models to find exact IDs.`); } + const settings = pi.getSettings() as Record; + const config = settings["pi-delegate"]; + const display = config !== null && typeof config === "object" + && !Array.isArray(config) && (config as { display?: unknown }).display === "tmux" + ? "tmux" : "hidden"; + const queuedAt = Date.now(); const controller = new AbortController(); const childSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; @@ -204,10 +222,21 @@ export default function (pi: ExtensionAPI) { error: "Delegation cancelled before launch.", }; } + let monitor: DelegateMonitor | undefined; try { - return await runDelegate(options, childSignal, publish); + if (display === "tmux" && !childSignal.aborted) { + try { + monitor = await openMonitor(params.label || params.task, queued); + } catch { + if (ctx.hasUI) ctx.ui.notify("Delegate tmux monitor unavailable; continuing hidden.", "warning"); + } + } + return await runDelegate(options, childSignal, (details) => { + monitor?.update(details); + publish(details); + }); } finally { - release(); + try { await monitor?.close(); } finally { release(); } } })(); @@ -220,15 +249,36 @@ export default function (pi: ExtensionAPI) { }, renderCall(args, theme, context) { - const task = args.task?.replace(/\s+/g, " ").slice(0, 140) ?? "…"; - return new Text([ - theme.fg("toolTitle", theme.bold("delegate ")) + theme.fg("accent", args.model ?? "…"), - theme.fg("dim", task), - theme.fg("muted", `cwd: ${args.cwd ?? context.cwd}`), - ].join("\n"), 0, 0); + // The call slot is composed before the result slot. Read shared details at + // render time so each progress update appears in the same compact header. + return { + render(width: number) { + const details = context.state.details as DelegateDetails | undefined; + const model = context.expanded ? args.model : args.model?.split("/").pop(); + let title = theme.fg("toolTitle", theme.bold("delegate ")) + theme.fg("accent", model ?? "…"); + if (details) { + const ms = details.status === "queued" ? details.queuedMs : details.elapsedMs; + title += theme.fg(STATUS_COLORS[details.status] ?? "error", ` · ${details.status} ${seconds(ms)}`); + title += theme.fg("dim", ` · $${details.usage.cost.total.toFixed(2)}`); + } else if (context.isError) { + title += theme.fg("error", " · failed"); + } + const task = args.task ?? "…"; + const label = args.label?.replace(/\s+/g, " ").trim() || task.replace(/\s+/g, " ").trim(); + const lines = context.expanded + ? [title, ...(args.label ? [theme.fg("dim", label)] : []), theme.fg("dim", task)] + : [title, theme.fg("dim", label)]; + return context.expanded + ? new Text(lines.join("\n"), 0, 0).render(width) + : lines.map((line) => truncateToWidth(line, width)); + }, + invalidate() {}, + }; }, - renderResult(result, { expanded }, theme) { + renderResult(result, { expanded }, theme, context) { + context.state.details = result.details; + if (!expanded) return new Container(); const details = result.details as DelegateDetails | undefined; const content = result.content .filter((part) => part.type === "text") @@ -239,14 +289,13 @@ export default function (pi: ExtensionAPI) { const { status, usage } = details; const timing = `${seconds(details.elapsedMs)} elapsed · ${seconds(details.queuedMs)} queued`; const cost = `$${usage.cost.total.toFixed(4)}`; - const activity = (expanded ? details.activity : details.activity.slice(-3)).join("\n"); - const preview = expanded ? content : content.split("\n").slice(-6).join("\n").slice(-1500); + const activity = details.activity.join("\n"); return new Text([ theme.fg(STATUS_COLORS[status] ?? "error", `${status} · ${timing}`), details.cwd && theme.fg("muted", `cwd: ${details.cwd}`), theme.fg("dim", `${details.turns} turns · ↑${usage.input} ↓${usage.output} · ${cost}`), activity && theme.fg("muted", activity), - preview, + content, ].filter(Boolean).join("\n"), 0, 0); }, }); diff --git a/pi/.pi/agent/extensions/delegate/monitor-view.mjs b/pi/.pi/agent/extensions/delegate/monitor-view.mjs new file mode 100644 index 0000000..d710ee1 --- /dev/null +++ b/pi/.pi/agent/extensions/delegate/monitor-view.mjs @@ -0,0 +1,43 @@ +// Standalone, dependency-free pane viewer; never starts another agent. +import { readFileSync, realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { stripVTControlCharacters } from "node:util"; + +export function safeText(value) { + return stripVTControlCharacters(String(value ?? "")).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, ""); +} + +export function renderMonitor(state) { + return safeText([ + `Delegate · ${state.status} · ${Math.floor(state.elapsedMs / 1000)}s · $${state.cost.toFixed(4)}`, + state.label, state.model, `cwd: ${state.cwd}`, + "Monitor only · cancel in parent Pi · closes when done", "", + ...state.activity.slice(-6), "", state.error, state.output || "Working…", + ].filter((line) => line !== undefined).join("\n")); +} + +// Node canonicalizes import.meta.url, but argv keeps the Stow symlink path. +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [path, owner] = process.argv.slice(2); + const tick = () => { + try { + process.kill(Number(owner), 0); + const state = JSON.parse(readFileSync(path, "utf8")); + const lines = renderMonitor(state).split("\n"); + const rows = Math.max(1, (process.stdout.rows || 24) - 1); + // Keep the header and newest content visible without growing scrollback. + const headerRows = Math.min(5, rows); + const tailRows = rows - headerRows; + const visible = lines.length > rows + ? [...lines.slice(0, headerRows), ...(tailRows ? lines.slice(-tailRows) : [])] + : lines; + const columns = Math.max(1, (process.stdout.columns || 80) - 1); + process.stdout.write("\x1b[H\x1b[2J" + visible.map((line) => Array.from(line).slice(0, columns).join("")).join("\n")); + } catch { + // Parent died or removed its private snapshot. Do not leave a live viewer behind. + process.exit(0); + } + }; + tick(); + setInterval(tick, 250); +} diff --git a/pi/.pi/agent/extensions/delegate/monitor.test.ts b/pi/.pi/agent/extensions/delegate/monitor.test.ts new file mode 100644 index 0000000..a5f2e4a --- /dev/null +++ b/pi/.pi/agent/extensions/delegate/monitor.test.ts @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import { execFile, spawn } from "node:child_process"; +import { once } from "node:events"; +import { readFile, stat, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { openMonitor } from "./monitor.ts"; +import { initialDetails } from "./runtime.ts"; +import { renderMonitor, safeText } from "./monitor-view.mjs"; + +const details = () => initialDetails({ task: "Inspect", provider: "test", model: "model", cwd: tmpdir(), trusted: false }); +const exec = promisify(execFile); + +test("monitor targets the parent without focus, bounds private snapshots, and cleans up idempotently", async () => { + const calls: string[][] = []; + const monitor = await openMonitor("Inspect 'quotes'; $(false)", details(), "%123", async (args) => { + calls.push(args); + return "%456"; + }); + const args = calls[0]; + assert.deepEqual(args.slice(0, 9), ["split-window", "-d", "-h", "-t", "%123", "-P", "-F", "#{pane_id}", process.execPath]); + const path = args.at(-2)!; + try { + assert.equal((await stat(path)).mode & 0o777, 0o600); + assert.equal((await stat(dirname(path))).mode & 0o777, 0o700); + monitor.update({ ...details(), status: "running", output: "x".repeat(100000) }); + const state = JSON.parse(await readFile(path, "utf8")); + assert.equal(state.output.length, 16384); + assert.equal(state.label, "Inspect 'quotes'; $(false)"); + } finally { await monitor.close(); } + await monitor.close(); + monitor.update(details()); + assert.deepEqual(calls[1], ["kill-pane", "-t", "%456"]); + assert.equal(calls.length, 2); + await assert.rejects(stat(dirname(path)), { code: "ENOENT" }); +}); + +test("missing/invalid pane and split failures fall back without leaking snapshot files", async () => { + for (const target of ["", "not-a-pane"]) { + await assert.rejects(openMonitor("Inspect", details(), target, async () => assert.fail("must not call tmux"))); + } + let path = ""; + await assert.rejects(openMonitor("Inspect", details(), "%1", async (args) => { + path = args.at(-2)!; + throw new Error("no space for pane"); + })); + await assert.rejects(stat(dirname(path)), { code: "ENOENT" }); +}); + +test("closed panes and missing snapshot directories do not break child progress or cleanup", async () => { + let path = ""; + const monitor = await openMonitor("Inspect", details(), "%1", async (args) => { + if (args[0] === "kill-pane") throw new Error("pane already closed"); + path = args.at(-2)!; + return "%2"; + }); + await rm(dirname(path), { recursive: true }); + assert.doesNotThrow(() => monitor.update(details())); + await monitor.close(); +}); + +test("viewer formats status/activity/output and strips terminal control sequences", () => { + assert.equal(safeText("a\x1b[31mb\x1b[0m\x07\r\x1b]52;c;secret\x07"), "ab"); + const text = renderMonitor({ ...details(), label: "Inspect", cost: 0.12, activity: ["→ read file"], output: "Found it" }); + assert.match(text, /Delegate · queued · 0s · \$0.1200/); + assert.match(text, /→ read file/); + assert.match(text, /Found it/); +}); + +test("viewer launched through a Stow-style symlink renders and exits when its snapshot disappears", { timeout: 5000 }, async () => { + const directory = await mkdtemp(join(tmpdir(), "delegate-viewer-test-")); + const viewer = join(directory, "viewer.mjs"); + const path = join(directory, "state.json"); + await symlink(fileURLToPath(new URL("monitor-view.mjs", import.meta.url)), viewer); + await writeFile(path, JSON.stringify({ ...details(), label: "SYMLINK_MONITOR", cost: 0 })); + const proc = spawn(process.execPath, [viewer, path, String(process.pid)], { stdio: ["ignore", "pipe", "pipe"] }); + const exited = once(proc, "close"); + try { + const output = await Promise.race([ + once(proc.stdout, "data").then(([data]) => String(data)), + exited.then(([code]) => { throw new Error(`Viewer exited before rendering: ${code}`); }), + ]); + assert.match(output, /SYMLINK_MONITOR/); + await rm(path); + const [code] = await exited; + assert.equal(code, 0); + } finally { + if (proc.exitCode === null) proc.kill(); + await exited; + await rm(directory, { recursive: true, force: true }); + } +}); + +test("real tmux pane stays in the parent's window, renders updates, and closes", { timeout: 10000 }, async (t) => { + try { await exec("tmux", ["-V"]); } catch { t.skip("tmux unavailable"); return; } + const directory = await mkdtemp(join(tmpdir(), "delegate-tmux-test-")); + const socket = join(directory, "socket"); + const command = async (args: string[]) => (await exec("tmux", ["-S", socket, ...args], { timeout: 2000 })).stdout.trim(); + let monitor: Awaited> | undefined; + try { + const parent = await command(["-f", "/dev/null", "new-session", "-d", "-x", "120", "-y", "99", "-P", "-F", "#{pane_id}", "/bin/sleep", "60"]); + const unrelated = await command(["split-window", "-d", "-v", "-t", parent, "-l", "49", "-P", "-F", "#{pane_id}", "/bin/sleep", "60"]); + const unrelatedGeometry = await command(["display-message", "-p", "-t", unrelated, "#{pane_left} #{pane_top} #{pane_width} #{pane_height}"]); + monitor = await openMonitor("Inspect test", details(), parent, command); + const panes = (await command(["list-panes", "-t", parent, "-F", "#{pane_id}"])).split("\n"); + assert.equal(panes.length, 3); + const pane = panes.find((id) => id !== parent && id !== unrelated)!; + assert.equal(await command(["display-message", "-p", "-t", parent, "#{pane_active}"]), "1"); + monitor.update({ ...details(), status: "running", output: "LIVE_MONITOR_OUTPUT" }); + let captured = ""; + for (let i = 0; i < 50; i++) { + captured = await command(["capture-pane", "-p", "-t", pane]); + if (captured.includes("LIVE_MONITOR_OUTPUT")) break; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + assert.match(captured, /Inspect test/); + assert.match(captured, /LIVE_MONITOR_OUTPUT/); + + const geometry = async () => (await command([ + "list-panes", "-t", parent, "-F", "#{pane_id} #{pane_left} #{pane_top} #{pane_width} #{pane_height}", + ])).split("\n").map((line) => { + const [id, ...values] = line.split(" "); + const [left, top, width, height] = values.map(Number); + return { id, left, top, width, height }; + }); + const parentGeometry = (await geometry()).find(({ id }) => id === parent)!; + assert.equal(parentGeometry.height, 49); + assert.ok(parentGeometry.width < 120); + const others = await Promise.all([ + openMonitor("Second", details(), parent, command), + openMonitor("Third", details(), parent, command), + ]); + try { + const assertColumn = async (count: number) => { + const panes = await geometry(); + assert.equal(panes.length, count + 2); + assert.equal(await command(["display-message", "-p", "-t", unrelated, "#{pane_left} #{pane_top} #{pane_width} #{pane_height}"]), unrelatedGeometry); + assert.deepEqual(panes.find(({ id }) => id === parent), parentGeometry); + const monitors = panes.filter(({ id }) => id !== parent && id !== unrelated); + for (const item of monitors) { + assert.equal(item.left, parentGeometry.width + 1); + assert.equal(item.width, 120 - parentGeometry.width - 1); + } + const heights = monitors.map(({ height }) => height); + assert.ok(Math.max(...heights) - Math.min(...heights) <= 1, `Unbalanced monitor heights: ${heights}`); + assert.equal(new Set(monitors.map(({ top }) => top)).size, count); + assert.equal(await command(["display-message", "-p", "-t", parent, "#{pane_active}"]), "1"); + }; + await assertColumn(3); + // Finishing the first monitor must not lose the right-hand stack. + await monitor.close(); + await assertColumn(2); + const replacement = await openMonitor("Replacement", details(), parent, command); + try { await assertColumn(3); } finally { await replacement.close(); } + // Manually closing all remaining viewers makes the next launch start right again. + for (const item of (await geometry()).filter(({ id }) => id !== parent && id !== unrelated)) { + await command(["kill-pane", "-t", item.id]); + } + const restarted = await openMonitor("Restarted", details(), parent, command); + try { await assertColumn(1); } finally { await restarted.close(); } + } finally { + await Promise.all(others.map((item) => item.close())); + } + await monitor.close(); + assert.deepEqual((await command(["list-panes", "-t", parent, "-F", "#{pane_id}"])).split("\n"), [parent, unrelated]); + } finally { + await monitor?.close(); + try { await command(["kill-server"]); } catch { /* already stopped */ } + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/pi/.pi/agent/extensions/delegate/monitor.ts b/pi/.pi/agent/extensions/delegate/monitor.ts new file mode 100644 index 0000000..4c785ec --- /dev/null +++ b/pi/.pi/agent/extensions/delegate/monitor.ts @@ -0,0 +1,133 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { renameSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import type { DelegateDetails } from "./runtime.ts"; + +const exec = promisify(execFile); +export type TmuxCommand = (args: string[]) => Promise; +const tmux: TmuxCommand = async (args) => (await exec("tmux", args, { + timeout: 2000, maxBuffer: 16384, +})).stdout.trim(); + +interface PaneGroup { + panes: string[]; + pending: Promise; +} + +// Serialize layout changes per parent, including simultaneous delegate launches. +const groups = new WeakMap>(); +async function withPaneGroup(target: string, command: TmuxCommand, action: (group: PaneGroup) => Promise): Promise { + let parents = groups.get(command); + if (!parents) groups.set(command, parents = new Map()); + let group = parents.get(target); + if (!group) parents.set(target, group = { panes: [], pending: Promise.resolve() }); + const previous = group.pending; + let release!: () => void; + const pending = group.pending = new Promise((resolve) => { release = resolve; }); + await previous; + try { + return await action(group); + } finally { + release(); + if (group.pending === pending && group.panes.length === 0) parents.delete(target); + } +} + +async function balanceMonitors(target: string, command: TmuxCommand, group: PaneGroup): Promise { + if (group.panes.length < 2) return; + const live = (await command(["list-panes", "-t", target, "-F", "#{pane_id} #{pane_top} #{pane_height}"])) + .split("\n").map((line) => line.trim().split(/\s+/)); + const monitors = live.filter(([id]) => group.panes.includes(id)) + .map(([id, top, height]) => ({ id, top: Number(top), height: Number(height) })) + .sort((a, b) => a.top - b.top); + group.panes = monitors.map(({ id }) => id); + if (monitors.length < 2) return; + const rows = monitors.reduce((sum, pane) => sum + pane.height, 0); + const height = Math.floor(rows / monitors.length); + const remainder = rows % monitors.length; + // Resize only monitor panes, never select a window-wide layout. + for (let i = 0; i < monitors.length - 1; i++) { + await command(["resize-pane", "-t", monitors[i].id, "-y", String(height + (i < remainder ? 1 : 0))]); + } +} + +export interface DelegateMonitor { + update(details: DelegateDetails): void; + close(): Promise; +} + +// The pane is only a viewer. Closing it never interrupts the parent-owned child. +export async function openMonitor( + label: string, + details: DelegateDetails, + target = process.env.TMUX_PANE, + command: TmuxCommand = tmux, +): Promise { + if (!target || !/^%\d+$/.test(target)) throw new Error("Not inside a tmux pane."); + const directory = await mkdtemp(join(tmpdir(), "pi-delegate-monitor-")); + const path = join(directory, "state.json"); + let pane: string | undefined; + let closed = false; + let writable = true; + const update = (state: DelegateDetails) => { + if (closed || !writable) return; + try { + // Atomic, bounded snapshots: no growing logs and no partially read JSON. + writeFileSync(`${path}.tmp`, JSON.stringify({ + label: label.slice(0, 500), model: state.model, cwd: state.cwd, + status: state.status, elapsedMs: state.elapsedMs, cost: state.usage.cost.total, + activity: state.activity.slice(-20), output: state.output.slice(-16384), + error: state.error?.slice(-2000), + }), { mode: 0o600 }); + renameSync(`${path}.tmp`, path); + } catch { + // A display failure must never cancel a delegate or lose its result. + writable = false; + } + }; + try { + update(details); + const runtime = /^(node|bun)(\.exe)?$/i.test(basename(process.execPath)) ? process.execPath : "node"; + // Multiple shell-command arguments make tmux exec directly, without shell interpolation. + pane = await withPaneGroup(target, command, async (group) => { + let splitTarget = target; + if (group.panes.length) { + // Reconcile manually closed panes, then split the tallest surviving monitor. + const live = (await command(["list-panes", "-t", target, "-F", "#{pane_id} #{pane_height}"])) + .split("\n").map((line) => line.trim().split(/\s+/)); + group.panes = group.panes.filter((id) => live.some(([paneId]) => paneId === id)); + const monitors = live.filter(([id]) => group.panes.includes(id)) + .sort((a, b) => Number(b[1]) - Number(a[1])); + if (monitors.length) splitTarget = monitors[0][0]; + } + const id = (await command([ + "split-window", "-d", splitTarget === target ? "-h" : "-v", "-t", splitTarget, "-P", "-F", "#{pane_id}", + runtime, fileURLToPath(new URL("monitor-view.mjs", import.meta.url)), path, String(process.pid), + ])).trim(); + if (!/^%\d+$/.test(id)) throw new Error("tmux did not return a pane ID."); + group.panes.push(id); + try { await balanceMonitors(target, command, group); } catch { /* display-only, keep the child running */ } + return id; + }); + return { + update, + async close() { + if (closed) return; + closed = true; + await withPaneGroup(target, command, async (group) => { + try { await command(["kill-pane", "-t", pane!]); } catch { /* already closed */ } + group.panes = group.panes.filter((id) => id !== pane); + try { await balanceMonitors(target, command, group); } catch { /* display-only cleanup */ } + }); + try { await rm(directory, { recursive: true, force: true }); } catch { /* best-effort display cleanup */ } + }, + }; + } catch (error) { + try { await rm(directory, { recursive: true, force: true }); } catch { /* preserve launch error */ } + throw error; + } +} diff --git a/pi/.pi/agent/settings.json b/pi/.pi/agent/settings.json index 21502ea..ce82314 100644 --- a/pi/.pi/agent/settings.json +++ b/pi/.pi/agent/settings.json @@ -15,5 +15,8 @@ "outputPad": 0, "autocompleteMaxVisible": 3, "quietStartup": true, - "tuiMode": "regular" + "tuiMode": "regular", + "pi-delegate": { + "display": "tmux" + } }