little-coder 1.8.4 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+
7
+ // Up-arrow prompt history, persisted across sessions.
8
+ //
9
+ // pi's default editor exposes an `addToHistory` hook (which pi calls on every
10
+ // submit) but ships no actual navigation, so up-arrow does nothing. We provide a
11
+ // custom editor — a subclass of pi's own CustomEditor, so pi copies all the
12
+ // app keybindings, autocomplete, and submit wiring onto it (see
13
+ // interactive-mode.setCustomEditorComponent) — that implements `addToHistory`
14
+ // and recalls history on ↑ / ↓.
15
+ //
16
+ // Why a custom editor and not raw onTerminalInput key-matching: pi runs the
17
+ // Kitty keyboard protocol (flags=7), so arrows arrive as CSI-u sequences with
18
+ // press/repeat/release events. The editor path (a) only sees press/repeat —
19
+ // the TUI filters releases before handleInput — and (b) lets us detect ↑/↓ via
20
+ // keybindings.matches(), which already understands every encoding. Both are
21
+ // brittle to reproduce from raw bytes.
22
+ //
23
+ // History is stored in <agentDir>/little-coder-prompt-history.json so a brand
24
+ // new session (even one with no messages) can recall prompts from earlier runs.
25
+
26
+ const MAX = 100;
27
+
28
+ function agentDir(): string {
29
+ const env = process.env.PI_CODING_AGENT_DIR;
30
+ if (env && env.trim().length > 0) {
31
+ if (env === "~") return homedir();
32
+ if (env.startsWith("~/")) return homedir() + env.slice(1);
33
+ return env;
34
+ }
35
+ return join(homedir(), ".pi", "agent");
36
+ }
37
+
38
+ function historyFile(): string {
39
+ return join(agentDir(), "little-coder-prompt-history.json");
40
+ }
41
+
42
+ export function loadHistory(): string[] {
43
+ try {
44
+ const raw = JSON.parse(readFileSync(historyFile(), "utf-8"));
45
+ if (Array.isArray(raw)) return raw.filter((x) => typeof x === "string").slice(-MAX);
46
+ } catch {
47
+ // missing / unreadable / corrupt — start empty
48
+ }
49
+ return [];
50
+ }
51
+
52
+ function saveHistory(items: string[]): void {
53
+ try {
54
+ const dir = dirname(historyFile());
55
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
56
+ writeFileSync(historyFile(), JSON.stringify(items.slice(-MAX)));
57
+ } catch {
58
+ // best-effort; recall still works in-process this session
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Pure history store. `recall("up"|"down", currentText)` returns the text to
64
+ * place in the editor, or null to let the editor handle the key normally.
65
+ * Navigation only STARTS from an empty prompt (so it never clobbers a draft or
66
+ * fights multi-line cursor movement); once navigating, ↑/↓ walk the list.
67
+ */
68
+ export function makeHistory(initial: string[] = [], persist: (items: string[]) => void = () => {}) {
69
+ const items = initial.slice(-MAX);
70
+ let nav = -1;
71
+
72
+ return {
73
+ items,
74
+ add(text: string) {
75
+ const t = (text ?? "").trim();
76
+ if (!t) return;
77
+ if (items[items.length - 1] !== text) {
78
+ items.push(text);
79
+ while (items.length > MAX) items.shift();
80
+ persist(items);
81
+ }
82
+ nav = -1;
83
+ },
84
+ reset() {
85
+ nav = -1;
86
+ },
87
+ recall(dir: "up" | "down", current: string): string | null {
88
+ if (dir === "up") {
89
+ if (nav === -1) {
90
+ if (current !== "" || items.length === 0) return null; // only from empty
91
+ nav = items.length - 1;
92
+ } else if (nav > 0) {
93
+ nav -= 1;
94
+ }
95
+ return items[nav];
96
+ }
97
+ // down
98
+ if (nav === -1) return null;
99
+ if (nav < items.length - 1) {
100
+ nav += 1;
101
+ return items[nav];
102
+ }
103
+ nav = -1;
104
+ return ""; // past the newest → empty prompt
105
+ },
106
+ };
107
+ }
108
+
109
+ export default function (pi: ExtensionAPI) {
110
+ const store = makeHistory(loadHistory(), saveHistory);
111
+
112
+ // A CustomEditor that adds history recall. pi copies app keybindings,
113
+ // autocomplete, and submit/change wiring onto it after construction.
114
+ class HistoryEditor extends CustomEditor {
115
+ private kb: any;
116
+ private tuiRef: any;
117
+ constructor(tui: any, theme: any, keybindings: any, options?: any) {
118
+ super(tui, theme, keybindings, options);
119
+ this.kb = keybindings;
120
+ this.tuiRef = tui;
121
+ }
122
+
123
+ // pi calls this on every submit — our hook to record + persist the prompt.
124
+ addToHistory(text: string): void {
125
+ store.add(text);
126
+ }
127
+
128
+ handleInput(data: string): void {
129
+ const up = this.kb?.matches?.(data, "tui.editor.cursorUp");
130
+ const down = this.kb?.matches?.(data, "tui.editor.cursorDown");
131
+ // Don't hijack ↑/↓ while the autocomplete dropdown is open — it owns them.
132
+ const autocompleting = (this as any).isShowingAutocomplete?.() === true;
133
+ if ((up || down) && !autocompleting) {
134
+ const next = store.recall(up ? "up" : "down", this.getText());
135
+ if (next !== null) {
136
+ this.setText(next);
137
+ this.tuiRef?.requestRender?.();
138
+ return;
139
+ }
140
+ }
141
+ // Any non-navigation key ends the current recall walk.
142
+ if (!up && !down) store.reset();
143
+ super.handleInput(data);
144
+ }
145
+ }
146
+
147
+ pi.on("session_start", async (_event, ctx) => {
148
+ if (!ctx.hasUI || typeof (ctx.ui as any).setEditorComponent !== "function") return;
149
+ store.reset();
150
+ (ctx.ui as any).setEditorComponent(
151
+ (tui: any, theme: any, keybindings: any) => new HistoryEditor(tui, theme, keybindings),
152
+ );
153
+ });
154
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { makeHistory } from "./index.ts";
3
+
4
+ describe("prompt history recall", () => {
5
+ it("records prompts, skipping blanks and consecutive duplicates", () => {
6
+ const h = makeHistory();
7
+ h.add("first");
8
+ h.add(" "); // blank
9
+ h.add("second");
10
+ h.add("second"); // dup
11
+ expect(h.items).toEqual(["first", "second"]);
12
+ });
13
+
14
+ it("persists on add via the provided callback", () => {
15
+ const saved: string[][] = [];
16
+ const h = makeHistory([], (items) => saved.push([...items]));
17
+ h.add("one");
18
+ h.add("two");
19
+ expect(saved).toEqual([["one"], ["one", "two"]]);
20
+ });
21
+
22
+ it("seeds from initial (cross-session) history", () => {
23
+ const h = makeHistory(["older", "newer"]);
24
+ expect(h.recall("up", "")).toBe("newer");
25
+ expect(h.recall("up", "newer")).toBe("older");
26
+ });
27
+
28
+ it("up from an empty prompt recalls most-recent-first and clamps", () => {
29
+ const h = makeHistory(["a", "b", "c"]);
30
+ expect(h.recall("up", "")).toBe("c");
31
+ expect(h.recall("up", "c")).toBe("b");
32
+ expect(h.recall("up", "b")).toBe("a");
33
+ expect(h.recall("up", "a")).toBe("a"); // clamps at oldest
34
+ });
35
+
36
+ it("down walks forward and clears past the newest", () => {
37
+ const h = makeHistory(["a", "b"]);
38
+ h.recall("up", ""); // → b
39
+ h.recall("up", "b"); // → a
40
+ expect(h.recall("down", "a")).toBe("b");
41
+ expect(h.recall("down", "b")).toBe(""); // past newest → empty
42
+ });
43
+
44
+ it("does NOT hijack up when the prompt is non-empty (editor handles it)", () => {
45
+ const h = makeHistory(["a"]);
46
+ expect(h.recall("up", "draft text")).toBeNull();
47
+ });
48
+
49
+ it("does nothing on up when there is no history", () => {
50
+ const h = makeHistory([]);
51
+ expect(h.recall("up", "")).toBeNull();
52
+ });
53
+
54
+ it("down with no active navigation is a no-op (editor handles it)", () => {
55
+ const h = makeHistory(["a"]);
56
+ expect(h.recall("down", "")).toBeNull();
57
+ });
58
+
59
+ it("reset() ends navigation so the next up starts fresh", () => {
60
+ const h = makeHistory(["a", "b"]);
61
+ h.recall("up", ""); // → b (navigating)
62
+ h.reset();
63
+ expect(h.recall("up", "b")).toBeNull(); // non-empty + not navigating → editor handles
64
+ });
65
+
66
+ it("caps stored history at the maximum", () => {
67
+ const many = Array.from({ length: 150 }, (_, i) => `p${i}`);
68
+ const h = makeHistory(many);
69
+ expect(h.items.length).toBe(100);
70
+ expect(h.items[0]).toBe("p50"); // kept the newest 100
71
+ });
72
+ });
@@ -0,0 +1,89 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { normalizeWritePath } from "../write-guard/index.ts";
3
+ import { harnessIntervention } from "../_shared/intervention.ts";
4
+
5
+ // Read-before-edit guard.
6
+ //
7
+ // Small models routinely fire `edit` with an `oldText` they never actually saw
8
+ // — guessing at the current file contents — which either fails the exact-match
9
+ // requirement (wasting a turn) or, worse, matches the wrong span. Editors the
10
+ // user is used to (Claude Code et al.) enforce a simple invariant: a file must
11
+ // be Read before it can be Edited. We reproduce that here.
12
+ //
13
+ // Mechanism mirrors write-guard: we don't own pi's built-in `read`/`edit`
14
+ // tools, so we enforce at the event layer. We remember every file that was
15
+ // successfully `read` this session (`tool_result`, !isError), and block any
16
+ // `edit` whose target hasn't been read, redirecting the model to Read first.
17
+ //
18
+ // Why a separate extension from `read-guard`: read-guard trims an oversized
19
+ // read so it can't overflow a small context window — a different concern from
20
+ // the read-before-edit invariant. Keeping them apart keeps each single-purpose.
21
+ //
22
+ // A successful `edit` or `write` also marks the path as known: an edit only
23
+ // succeeds when the file was already read (we'd have blocked it otherwise), and
24
+ // a write means the model authored the file's contents, so a follow-up edit to
25
+ // either is legitimate without a re-read.
26
+
27
+ // Files read (or authored) in the current session. Module-scoped: one pi
28
+ // process drives one session at a time, and we clear on session_start.
29
+ export const readFiles = new Set<string>();
30
+
31
+ // pi's built-in tools use `path`; some prompts/older builds use `file_path`.
32
+ // Accept both so the guard is independent of which key the model emits.
33
+ export function resolveToolPath(
34
+ input: Record<string, unknown>,
35
+ cwd: string,
36
+ ): string | undefined {
37
+ const raw =
38
+ typeof input.path === "string"
39
+ ? input.path
40
+ : typeof input.file_path === "string"
41
+ ? input.file_path
42
+ : undefined;
43
+ if (!raw) return undefined;
44
+ return normalizeWritePath(raw, cwd).path;
45
+ }
46
+
47
+ export function editBeforeReadReason(resolved: string): string {
48
+ return (
49
+ `File must be read first before edit — ${resolved} has not been read in ` +
50
+ `this session.\n` +
51
+ `\n` +
52
+ `Read ${resolved} first to get the exact current text for oldText ` +
53
+ `(whitespace and indentation must match exactly), then issue the Edit. ` +
54
+ `Reading also lets you include enough surrounding context (2-3 lines) to ` +
55
+ `make oldText unique in the file. Do NOT guess the file's contents.`
56
+ );
57
+ }
58
+
59
+ export default function (pi: ExtensionAPI) {
60
+ // New session (startup, /clear, /resume, reload) is a clean slate — what was
61
+ // read in a previous session says nothing about the current one.
62
+ pi.on("session_start", async () => {
63
+ readFiles.clear();
64
+ });
65
+
66
+ // Record successful reads (and authored files) as "known".
67
+ pi.on("tool_result", async (event, ctx) => {
68
+ const name = String((event as any).toolName ?? "").toLowerCase();
69
+ if (name !== "read" && name !== "edit" && name !== "write") return;
70
+ if ((event as any).isError) return;
71
+ const p = resolveToolPath(((event as any).input ?? {}) as Record<string, unknown>, ctx.cwd);
72
+ if (p) readFiles.add(p);
73
+ });
74
+
75
+ // Block edits to files that were never read.
76
+ pi.on("tool_call", async (event, ctx) => {
77
+ if (String((event as any).toolName ?? "").toLowerCase() !== "edit") return;
78
+ const input = ((event as any).input ?? {}) as Record<string, unknown>;
79
+ const p = resolveToolPath(input, ctx.cwd);
80
+ if (!p) return; // no resolvable path — let the edit tool surface its own error
81
+ if (readFiles.has(p)) return; // already read this session — allow
82
+
83
+ harnessIntervention(
84
+ ctx,
85
+ "the model tried to edit a file it hadn't read — redirected it to Read first.",
86
+ );
87
+ return { block: true, reason: editBeforeReadReason(p) };
88
+ });
89
+ }
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import setupReadGuardEdit, { readFiles, resolveToolPath } from "./index.ts";
3
+
4
+ describe("resolveToolPath", () => {
5
+ const cwd = "/home/me/proj";
6
+ it("resolves relative paths against cwd", () => {
7
+ expect(resolveToolPath({ path: "src/a.ts" }, cwd)).toBe("/home/me/proj/src/a.ts");
8
+ });
9
+ it("honors the file_path key", () => {
10
+ expect(resolveToolPath({ file_path: "b.ts" }, cwd)).toBe("/home/me/proj/b.ts");
11
+ });
12
+ it("returns undefined with no path", () => {
13
+ expect(resolveToolPath({ content: "x" }, cwd)).toBeUndefined();
14
+ });
15
+ });
16
+
17
+ // Wire up the extension against a fake pi, capturing each registered handler.
18
+ function setup() {
19
+ const handlers: Record<string, (...args: any[]) => any> = {};
20
+ const pi = {
21
+ on(name: string, h: (...args: any[]) => any) {
22
+ handlers[name] = h;
23
+ },
24
+ };
25
+ setupReadGuardEdit(pi as any);
26
+ return handlers;
27
+ }
28
+
29
+ function makeCtx(cwd: string) {
30
+ const notifies: string[] = [];
31
+ return { cwd, notifies, ui: { notify: (m: string) => notifies.push(m) } };
32
+ }
33
+
34
+ describe("read-before-edit guard", () => {
35
+ const cwd = "/home/me/proj";
36
+ beforeEach(() => readFiles.clear());
37
+
38
+ it("blocks an edit to a file that was never read", async () => {
39
+ const h = setup();
40
+ const ctx = makeCtx(cwd);
41
+ const result = await h.tool_call({ toolName: "edit", input: { path: "a.ts", edits: [] } }, ctx);
42
+ expect(result?.block).toBe(true);
43
+ expect(result.reason).toContain("must be read first");
44
+ expect(result.reason).toContain("/home/me/proj/a.ts");
45
+ expect(ctx.notifies[0]).toMatch(/harness intervention:.*Read first/i);
46
+ });
47
+
48
+ it("allows an edit after the file was successfully read", async () => {
49
+ const h = setup();
50
+ const ctx = makeCtx(cwd);
51
+ await h.tool_result({ toolName: "read", isError: false, input: { path: "a.ts" } }, ctx);
52
+ const result = await h.tool_call({ toolName: "edit", input: { path: "a.ts", edits: [] } }, ctx);
53
+ expect(result).toBeUndefined();
54
+ expect(ctx.notifies).toHaveLength(0);
55
+ });
56
+
57
+ it("does not count a failed read as having read the file", async () => {
58
+ const h = setup();
59
+ const ctx = makeCtx(cwd);
60
+ await h.tool_result({ toolName: "read", isError: true, input: { path: "a.ts" } }, ctx);
61
+ const result = await h.tool_call({ toolName: "edit", input: { path: "a.ts" } }, ctx);
62
+ expect(result?.block).toBe(true);
63
+ });
64
+
65
+ it("treats a freshly written file as known (write then edit is allowed)", async () => {
66
+ const h = setup();
67
+ const ctx = makeCtx(cwd);
68
+ await h.tool_result({ toolName: "write", isError: false, input: { path: "new.ts" } }, ctx);
69
+ const result = await h.tool_call({ toolName: "edit", input: { path: "new.ts" } }, ctx);
70
+ expect(result).toBeUndefined();
71
+ });
72
+
73
+ it("clears known files on session_start", async () => {
74
+ const h = setup();
75
+ const ctx = makeCtx(cwd);
76
+ await h.tool_result({ toolName: "read", isError: false, input: { path: "a.ts" } }, ctx);
77
+ await h.session_start();
78
+ const result = await h.tool_call({ toolName: "edit", input: { path: "a.ts" } }, ctx);
79
+ expect(result?.block).toBe(true);
80
+ });
81
+
82
+ it("matches reads and edits that use different path spellings for the same file", async () => {
83
+ const h = setup();
84
+ const ctx = makeCtx(cwd);
85
+ // read with cwd-relative, edit with the absolute form of the same file
86
+ await h.tool_result({ toolName: "read", isError: false, input: { path: "src/a.ts" } }, ctx);
87
+ const result = await h.tool_call(
88
+ { toolName: "edit", input: { path: "/home/me/proj/src/a.ts" } },
89
+ ctx,
90
+ );
91
+ expect(result).toBeUndefined();
92
+ });
93
+
94
+ it("ignores non-edit tool calls", async () => {
95
+ const h = setup();
96
+ const ctx = makeCtx(cwd);
97
+ const result = await h.tool_call({ toolName: "grep", input: { pattern: "x" } }, ctx);
98
+ expect(result).toBeUndefined();
99
+ });
100
+ });
@@ -56,6 +56,9 @@ const INTENT_MAP: Record<string, string[]> = {
56
56
  browse: ["BrowserNavigate", "BrowserExtract"],
57
57
  page: ["BrowserExtract"],
58
58
  click: ["BrowserClick"],
59
+ // Sub-coder delegation
60
+ delegate: ["dispatch"], dispatch: ["dispatch"], subagent: ["dispatch"],
61
+ investigate: ["dispatch"], parallel: ["dispatch"],
59
62
  };
60
63
 
61
64
  function skillsDir(): string {
@@ -60,10 +60,10 @@ describe("skills directory loads from repo", () => {
60
60
  const here = dirname(fileURLToPath(import.meta.url));
61
61
  const toolsDir = join(here, "..", "..", "..", "skills", "tools");
62
62
 
63
- it("exists and has 13 markdown files", () => {
63
+ it("exists and has 14 markdown files", () => {
64
64
  expect(existsSync(toolsDir)).toBe(true);
65
65
  const files = readdirSync(toolsDir).filter((f) => f.endsWith(".md"));
66
- expect(files.length).toBe(13);
66
+ expect(files.length).toBe(14);
67
67
  });
68
68
 
69
69
  it("every tool skill has target_tool in frontmatter", () => {
@@ -0,0 +1,201 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import {
4
+ runSubCoder,
5
+ runSubCodersConcurrent,
6
+ truncateReport,
7
+ type SubCoderItem,
8
+ type SubCoderResult,
9
+ } from "./spawn.ts";
10
+ import { SubCoderTracker } from "./tracker.ts";
11
+
12
+ // The `dispatch` tool: the main little-coder spawns isolated child little-coder
13
+ // sessions ("sub-coders") to research a focused question — they read the repo
14
+ // and browse online, then return a CONCISE report. The full child transcript
15
+ // lives in the tool's `details` (UI-only); only the short report enters the
16
+ // parent model's context. A live panel above the input tracks them while they
17
+ // run. See spawn.ts for the engine and the read-only constraints.
18
+
19
+ const MAX_PARALLEL = 4;
20
+
21
+ /** "provider/id" of the current model, so children run on the same backend. */
22
+ export function currentModelId(ctx: any): string | undefined {
23
+ const m = ctx?.model;
24
+ if (!m || typeof m.id !== "string") return undefined;
25
+ return m.provider ? `${m.provider}/${m.id}` : m.id;
26
+ }
27
+
28
+ /** A short label for a single-mode task (first few words). */
29
+ function shortLabel(task: string): string {
30
+ const words = task.trim().split(/\s+/).filter(Boolean).slice(0, 4).join(" ");
31
+ return words.length > 28 ? `${words.slice(0, 27)}…` : words || "sub-coder";
32
+ }
33
+
34
+ function buildContent(results: SubCoderResult[]): string {
35
+ if (results.length === 1) {
36
+ const r = results[0];
37
+ if (r.exitCode !== 0) return `Sub-coder "${r.label}" ${r.stopReason || "failed"}: ${r.errorMessage || "(no output)"}`;
38
+ return truncateReport(r.report) || "(no report)";
39
+ }
40
+ const ok = results.filter((r) => r.exitCode === 0).length;
41
+ const blocks = results.map((r) => {
42
+ const status = r.exitCode === 0 ? "" : ` [${r.stopReason || "failed"}]`;
43
+ const body = r.exitCode === 0 ? truncateReport(r.report) : r.errorMessage || "(no output)";
44
+ return `### ${r.label}${status}\n${body || "(no report)"}`;
45
+ });
46
+ return `${ok}/${results.length} sub-coders succeeded.\n\n${blocks.join("\n\n")}`;
47
+ }
48
+
49
+ export default function (pi: ExtensionAPI) {
50
+ pi.registerTool({
51
+ name: "dispatch",
52
+ label: "Dispatch sub-coder",
53
+ description: [
54
+ "Dispatch one or more isolated sub-coders to research a focused question.",
55
+ "Each runs in its own context window, can read the repo and browse online",
56
+ "(read, grep, glob, webfetch, websearch, browser, read-only bash) but CANNOT",
57
+ "edit or write files. Each returns a concise report. Use this to gather",
58
+ "information without cluttering your own context.",
59
+ "Single: { task }. Parallel: { tasks: [{ label, task }] } (max 4).",
60
+ ].join(" "),
61
+ parameters: Type.Object({
62
+ task: Type.Optional(Type.String({ description: "A single research task (single mode)" })),
63
+ tasks: Type.Optional(
64
+ Type.Array(
65
+ Type.Object({
66
+ label: Type.String({ description: "Short name for this sub-coder (shown in the tracker)" }),
67
+ task: Type.String({ description: "The research task delegated to this sub-coder" }),
68
+ }),
69
+ { description: `Up to ${MAX_PARALLEL} tasks to run in parallel` },
70
+ ),
71
+ ),
72
+ cwd: Type.Optional(Type.String({ description: "Working directory for the sub-coders (default: current)" })),
73
+ }),
74
+
75
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
76
+ const hasSingle = typeof params.task === "string" && params.task.trim().length > 0;
77
+ const hasTasks = Array.isArray(params.tasks) && params.tasks.length > 0;
78
+ if (hasSingle === hasTasks) {
79
+ return {
80
+ content: [{ type: "text", text: "Provide exactly one of `task` (single) or `tasks` (parallel)." }],
81
+ details: { results: [] },
82
+ isError: true,
83
+ };
84
+ }
85
+ if (hasTasks && params.tasks!.length > MAX_PARALLEL) {
86
+ return {
87
+ content: [{ type: "text", text: `Too many parallel tasks (${params.tasks!.length}). Max is ${MAX_PARALLEL}.` }],
88
+ details: { results: [] },
89
+ isError: true,
90
+ };
91
+ }
92
+
93
+ const cwd = params.cwd || ctx.cwd;
94
+ const items: SubCoderItem[] = hasSingle
95
+ ? [{ id: "1", label: shortLabel(params.task!), task: params.task!, cwd }]
96
+ : params.tasks!.map((t, i) => ({ id: String(i + 1), label: t.label, task: t.task, cwd }));
97
+
98
+ const model = currentModelId(ctx);
99
+ const tracker = new SubCoderTracker(ctx as any);
100
+ tracker.begin(items.map((it) => ({ id: it.id, label: it.label })));
101
+
102
+ const streamToToolCard = (results: SubCoderResult[]) => {
103
+ if (!onUpdate) return;
104
+ const done = results.filter((r) => r.exitCode !== -1).length;
105
+ onUpdate({
106
+ content: [{ type: "text", text: `${done}/${results.length} sub-coders done…` }],
107
+ details: { results },
108
+ });
109
+ };
110
+
111
+ let results: SubCoderResult[];
112
+ try {
113
+ if (hasSingle) {
114
+ const r = await runSubCoder({
115
+ ...items[0],
116
+ model,
117
+ signal,
118
+ onUpdate: (live) => {
119
+ tracker.update([live]);
120
+ streamToToolCard([live]);
121
+ },
122
+ });
123
+ results = [r];
124
+ } else {
125
+ results = await runSubCodersConcurrent(items, {
126
+ model,
127
+ signal,
128
+ onUpdate: (all) => {
129
+ tracker.update(all);
130
+ streamToToolCard(all);
131
+ },
132
+ });
133
+ }
134
+ } finally {
135
+ tracker.end();
136
+ }
137
+
138
+ const anyError = results.some((r) => r.exitCode !== 0);
139
+ const allError = results.every((r) => r.exitCode !== 0);
140
+ return {
141
+ content: [{ type: "text", text: buildContent(results) }],
142
+ details: { results },
143
+ isError: allError && anyError,
144
+ };
145
+ },
146
+
147
+ // Renderers return a duck-typed Component ({ render→lines, invalidate }).
148
+ // pi 0.79 stopped hoisting pi-tui, so we build colored lines via `theme`
149
+ // rather than importing pi-tui primitives (same approach as branding).
150
+ renderCall(args: any, theme: any) {
151
+ const lines: string[] = [];
152
+ const title = theme.fg("toolTitle", theme.bold("dispatch "));
153
+ if (Array.isArray(args.tasks) && args.tasks.length > 0) {
154
+ lines.push(title + theme.fg("accent", `${args.tasks.length} sub-coders`));
155
+ for (const t of args.tasks.slice(0, MAX_PARALLEL)) {
156
+ const preview = t.task.length > 48 ? `${t.task.slice(0, 48)}…` : t.task;
157
+ lines.push(` ${theme.fg("accent", t.label)}${theme.fg("dim", ` — ${preview}`)}`);
158
+ }
159
+ } else {
160
+ const task = String(args.task ?? "…");
161
+ lines.push(title + theme.fg("dim", task.length > 64 ? `${task.slice(0, 64)}…` : task));
162
+ }
163
+ return makeComponent(lines);
164
+ },
165
+
166
+ renderResult(result: any, options: any, theme: any) {
167
+ const expanded = !!options?.expanded;
168
+ const results: SubCoderResult[] = result?.details?.results ?? [];
169
+ if (results.length === 0) {
170
+ const t = result?.content?.[0];
171
+ return makeComponent([t?.type === "text" ? t.text : "(no output)"]);
172
+ }
173
+ const ok = results.filter((r) => r.exitCode === 0).length;
174
+ const lines: string[] = [
175
+ theme.fg("toolTitle", theme.bold("dispatch ")) + theme.fg("accent", `${ok}/${results.length} sub-coders`),
176
+ ];
177
+ for (const r of results) {
178
+ const icon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
179
+ lines.push("");
180
+ lines.push(`${icon} ${theme.fg("accent", r.label)}`);
181
+ const body = r.exitCode === 0 ? r.report : r.errorMessage || r.stderr || "(no output)";
182
+ const bodyLines = body.trim() ? body.trim().split("\n") : ["(no output)"];
183
+ const shown = expanded ? bodyLines : bodyLines.slice(0, 4);
184
+ for (const bl of shown) lines.push(theme.fg("toolOutput", bl));
185
+ if (!expanded && bodyLines.length > shown.length) lines.push(theme.fg("muted", " …"));
186
+ }
187
+ if (!expanded) lines.push(theme.fg("muted", "(Ctrl+O to expand)"));
188
+ return makeComponent(lines);
189
+ },
190
+ });
191
+ }
192
+
193
+ /** A minimal pi-tui Component backed by precomputed lines. */
194
+ function makeComponent(lines: string[]) {
195
+ return {
196
+ render(_width: number): string[] {
197
+ return lines;
198
+ },
199
+ invalidate() {},
200
+ };
201
+ }