pi-jar 0.1.2 → 0.1.4

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.
package/src/delegate.ts CHANGED
@@ -1,25 +1,57 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { basename } from "node:path";
4
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, isAbsolute, join, resolve } from "node:path";
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
6
  import { Text } from "@earendil-works/pi-tui";
6
7
  import { Type } from "typebox";
8
+ import type { ChangeTracker } from "./changes.ts";
9
+ import { CHILD_BASELINE_ENV, readChildBaseline } from "./child-baselines.ts";
7
10
  import type { ModelRoleManager } from "./model-roles.ts";
8
- import { ROLE_PREFIX } from "./status.ts";
9
- import { cleanText } from "./status.ts";
11
+ import { cleanText, ROLE_PREFIX } from "./status.ts";
12
+ import type { Todo } from "./tasks.ts";
10
13
 
11
14
  export const DELEGATE_TOOL = "jar_delegate";
12
15
  /** Set in child processes so a subagent never delegates again. */
13
16
  export const CHILD_ENV = "PI_JAR_CHILD";
14
17
  export const MAX_DELEGATES = 4;
15
- export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const;
18
+ /** Read-only subagents also keep jar_todo so their checklist shows in the activity view. */
19
+ export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "jar_todo"] as const;
16
20
  const MAX_OUTPUT_CHARS = 12_000;
17
21
  const TIMEOUT_MS = 20 * 60_000;
18
22
  const STATUS_REFRESH_MS = 20_000;
19
23
  const LIVE_UPDATE_MS = 1000;
20
24
  const MAX_EVENT_TEXT = 16_000;
25
+ /** Transcript entries kept per run; tool output and text are clipped so a run stays well under 2 MiB. */
26
+ const MAX_ENTRIES = 200;
27
+ const MAX_ENTRY_TEXT = 4000;
28
+ const MAX_ARGS_CHARS = 2000;
29
+ /** Tail of the assistant text still streaming, shown so a long answer never looks stuck. */
30
+ const MAX_LIVE_CHARS = 1200;
31
+ const LIVE_REPAINT_MS = 50;
32
+ /** One RPC record larger than this (a huge tool result) is skipped instead of buffered. */
33
+ const MAX_LINE_CHARS = 16 * 1024 * 1024;
34
+ const MAX_FILES = 50;
35
+ const MAX_TODOS = 50;
36
+ const MAX_STEER_CHARS = 4000;
37
+ /** Finished subagents the registry keeps for the activity view after their tool call returns. */
38
+ const MAX_FINISHED = 8;
39
+ /** Abort reason for stopping one subagent from the activity view (vs. aborting the whole tool call). */
40
+ const STOP_REASON = "stopped";
41
+ /** Tool arguments that best describe a call in the transcript, most telling first. */
42
+ const HINT_KEYS = ["command", "path", "file_path", "pattern", "query", "url"] as const;
43
+ /** RPC dialogs a subagent cannot show: answered as cancelled so the child never blocks on them. */
44
+ const DIALOGS = new Set(["select", "confirm", "input", "editor"]);
45
+ const ANSI = /\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g;
21
46
 
22
47
  export type DelegateState = "queued" | "working" | "done" | "failed";
48
+ export type ToolStatus = "running" | "done" | "error";
49
+ /** One transcript row; `rev` changes whenever the entry does, so views can cache their rendering. */
50
+ export type TranscriptEntry =
51
+ | { kind: "tool"; rev: number; id: string; name: string; hint: string; args: string; status: ToolStatus; output: string; startedAt: number; endedAt?: number }
52
+ | { kind: "text" | "steer" | "note"; rev: number; text: string };
53
+ export type ToolEntry = Extract<TranscriptEntry, { kind: "tool" }>;
54
+
23
55
  export interface DelegateRun {
24
56
  index: number;
25
57
  name: string;
@@ -33,6 +65,19 @@ export interface DelegateRun {
33
65
  cost: number;
34
66
  output: string;
35
67
  error?: string;
68
+ startedAt?: number;
69
+ endedAt?: number;
70
+ /** Tool calls by name, for the report. */
71
+ toolCounts: Record<string, number>;
72
+ filesRead: string[];
73
+ filesEdited: string[];
74
+ /** The subagent's own jar_todo checklist, mirrored from its tool results. */
75
+ todos: Todo[];
76
+ steered: number;
77
+ /** Live transcript (tool calls with output, assistant text, steering); never copied into tool details. */
78
+ transcript: TranscriptEntry[];
79
+ /** Assistant text still streaming; never copied into tool details. */
80
+ live: string;
36
81
  }
37
82
 
38
83
  type Spawn = (command: string, args: string[], options: Parameters<typeof spawn>[2]) => ChildProcess;
@@ -45,19 +90,52 @@ export function piInvocation(args: string[], argv = process.argv, execPath = pro
45
90
  return { command: "pi", args };
46
91
  }
47
92
 
48
- /** CLI arguments for one subagent: JSON events, one-shot, no session, read-only tools unless `write`. */
49
- export function delegateArgs(task: string, model: string | undefined, thinking: string | undefined, write: boolean): string[] {
50
- const args = ["--mode", "json", "-p", "--no-session"];
93
+ /**
94
+ * The parent's extension flags (`-e`, `--extension`, `-ne`), so a subagent loads the same extensions.
95
+ * In particular pi-jar itself, which records edit baselines for the parent's /diff and runs jar_todo.
96
+ */
97
+ export function extensionFlags(argv = process.argv, cwd = process.cwd()): string[] {
98
+ const flags: string[] = [];
99
+ for (let index = 2; index < argv.length; index++) {
100
+ const arg = argv[index]!;
101
+ if (arg === "-ne" || arg === "--no-extensions") flags.push(arg);
102
+ else if ((arg === "-e" || arg === "--extension") && argv[index + 1]) flags.push("--extension", resolve(cwd, argv[++index]!));
103
+ else if (arg.startsWith("--extension=")) flags.push("--extension", resolve(cwd, arg.slice(12)));
104
+ }
105
+ return flags;
106
+ }
107
+
108
+ /** CLI arguments for one subagent: RPC (so it can be steered), no session, read-only tools unless `write`. */
109
+ export function delegateArgs(model: string | undefined, thinking: string | undefined, write: boolean, argv = process.argv): string[] {
110
+ const args = ["--mode", "rpc", "--no-session", ...extensionFlags(argv)];
51
111
  if (model) args.push("--model", model);
52
112
  if (thinking) args.push("--thinking", thinking);
53
113
  if (!write) args.push("--tools", READ_ONLY_TOOLS.join(","));
114
+ return args;
115
+ }
116
+
117
+ /** The subagent's prompt: its rules, how to report back, then the task. */
118
+ export function delegatePrompt(task: string, write: boolean): string {
54
119
  const rules = write
55
120
  ? "You may edit files. Stay strictly within the task; other subagents may be working in the same repository at the same time."
56
121
  : "You are read-only: investigate and report, do not attempt to modify anything.";
57
- args.push(`You are a focused subagent working for another agent. ${rules} Finish with a concise, self-contained report of your findings or changes (include file paths).\n\nTask: ${task}`);
58
- return args;
122
+ return [
123
+ `You are a focused subagent working for another agent. ${rules}`,
124
+ "Track multi-step work with jar_todo (when available) so your progress is visible. The user may steer you while you work; follow their messages.",
125
+ "Finish with a self-contained report in these sections:",
126
+ "## Summary — the outcome in one to three sentences.",
127
+ "## Details — findings or changes, with file paths (and line numbers where useful).",
128
+ "## Verification — what you checked or ran, and the results.",
129
+ "## Open issues — risks, unknowns and follow-ups, or \"none\".",
130
+ "",
131
+ `Task: ${task}`
132
+ ].join("\n");
59
133
  }
60
134
 
135
+ /** Text without terminal controls, keeping line breaks; clipped to `limit`. */
136
+ const cleanBlock = (value: string, limit: number): string => value.slice(0, limit * 2)
137
+ .replace(ANSI, "").replace(/\r\n?/g, "\n").replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, " ").replace(/\n{3,}/g, "\n\n").trim().slice(0, limit);
138
+
61
139
  const textOf = (message: { content?: unknown }, limit = MAX_EVENT_TEXT): string => {
62
140
  if (typeof message.content === "string") return message.content.slice(0, limit);
63
141
  if (!Array.isArray(message.content)) return "";
@@ -73,68 +151,385 @@ const textOf = (message: { content?: unknown }, limit = MAX_EVENT_TEXT): string
73
151
  return out;
74
152
  };
75
153
 
76
- /** Run one child Pi, feeding progress into `run` and calling `update` on every change. */
77
- export function runDelegate(run: DelegateRun, args: string[], cwd: string, signal: AbortSignal | undefined, update: () => void, spawnProcess: Spawn = spawn as Spawn): Promise<void> {
78
- return new Promise((resolve) => {
154
+ /** The argument that says what a tool call does: the bash command, the file path, the search pattern… */
155
+ const toolHint = (args: unknown): string => {
156
+ if (!args || typeof args !== "object") return "";
157
+ for (const key of HINT_KEYS) {
158
+ const value = (args as Record<string, unknown>)[key];
159
+ if (typeof value === "string" && value.trim()) return value;
160
+ }
161
+ return "";
162
+ };
163
+ const toolPath = (args: unknown): string | undefined => {
164
+ if (!args || typeof args !== "object") return undefined;
165
+ const value = (args as Record<string, unknown>).path ?? (args as Record<string, unknown>).file_path;
166
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
167
+ };
168
+ const argsText = (args: unknown): string => {
169
+ if (args === undefined) return "";
170
+ try { return cleanBlock(JSON.stringify(args, null, 1) ?? "", MAX_ARGS_CHARS); } catch { return ""; }
171
+ };
172
+
173
+ const pushEntry = (run: DelegateRun, entry: TranscriptEntry) => {
174
+ run.transcript.push(entry);
175
+ if (run.transcript.length > MAX_ENTRIES) run.transcript.splice(0, run.transcript.length - MAX_ENTRIES);
176
+ };
177
+ const addFile = (list: string[], path: string) => {
178
+ const clean = cleanText(path, 300);
179
+ if (clean && !list.includes(clean) && list.length < MAX_FILES) list.push(clean);
180
+ };
181
+
182
+ /** A subagent's jar_todo items from a tool result, or undefined when the result is not a checklist. */
183
+ export function childTodos(details: unknown): Todo[] | undefined {
184
+ const items = details && typeof details === "object" ? (details as { items?: unknown }).items : undefined;
185
+ if (!Array.isArray(items)) return undefined;
186
+ const todos: Todo[] = [];
187
+ for (const raw of items.slice(0, MAX_TODOS)) {
188
+ if (!raw || typeof raw !== "object") continue;
189
+ const item = raw as Record<string, unknown>;
190
+ const status = item.status;
191
+ if (typeof item.id !== "string" || typeof item.title !== "string" || (status !== "pending" && status !== "in_progress" && status !== "completed")) continue;
192
+ todos.push({
193
+ id: cleanText(item.id, 64), title: cleanText(item.title, 120), status, done: status === "completed",
194
+ ...(typeof item.activeForm === "string" && item.activeForm.trim() ? { activeForm: cleanText(item.activeForm, 80) } : {}),
195
+ ...(typeof item.parentId === "string" && item.parentId ? { parentId: cleanText(item.parentId, 64) } : {})
196
+ });
197
+ }
198
+ return todos;
199
+ }
200
+
201
+ /** Tasks that count toward completion: subtasks, and top-level tasks without subtasks. */
202
+ export const leafTodos = (todos: readonly Todo[]): Todo[] => {
203
+ const parents = new Set<string>();
204
+ for (const todo of todos) if (todo.parentId) parents.add(todo.parentId);
205
+ return todos.filter((todo) => !parents.has(todo.id));
206
+ };
207
+
208
+ /** The slice of Pi's RPC stream a run reads; leaves are unknown because it is another process's output. */
209
+ interface ChildEvent {
210
+ type?: unknown;
211
+ id?: unknown;
212
+ command?: unknown;
213
+ success?: unknown;
214
+ error?: unknown;
215
+ method?: unknown;
216
+ toolCallId?: unknown;
217
+ toolName?: unknown;
218
+ args?: unknown;
219
+ isError?: unknown;
220
+ result?: { content?: unknown; details?: unknown };
221
+ partialResult?: { content?: unknown };
222
+ assistantMessageEvent?: { type?: unknown; delta?: unknown };
223
+ message?: { role?: unknown; content?: unknown; usage?: { cost?: { total?: unknown } }; stopReason?: unknown; errorMessage?: unknown };
224
+ }
225
+
226
+ export interface DelegateHooks {
227
+ /** Extra environment for the child (the baseline directory for /diff). */
228
+ env?: Record<string, string>;
229
+ /** A successful edit/write by the child, with the path it gave. */
230
+ edited?(path: string): void;
231
+ }
232
+ export interface DelegateHandle { done: Promise<void>; steer(text: string): boolean }
233
+
234
+ /**
235
+ * Run one child Pi in RPC mode, feeding progress into `run` and calling `update` on every change.
236
+ * RPC keeps stdin open so the user can steer the subagent; it is closed once the child settles.
237
+ */
238
+ export function startDelegate(run: DelegateRun, args: string[], prompt: string, cwd: string, signal: AbortSignal | undefined,
239
+ update: () => void, spawnProcess: Spawn = spawn as Spawn, hooks: DelegateHooks = {}): DelegateHandle {
240
+ let steer: (text: string) => boolean = () => false;
241
+ const done = new Promise<void>((resolveDone) => {
79
242
  const invocation = piInvocation(args);
80
243
  let child: ChildProcess;
81
244
  try {
82
- child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, [CHILD_ENV]: "1" } });
245
+ child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...hooks.env, [CHILD_ENV]: "1" } });
83
246
  } catch (error) {
84
- run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); update(); resolve(); return;
247
+ run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); run.endedAt = Date.now(); update(); resolveDone(); return;
85
248
  }
86
249
  run.state = "working";
250
+ run.startedAt = Date.now();
87
251
  run.activity = "starting";
88
252
  update();
89
253
  let buffer = "";
254
+ // A JSON line past this is dropped whole rather than buffered without bound.
255
+ let overflow = false;
256
+ // Streamed text repaints at most every LIVE_REPAINT_MS; the trailing timer shows the last tokens.
257
+ let liveAt = 0;
258
+ let liveTimer: ReturnType<typeof setTimeout> | undefined;
90
259
  let stderr = "";
91
260
  let finished = false;
261
+ let settled = false;
262
+ const send = (command: object): boolean => {
263
+ const stdin = child.stdin;
264
+ if (!stdin || stdin.destroyed || stdin.writableEnded) return false;
265
+ try { stdin.write(JSON.stringify(command) + "\n"); return true; } catch { return false; }
266
+ };
267
+ // EPIPE after the child exits is expected; anything else is kept for the failure message.
268
+ child.stdin?.on("error", (error) => { if (!finished) stderr = (stderr + "\n" + error.message).slice(-4000); });
92
269
  const finish = (state: DelegateState, error?: string) => {
93
270
  if (finished) return;
94
271
  finished = true;
95
272
  clearTimeout(timer);
273
+ clearTimeout(liveTimer);
96
274
  signal?.removeEventListener("abort", abort);
275
+ if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
97
276
  run.state = state;
98
277
  if (error) run.error = error;
99
278
  run.activity = undefined;
279
+ run.live = "";
280
+ for (const entry of run.transcript) if (entry.kind === "tool" && entry.status === "running") { entry.status = "error"; entry.endedAt = Date.now(); entry.rev++; }
281
+ run.endedAt = Date.now();
100
282
  update();
101
- resolve();
283
+ resolveDone();
102
284
  };
103
285
  const stop = () => { child.kill("SIGTERM"); setTimeout(() => { if (child.exitCode === null) child.kill("SIGKILL"); }, 3000).unref?.(); };
104
- const abort = () => { stop(); finish("failed", "aborted"); };
286
+ // Stopping one run from the activity view reads "stopped"; aborting the whole tool call reads "aborted".
287
+ const abort = () => { stop(); finish("failed", signal?.reason === STOP_REASON ? "stopped" : "aborted"); };
105
288
  const timer = setTimeout(() => { stop(); finish("failed", "timed out"); }, TIMEOUT_MS);
106
289
  timer.unref?.();
107
290
  if (signal?.aborted) { abort(); return; }
108
291
  signal?.addEventListener("abort", abort, { once: true });
292
+ const running = new Map<string, { entry: ToolEntry; path?: string }>();
109
293
  const line = (raw: string) => {
110
- if (!raw.trim()) return;
111
- let event: any;
112
- try { event = JSON.parse(raw); } catch { return; }
113
- if (event.type === "tool_execution_start") { run.tools++; run.activity = cleanText(String(event.toolName ?? "tool"), 24); update(); }
114
- else if (event.type === "message_end" && event.message?.role === "assistant") {
115
- run.turns++;
116
- run.cost += Number(event.message.usage?.cost?.total) || 0;
117
- const text = textOf(event.message).trim();
118
- if (text) run.output = text.slice(0, MAX_OUTPUT_CHARS);
119
- if (event.message.stopReason === "error" && event.message.errorMessage) run.error = cleanText(String(event.message.errorMessage), 200);
120
- run.activity = "thinking";
121
- update();
294
+ // Output that arrives after an abort or timeout must not revive a finished run's activity.
295
+ if (finished || !raw.trim()) return;
296
+ let parsed: unknown;
297
+ try { parsed = JSON.parse(raw); } catch { return; }
298
+ if (!parsed || typeof parsed !== "object") return;
299
+ // Parsed child output: the object check above is all the cast assumes; every field stays unknown until read.
300
+ const event = parsed as ChildEvent;
301
+ switch (event.type) {
302
+ case "response":
303
+ if (event.success !== false) return;
304
+ if (event.command === "prompt") { stop(); finish("failed", cleanText(String(event.error ?? "prompt rejected"), 300)); return; }
305
+ pushEntry(run, { kind: "note", rev: 0, text: `${cleanText(String(event.command ?? "command"), 24)} rejected: ${cleanText(String(event.error ?? ""), 200)}` });
306
+ update();
307
+ return;
308
+ case "extension_ui_request":
309
+ if (DIALOGS.has(String(event.method))) send({ type: "extension_ui_response", id: event.id, cancelled: true });
310
+ return;
311
+ case "message_update": {
312
+ const delta = event.assistantMessageEvent;
313
+ if (delta?.type === "text_delta" && typeof delta.delta === "string") {
314
+ run.live = (run.live + delta.delta).slice(-MAX_LIVE_CHARS);
315
+ run.activity = "writing";
316
+ const now = Date.now();
317
+ if (now - liveAt >= LIVE_REPAINT_MS) { liveAt = now; update(); }
318
+ else if (!liveTimer) {
319
+ liveTimer = setTimeout(() => { liveTimer = undefined; liveAt = Date.now(); if (!finished) update(); }, LIVE_REPAINT_MS - (now - liveAt));
320
+ liveTimer.unref?.();
321
+ }
322
+ } else if ((delta?.type === "thinking_start" || delta?.type === "thinking_delta") && run.activity !== "thinking") {
323
+ run.activity = "thinking";
324
+ update();
325
+ }
326
+ return;
327
+ }
328
+ case "tool_execution_start": {
329
+ const name = cleanText(String(event.toolName ?? "tool"), 24) || "tool";
330
+ const hint = cleanText(toolHint(event.args), 200);
331
+ const id = typeof event.toolCallId === "string" && event.toolCallId ? event.toolCallId : `call-${run.tools}`;
332
+ const entry: ToolEntry = { kind: "tool", rev: 0, id, name, hint, args: argsText(event.args), status: "running", output: "", startedAt: Date.now() };
333
+ const path = toolPath(event.args);
334
+ run.tools++;
335
+ run.toolCounts[name] = (run.toolCounts[name] ?? 0) + 1;
336
+ if (name === "read" && path) addFile(run.filesRead, path);
337
+ running.set(id, { entry, ...(path ? { path } : {}) });
338
+ pushEntry(run, entry);
339
+ run.activity = cleanText(hint ? `${name} ${hint}` : name, 60);
340
+ update();
341
+ return;
342
+ }
343
+ case "tool_execution_update": {
344
+ const call = typeof event.toolCallId === "string" ? running.get(event.toolCallId) : undefined;
345
+ if (!call || !event.partialResult) return;
346
+ call.entry.output = cleanBlock(textOf(event.partialResult, MAX_ENTRY_TEXT * 2), MAX_ENTRY_TEXT);
347
+ call.entry.rev++;
348
+ update();
349
+ return;
350
+ }
351
+ case "tool_execution_end": {
352
+ const call = typeof event.toolCallId === "string" ? running.get(event.toolCallId) : undefined;
353
+ if (call) running.delete(event.toolCallId as string);
354
+ const failed = event.isError === true;
355
+ const name = call?.entry.name ?? cleanText(String(event.toolName ?? ""), 24);
356
+ if (call) {
357
+ call.entry.status = failed ? "error" : "done";
358
+ call.entry.output = event.result ? cleanBlock(textOf(event.result, MAX_ENTRY_TEXT * 2), MAX_ENTRY_TEXT) : call.entry.output;
359
+ call.entry.endedAt = Date.now();
360
+ call.entry.rev++;
361
+ }
362
+ if (name === "jar_todo" && !failed) { const todos = childTodos(event.result?.details); if (todos) run.todos = todos; }
363
+ if ((name === "edit" || name === "write") && !failed && call?.path) {
364
+ addFile(run.filesEdited, call.path);
365
+ try { hooks.edited?.(call.path); } catch (error) { console.error("pi-jar: could not track a subagent edit", error); }
366
+ }
367
+ run.activity = "thinking";
368
+ update();
369
+ return;
370
+ }
371
+ case "message_end": {
372
+ if (event.message?.role !== "assistant") return;
373
+ run.turns++;
374
+ run.cost += Number(event.message.usage?.cost?.total) || 0;
375
+ const text = textOf(event.message).trim();
376
+ if (text) { run.output = text.slice(0, MAX_OUTPUT_CHARS); pushEntry(run, { kind: "text", rev: 0, text: cleanBlock(text, MAX_ENTRY_TEXT) }); }
377
+ if (event.message.stopReason === "error" && event.message.errorMessage) run.error = cleanText(String(event.message.errorMessage), 200);
378
+ run.live = "";
379
+ run.activity = "thinking";
380
+ update();
381
+ return;
382
+ }
383
+ case "agent_settled":
384
+ // Nothing more will run on its own: close stdin so Pi shuts down in order.
385
+ settled = true;
386
+ run.activity = "finishing";
387
+ if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
388
+ update();
389
+ return;
122
390
  }
123
391
  };
392
+ // Decode as UTF-8 across chunk boundaries, and scan only each new chunk for line ends.
393
+ child.stdout?.setEncoding?.("utf8");
124
394
  child.stdout?.on("data", (chunk) => {
125
- buffer += String(chunk);
126
- const lines = buffer.split("\n");
127
- buffer = lines.pop() ?? "";
128
- for (const item of lines) line(item);
395
+ const text = String(chunk);
396
+ let start = 0;
397
+ for (let end = text.indexOf("\n", start); end >= 0; end = text.indexOf("\n", start)) {
398
+ const item = buffer + text.slice(start, end);
399
+ if (!overflow) line(item.endsWith("\r") ? item.slice(0, -1) : item);
400
+ buffer = "";
401
+ overflow = false;
402
+ start = end + 1;
403
+ }
404
+ if (overflow) return;
405
+ buffer += text.slice(start);
406
+ if (buffer.length > MAX_LINE_CHARS) { buffer = ""; overflow = true; }
129
407
  });
130
408
  child.stderr?.on("data", (chunk) => { stderr = (stderr + String(chunk)).slice(-4000); });
131
409
  child.on("error", (error) => finish("failed", error.message));
132
410
  child.on("close", (code) => {
133
- if (buffer) line(buffer);
134
- if (code === 0 && !run.error) finish("done");
135
- else finish("failed", run.error ?? (cleanText(stderr, 300) || `exited ${code}`));
411
+ if (buffer && !overflow) line(buffer);
412
+ if (code === 0 && settled && !run.error) finish("done");
413
+ else finish("failed", run.error ?? (cleanText(stderr, 300) || (settled ? `exited ${code}` : `exited ${code} before finishing`)));
136
414
  });
415
+ steer = (text) => {
416
+ const message = cleanBlock(text, MAX_STEER_CHARS);
417
+ if (finished || settled || !message || !send({ type: "steer", message })) return false;
418
+ pushEntry(run, { kind: "steer", rev: 0, text: message });
419
+ run.steered++;
420
+ update();
421
+ return true;
422
+ };
423
+ if (!send({ id: "prompt", type: "prompt", message: prompt })) { stop(); finish("failed", "could not send the task to the subagent"); }
137
424
  });
425
+ return { done, steer: (text) => steer(text) };
426
+ }
427
+
428
+ const isActive = (state: DelegateState) => state === "queued" || state === "working";
429
+
430
+ export interface SubagentRecord { key: string; batch: number; run: DelegateRun; stop(): void; steer(text: string): boolean }
431
+
432
+ /**
433
+ * Live subagents for the activity view. Tool details are saved in the session, so they stay small;
434
+ * the transcript and the per-run stop and steer handles live only here, for this process.
435
+ */
436
+ export class DelegateRegistry {
437
+ private entries = new Map<string, { record: SubagentRecord; finished?: number }>();
438
+ private listeners = new Set<() => void>();
439
+ private sequence = 0;
440
+
441
+ subscribe(listener: () => void): () => void {
442
+ this.listeners.add(listener);
443
+ return () => { this.listeners.delete(listener); };
444
+ }
445
+ /** Queued/working first (oldest first), then finished ones newest first. */
446
+ records(): SubagentRecord[] {
447
+ this.sweep();
448
+ const live: SubagentRecord[] = [];
449
+ const finished: Array<{ record: SubagentRecord; finished: number }> = [];
450
+ for (const { record, finished: at } of this.entries.values()) {
451
+ if (at === undefined) live.push(record); else finished.push({ record, finished: at });
452
+ }
453
+ finished.sort((a, b) => b.finished - a.finished);
454
+ return [...live, ...finished.map((entry) => entry.record)];
455
+ }
456
+ get(key: string): SubagentRecord | undefined { return this.entries.get(key)?.record; }
457
+ /** Abort one queued/working run, leaving the rest of its batch alone; false when unknown or already finished. */
458
+ stop(key: string): boolean {
459
+ const record = this.entries.get(key)?.record;
460
+ if (!record || !isActive(record.run.state)) return false;
461
+ record.stop();
462
+ this.notify();
463
+ return true;
464
+ }
465
+ /** Send a steering message to one working run; false when it cannot take one. */
466
+ steer(key: string, text: string): boolean {
467
+ const record = this.entries.get(key)?.record;
468
+ return !!record && record.run.state === "working" && record.steer(text);
469
+ }
470
+ running(): number {
471
+ let count = 0;
472
+ for (const { record } of this.entries.values()) if (isActive(record.run.state)) count++;
473
+ return count;
474
+ }
475
+ /** Stop outstanding runs on session changes/shutdown and forget their transcript. */
476
+ clear(): void {
477
+ const stopping = [...this.entries.values()].filter(({ record }) => isActive(record.run.state)).map(({ record }) => record.stop);
478
+ this.entries.clear();
479
+ for (const stop of stopping) stop();
480
+ this.notify();
481
+ }
482
+ /** jar_delegate's side: list new runs. */
483
+ add(...records: SubagentRecord[]): void {
484
+ for (const record of records) this.entries.set(record.key, { record });
485
+ this.notify();
486
+ }
487
+ /** jar_delegate's side: a run changed. */
488
+ notify(): void {
489
+ this.sweep();
490
+ for (const listener of this.listeners) {
491
+ try { listener(); } catch { /* views are decoration; a broken one must not break the run */ }
492
+ }
493
+ }
494
+ /** Stamp runs in the order they finish and keep only the newest MAX_FINISHED of them. */
495
+ private sweep(): void {
496
+ let finished = 0;
497
+ for (const entry of this.entries.values()) {
498
+ if (isActive(entry.record.run.state)) continue;
499
+ entry.finished ??= ++this.sequence;
500
+ finished++;
501
+ }
502
+ if (finished <= MAX_FINISHED) return;
503
+ const oldest = [...this.entries].filter(([, entry]) => entry.finished !== undefined).sort((a, b) => a[1].finished! - b[1].finished!);
504
+ for (const [key] of oldest.slice(0, finished - MAX_FINISHED)) this.entries.delete(key);
505
+ }
506
+ }
507
+
508
+ /** `12s` / `3m 4s`. */
509
+ export const duration = (ms: number) => { const s = Math.max(0, Math.round(ms / 1000)); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`; };
510
+ const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`;
511
+ const fileList = (files: readonly string[], limit: number) => files.slice(0, limit).join(", ") + (files.length > limit ? ` (+${files.length - limit} more)` : "");
512
+ const todoLine = (todo: Todo) => `${todo.parentId ? " " : " "}${todo.status === "completed" ? "[x]" : todo.status === "in_progress" ? "[~]" : "[ ]"} ${todo.title}`;
513
+
514
+ /** What the parent agent reads back: the run's facts (time, tools, files, tasks, failures), then its own report. */
515
+ export function runReport(run: DelegateRun, now = Date.now()): string {
516
+ const counts = Object.entries(run.toolCounts).sort((a, b) => b[1] - a[1]).map(([name, count]) => `${name}×${count}`).join(", ");
517
+ const facts = [
518
+ run.startedAt !== undefined ? `took ${duration((run.endedAt ?? now) - run.startedAt)}` : "never started",
519
+ plural(run.turns, "turn"), `${plural(run.tools, "tool call")}${counts ? ` (${counts})` : ""}`,
520
+ run.cost ? `$${run.cost.toFixed(3)}` : "", run.steered ? `steered ${run.steered}×` : ""
521
+ ].filter(Boolean).join(" · ");
522
+ const lines = [`## [${run.index}] ${run.name} (${run.role}${run.model ? " · " + run.model : ""}) — ${run.state}${run.error ? ": " + run.error : ""}`, `- ${facts}`];
523
+ if (run.filesEdited.length) lines.push(`- files edited: ${fileList(run.filesEdited, 20)}`);
524
+ if (run.filesRead.length) lines.push(`- files read: ${fileList(run.filesRead, 12)}`);
525
+ if (run.todos.length) {
526
+ const leaves = leafTodos(run.todos);
527
+ lines.push(`- tasks: ${leaves.filter((todo) => todo.done).length}/${leaves.length} done`, ...run.todos.map(todoLine));
528
+ }
529
+ const failures = run.transcript.filter((entry): entry is ToolEntry => entry.kind === "tool" && entry.status === "error").slice(-5);
530
+ if (failures.length) lines.push(`- failed tool calls: ${failures.map((entry) => cleanText(`${entry.name} ${entry.hint}`, 80)).join("; ")}`);
531
+ lines.push("", run.output || "(no report)");
532
+ return lines.join("\n");
138
533
  }
139
534
 
140
535
  const Parameters = Type.Object({
@@ -148,18 +543,28 @@ const Parameters = Type.Object({
148
543
 
149
544
  const glyph = (state: DelegateState) => state === "done" ? "✔" : state === "failed" ? "✖" : state === "working" ? "●" : "○";
150
545
  const color = (state: DelegateState) => state === "done" ? "success" : state === "failed" ? "error" : state === "working" ? "accent" : "dim";
546
+ type RunDetails = Omit<DelegateRun, "transcript" | "live">;
547
+
548
+ export interface DelegateOptions {
549
+ spawnProcess?: Spawn;
550
+ /** The parent's change tracker: subagent edits join /diff. */
551
+ changes?: () => ChangeTracker | undefined;
552
+ /** Called after a subagent edit was added to the tracker. */
553
+ changed?: () => void;
554
+ }
151
555
 
152
- export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spawnProcess?: Spawn): void {
556
+ /** jar_delegate. Every run is listed live in `registry` (with its transcript and stop/steer handles) for the activity view. */
557
+ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, registry: DelegateRegistry, options: DelegateOptions = {}): void {
153
558
  if (process.env[CHILD_ENV] || typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool !== "function") return;
154
559
  let batch = 0;
155
560
  pi.registerTool({
156
561
  name: DELEGATE_TOOL,
157
562
  label: "delegate",
158
- description: `Run up to ${MAX_DELEGATES} subagents in parallel, each an isolated Pi with a fresh context, on a pi-jar model role (default "task"). Read-only unless write is true. Returns each subagent's report.`,
563
+ description: `Run up to ${MAX_DELEGATES} subagents in parallel, each an isolated Pi with a fresh context, on a pi-jar model role (default "task"). Read-only unless write is true. Returns each subagent's report with its tool calls, files read/edited, task checklist and failures.`,
159
564
  promptSnippet: "Use jar_delegate to fan out independent investigations to parallel subagents.",
160
565
  promptGuidelines: [
161
566
  "Use jar_delegate for independent, parallelizable investigation (scouting several areas, reviewing, researching); give each task complete context because subagents see nothing else.",
162
- "Keep delegated work read-only by default. Only set write for clearly separated edits that cannot conflict; never have two subagents edit the same files."
567
+ "Keep delegated work read-only by default. Only set write for clearly separated edits that cannot conflict; never have two subagents edit the same files. Files they edit join /diff for review."
163
568
  ],
164
569
  parameters: Parameters,
165
570
  async execute(_id, params, signal, onUpdate, ctx) {
@@ -170,30 +575,59 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spaw
170
575
  const resolved = roles.resolve(role) ?? roles.resolve("default");
171
576
  const model = resolved ? `${resolved.provider}/${resolved.model}` : ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
172
577
  return { index: index + 1, name: cleanText(item.name ?? `agent ${index + 1}`, 24), task: item.task, role, ...(model ? { model } : {}),
173
- state: "queued", tools: 0, turns: 0, cost: 0, output: "" };
578
+ state: "queued", tools: 0, turns: 0, cost: 0, output: "", toolCounts: {}, filesRead: [], filesEdited: [], todos: [], steered: 0, transcript: [], live: "" };
174
579
  });
580
+ // One controller per run: aborting the tool call stops them all, the activity view stops one.
581
+ const controllers = runs.map(() => new AbortController());
582
+ const abortAll = () => { for (const controller of controllers) controller.abort(); };
583
+ if (signal?.aborted) abortAll(); else signal?.addEventListener("abort", abortAll, { once: true });
584
+ let handles: DelegateHandle[] = [];
585
+ // The key doubles as the role-status id, so views can tell our own teammates from other extensions'.
586
+ const records: SubagentRecord[] = runs.map((run, index) => ({
587
+ key: `delegate-${id}-${run.index}`, batch: id, run,
588
+ stop: () => controllers[index]!.abort(STOP_REASON),
589
+ steer: (text) => handles[index]?.steer(text) ?? false
590
+ }));
591
+ registry.add(...records);
175
592
  const thinking = (role: string) => (roles.resolve(role) ?? roles.resolve("default"))?.thinking;
593
+ // Editing subagents record each file's content before their first change here, for the parent's /diff.
594
+ let baselines: string | undefined;
595
+ if (write && options.changes) {
596
+ try { baselines = mkdtempSync(join(tmpdir(), "pi-jar-baselines-")); }
597
+ catch (error) { console.error("pi-jar: subagent edits will not join /diff", error); }
598
+ }
599
+ const edited = (path: string) => {
600
+ const tracker = options.changes?.();
601
+ if (!baselines || !tracker) return;
602
+ const absolute = isAbsolute(path) ? resolve(path) : resolve(ctx.cwd, path);
603
+ const baseline = readChildBaseline(baselines, absolute);
604
+ if (baseline !== undefined && tracker.adopt(absolute, baseline)) options.changed?.();
605
+ };
176
606
  // Live teammates: the welcome TEAM row and the footer read this public status contract.
177
607
  const publish = () => {
178
608
  if (!ctx.hasUI) return;
179
- for (const run of runs) {
609
+ for (const { key, run } of records) {
180
610
  try {
181
- ctx.ui.setStatus(`${ROLE_PREFIX}delegate-${id}-${run.index}`, run.state === "done" || run.state === "failed" ? undefined : JSON.stringify({
611
+ ctx.ui.setStatus(`${ROLE_PREFIX}${key}`, run.state === "done" || run.state === "failed" ? undefined : JSON.stringify({
182
612
  name: run.name, label: run.name.slice(0, 12), state: run.state === "queued" ? "waiting" : "working", task: cleanText(run.task, 60), expiresAt: Date.now() + 25_000
183
613
  }));
184
614
  } catch { /* status is decoration */ }
185
615
  }
186
616
  };
187
- const details = (includeOutput = true) => ({ runs: runs.map((run) => includeOutput ? { ...run } : { ...run, output: "" }), write });
617
+ // Details are persisted with the session: never the transcript or live text, which the registry holds instead.
618
+ const details = (includeOutput = true) => ({
619
+ runs: runs.map(({ transcript: _transcript, live: _live, ...run }): RunDetails => includeOutput ? run : { ...run, output: "" }), write
620
+ });
188
621
  let updateTimer: ReturnType<typeof setTimeout> | undefined;
189
622
  let lastUpdateAt = 0;
190
623
  const emitUpdate = () => {
191
624
  updateTimer = undefined;
192
625
  lastUpdateAt = Date.now();
193
626
  publish();
194
- onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}`).join("\n") }], details: details(false) });
627
+ onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}${run.activity ? ` · ${run.activity}` : ""}`).join("\n") }], details: details(false) });
195
628
  };
196
629
  const update = () => {
630
+ registry.notify();
197
631
  if (!onUpdate) { publish(); return; }
198
632
  const wait = Math.max(0, LIVE_UPDATE_MS - (Date.now() - lastUpdateAt));
199
633
  if (wait === 0) { if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; } emitUpdate(); return; }
@@ -208,15 +642,20 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spaw
208
642
  };
209
643
  const refresh = setInterval(publish, STATUS_REFRESH_MS);
210
644
  refresh.unref?.();
645
+ const hooks: DelegateHooks = { ...(baselines ? { env: { [CHILD_BASELINE_ENV]: baselines } } : {}), edited };
211
646
  try {
212
- await Promise.all(runs.map((run) => runDelegate(run, delegateArgs(run.task, run.model, thinking(run.role), write), ctx.cwd, signal, update, spawnProcess)));
647
+ handles = runs.map((run, index) => startDelegate(run, delegateArgs(run.model, thinking(run.role), write), delegatePrompt(run.task, write),
648
+ ctx.cwd, controllers[index]!.signal, update, options.spawnProcess, hooks));
649
+ await Promise.all(handles.map((handle) => handle.done));
213
650
  } finally {
214
651
  clearInterval(refresh);
215
- for (const run of runs) if (run.state !== "done") run.state = "failed";
652
+ signal?.removeEventListener("abort", abortAll);
653
+ if (baselines) rmSync(baselines, { recursive: true, force: true });
654
+ for (const run of runs) if (run.state !== "done") { run.state = "failed"; run.endedAt ??= Date.now(); }
655
+ registry.notify();
216
656
  flushUpdate();
217
657
  }
218
- const report = runs.map((run) => `## [${run.index}] ${run.name} (${run.role}${run.model ? " · " + run.model : ""}) — ${run.state}${run.error ? ": " + run.error : ""}\n${run.output || "(no report)"}`).join("\n\n");
219
- return { content: [{ type: "text", text: report }], details: details() };
658
+ return { content: [{ type: "text", text: runs.map((run) => runReport(run)).join("\n\n") }], details: details() };
220
659
  },
221
660
  renderCall(args, theme) {
222
661
  const count = Array.isArray(args.tasks) ? args.tasks.length : 0;
@@ -224,9 +663,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spaw
224
663
  + theme.fg("dim", args.write ? " · can edit" : " · read-only"), 0, 0);
225
664
  },
226
665
  renderResult(result, { expanded }, theme) {
227
- const runs = (result.details as { runs?: DelegateRun[] } | undefined)?.runs ?? [];
666
+ const runs = (result.details as { runs?: Array<Partial<RunDetails> & Pick<DelegateRun, "name" | "state" | "role">> } | undefined)?.runs ?? [];
228
667
  const rows = runs.map((run) => {
229
- const meta = [run.role, run.activity, run.tools ? `${run.tools} tools` : "", run.cost ? `$${run.cost.toFixed(3)}` : "", run.error].filter(Boolean).join(" · ");
668
+ const leaves = leafTodos(run.todos ?? []);
669
+ const meta = [run.role, run.activity, run.tools ? `${run.tools} tools` : "", leaves.length ? `${leaves.filter((todo) => todo.done).length}/${leaves.length} tasks` : "",
670
+ run.filesEdited?.length ? `${run.filesEdited.length} edited` : "", run.cost ? `$${run.cost.toFixed(3)}` : "", run.error].filter(Boolean).join(" · ");
230
671
  let row = theme.fg(color(run.state) as never, ` ${glyph(run.state)} ${run.name}`) + theme.fg("dim", " · " + meta);
231
672
  if (expanded && run.output) row += "\n" + run.output.split("\n").map((line) => theme.fg("muted", " " + line)).join("\n");
232
673
  return row;