pi-jar 0.1.3 → 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/README.md +33 -7
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/WORKFLOWS.md +7 -4
- package/extensions/index.ts +55 -37
- package/package.json +1 -1
- package/src/activity-view.ts +314 -41
- package/src/advisor.ts +9 -3
- package/src/changes.ts +35 -11
- package/src/child-baselines.ts +42 -0
- package/src/compact-tools.ts +58 -24
- package/src/composer.ts +39 -12
- package/src/delegate.ts +371 -65
- package/src/dialogs.ts +9 -5
- package/src/diff-view.ts +13 -1
- package/src/flame.ts +18 -5
- package/src/footer.ts +34 -16
- package/src/history-ui.ts +19 -12
- package/src/history.ts +3 -1
- package/src/model-roles.ts +62 -7
- package/src/panel.ts +5 -1
- package/src/prompt-search.ts +16 -8
- package/src/row-cache.ts +26 -0
- package/src/session-ui.ts +9 -4
- package/src/settings-ui.ts +5 -9
- package/src/shells.ts +2 -1
- package/src/side-model.ts +45 -25
- package/src/split-view.ts +29 -12
- package/src/task-tool.ts +140 -53
- package/src/tasks-ui.ts +15 -5
- package/src/tasks.ts +224 -38
- package/src/welcome.ts +26 -13
package/src/delegate.ts
CHANGED
|
@@ -1,33 +1,57 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
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 {
|
|
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
|
-
|
|
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;
|
|
21
|
-
|
|
22
|
-
const
|
|
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;
|
|
23
37
|
/** Finished subagents the registry keeps for the activity view after their tool call returns. */
|
|
24
38
|
const MAX_FINISHED = 8;
|
|
25
39
|
/** Abort reason for stopping one subagent from the activity view (vs. aborting the whole tool call). */
|
|
26
40
|
const STOP_REASON = "stopped";
|
|
27
41
|
/** Tool arguments that best describe a call in the transcript, most telling first. */
|
|
28
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;
|
|
29
46
|
|
|
30
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
|
+
|
|
31
55
|
export interface DelegateRun {
|
|
32
56
|
index: number;
|
|
33
57
|
name: string;
|
|
@@ -43,8 +67,17 @@ export interface DelegateRun {
|
|
|
43
67
|
error?: string;
|
|
44
68
|
startedAt?: number;
|
|
45
69
|
endedAt?: number;
|
|
46
|
-
/**
|
|
47
|
-
|
|
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;
|
|
48
81
|
}
|
|
49
82
|
|
|
50
83
|
type Spawn = (command: string, args: string[], options: Parameters<typeof spawn>[2]) => ChildProcess;
|
|
@@ -57,19 +90,52 @@ export function piInvocation(args: string[], argv = process.argv, execPath = pro
|
|
|
57
90
|
return { command: "pi", args };
|
|
58
91
|
}
|
|
59
92
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
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)];
|
|
63
111
|
if (model) args.push("--model", model);
|
|
64
112
|
if (thinking) args.push("--thinking", thinking);
|
|
65
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 {
|
|
66
119
|
const rules = write
|
|
67
120
|
? "You may edit files. Stay strictly within the task; other subagents may be working in the same repository at the same time."
|
|
68
121
|
: "You are read-only: investigate and report, do not attempt to modify anything.";
|
|
69
|
-
|
|
70
|
-
|
|
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");
|
|
71
133
|
}
|
|
72
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
|
+
|
|
73
139
|
const textOf = (message: { content?: unknown }, limit = MAX_EVENT_TEXT): string => {
|
|
74
140
|
if (typeof message.content === "string") return message.content.slice(0, limit);
|
|
75
141
|
if (!Array.isArray(message.content)) return "";
|
|
@@ -85,12 +151,6 @@ const textOf = (message: { content?: unknown }, limit = MAX_EVENT_TEXT): string
|
|
|
85
151
|
return out;
|
|
86
152
|
};
|
|
87
153
|
|
|
88
|
-
/** Append transcript lines, cleaned and clipped, keeping only the newest MAX_LOG_LINES. */
|
|
89
|
-
const appendLog = (run: DelegateRun, lines: readonly string[]) => {
|
|
90
|
-
for (const raw of lines) { const line = cleanText(raw, MAX_LOG_CHARS); if (line) run.log.push(line); }
|
|
91
|
-
if (run.log.length > MAX_LOG_LINES) run.log.splice(0, run.log.length - MAX_LOG_LINES);
|
|
92
|
-
};
|
|
93
|
-
|
|
94
154
|
/** The argument that says what a tool call does: the bash command, the file path, the search pattern… */
|
|
95
155
|
const toolHint = (args: unknown): string => {
|
|
96
156
|
if (!args || typeof args !== "object") return "";
|
|
@@ -100,43 +160,127 @@ const toolHint = (args: unknown): string => {
|
|
|
100
160
|
}
|
|
101
161
|
return "";
|
|
102
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
|
+
}
|
|
103
200
|
|
|
104
|
-
/**
|
|
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. */
|
|
105
209
|
interface ChildEvent {
|
|
106
210
|
type?: unknown;
|
|
211
|
+
id?: unknown;
|
|
212
|
+
command?: unknown;
|
|
213
|
+
success?: unknown;
|
|
214
|
+
error?: unknown;
|
|
215
|
+
method?: unknown;
|
|
216
|
+
toolCallId?: unknown;
|
|
107
217
|
toolName?: unknown;
|
|
108
218
|
args?: unknown;
|
|
219
|
+
isError?: unknown;
|
|
220
|
+
result?: { content?: unknown; details?: unknown };
|
|
221
|
+
partialResult?: { content?: unknown };
|
|
222
|
+
assistantMessageEvent?: { type?: unknown; delta?: unknown };
|
|
109
223
|
message?: { role?: unknown; content?: unknown; usage?: { cost?: { total?: unknown } }; stopReason?: unknown; errorMessage?: unknown };
|
|
110
224
|
}
|
|
111
225
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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) => {
|
|
115
242
|
const invocation = piInvocation(args);
|
|
116
243
|
let child: ChildProcess;
|
|
117
244
|
try {
|
|
118
|
-
child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, stdio: ["
|
|
245
|
+
child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...hooks.env, [CHILD_ENV]: "1" } });
|
|
119
246
|
} catch (error) {
|
|
120
|
-
run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); run.endedAt = Date.now(); update();
|
|
247
|
+
run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); run.endedAt = Date.now(); update(); resolveDone(); return;
|
|
121
248
|
}
|
|
122
249
|
run.state = "working";
|
|
123
250
|
run.startedAt = Date.now();
|
|
124
251
|
run.activity = "starting";
|
|
125
252
|
update();
|
|
126
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;
|
|
127
259
|
let stderr = "";
|
|
128
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); });
|
|
129
269
|
const finish = (state: DelegateState, error?: string) => {
|
|
130
270
|
if (finished) return;
|
|
131
271
|
finished = true;
|
|
132
272
|
clearTimeout(timer);
|
|
273
|
+
clearTimeout(liveTimer);
|
|
133
274
|
signal?.removeEventListener("abort", abort);
|
|
275
|
+
if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
|
|
134
276
|
run.state = state;
|
|
135
277
|
if (error) run.error = error;
|
|
136
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++; }
|
|
137
281
|
run.endedAt = Date.now();
|
|
138
282
|
update();
|
|
139
|
-
|
|
283
|
+
resolveDone();
|
|
140
284
|
};
|
|
141
285
|
const stop = () => { child.kill("SIGTERM"); setTimeout(() => { if (child.exitCode === null) child.kill("SIGKILL"); }, 3000).unref?.(); };
|
|
142
286
|
// Stopping one run from the activity view reads "stopped"; aborting the whole tool call reads "aborted".
|
|
@@ -145,6 +289,7 @@ export function runDelegate(run: DelegateRun, args: string[], cwd: string, signa
|
|
|
145
289
|
timer.unref?.();
|
|
146
290
|
if (signal?.aborted) { abort(); return; }
|
|
147
291
|
signal?.addEventListener("abort", abort, { once: true });
|
|
292
|
+
const running = new Map<string, { entry: ToolEntry; path?: string }>();
|
|
148
293
|
const line = (raw: string) => {
|
|
149
294
|
// Output that arrives after an abort or timeout must not revive a finished run's activity.
|
|
150
295
|
if (finished || !raw.trim()) return;
|
|
@@ -153,45 +298,140 @@ export function runDelegate(run: DelegateRun, args: string[], cwd: string, signa
|
|
|
153
298
|
if (!parsed || typeof parsed !== "object") return;
|
|
154
299
|
// Parsed child output: the object check above is all the cast assumes; every field stays unknown until read.
|
|
155
300
|
const event = parsed as ChildEvent;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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;
|
|
170
390
|
}
|
|
171
391
|
};
|
|
392
|
+
// Decode as UTF-8 across chunk boundaries, and scan only each new chunk for line ends.
|
|
393
|
+
child.stdout?.setEncoding?.("utf8");
|
|
172
394
|
child.stdout?.on("data", (chunk) => {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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; }
|
|
177
407
|
});
|
|
178
408
|
child.stderr?.on("data", (chunk) => { stderr = (stderr + String(chunk)).slice(-4000); });
|
|
179
409
|
child.on("error", (error) => finish("failed", error.message));
|
|
180
410
|
child.on("close", (code) => {
|
|
181
|
-
if (buffer) line(buffer);
|
|
182
|
-
if (code === 0 && !run.error) finish("done");
|
|
183
|
-
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`)));
|
|
184
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"); }
|
|
185
424
|
});
|
|
425
|
+
return { done, steer: (text) => steer(text) };
|
|
186
426
|
}
|
|
187
427
|
|
|
188
428
|
const isActive = (state: DelegateState) => state === "queued" || state === "working";
|
|
189
429
|
|
|
190
|
-
export interface SubagentRecord { key: string; batch: number; run: DelegateRun; stop(): void }
|
|
430
|
+
export interface SubagentRecord { key: string; batch: number; run: DelegateRun; stop(): void; steer(text: string): boolean }
|
|
191
431
|
|
|
192
432
|
/**
|
|
193
433
|
* Live subagents for the activity view. Tool details are saved in the session, so they stay small;
|
|
194
|
-
* the transcript and the per-run stop
|
|
434
|
+
* the transcript and the per-run stop and steer handles live only here, for this process.
|
|
195
435
|
*/
|
|
196
436
|
export class DelegateRegistry {
|
|
197
437
|
private entries = new Map<string, { record: SubagentRecord; finished?: number }>();
|
|
@@ -222,6 +462,11 @@ export class DelegateRegistry {
|
|
|
222
462
|
this.notify();
|
|
223
463
|
return true;
|
|
224
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
|
+
}
|
|
225
470
|
running(): number {
|
|
226
471
|
let count = 0;
|
|
227
472
|
for (const { record } of this.entries.values()) if (isActive(record.run.state)) count++;
|
|
@@ -260,6 +505,33 @@ export class DelegateRegistry {
|
|
|
260
505
|
}
|
|
261
506
|
}
|
|
262
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");
|
|
533
|
+
}
|
|
534
|
+
|
|
263
535
|
const Parameters = Type.Object({
|
|
264
536
|
tasks: Type.Array(Type.Object({
|
|
265
537
|
task: Type.String({ description: "A complete, self-contained instruction: the subagent sees nothing else." }),
|
|
@@ -271,19 +543,28 @@ const Parameters = Type.Object({
|
|
|
271
543
|
|
|
272
544
|
const glyph = (state: DelegateState) => state === "done" ? "✔" : state === "failed" ? "✖" : state === "working" ? "●" : "○";
|
|
273
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
|
+
}
|
|
274
555
|
|
|
275
|
-
/** jar_delegate. Every run is listed live in `registry` (with its transcript and
|
|
276
|
-
export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, registry: DelegateRegistry,
|
|
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 {
|
|
277
558
|
if (process.env[CHILD_ENV] || typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool !== "function") return;
|
|
278
559
|
let batch = 0;
|
|
279
560
|
pi.registerTool({
|
|
280
561
|
name: DELEGATE_TOOL,
|
|
281
562
|
label: "delegate",
|
|
282
|
-
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.`,
|
|
283
564
|
promptSnippet: "Use jar_delegate to fan out independent investigations to parallel subagents.",
|
|
284
565
|
promptGuidelines: [
|
|
285
566
|
"Use jar_delegate for independent, parallelizable investigation (scouting several areas, reviewing, researching); give each task complete context because subagents see nothing else.",
|
|
286
|
-
"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."
|
|
287
568
|
],
|
|
288
569
|
parameters: Parameters,
|
|
289
570
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
@@ -294,16 +575,34 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
294
575
|
const resolved = roles.resolve(role) ?? roles.resolve("default");
|
|
295
576
|
const model = resolved ? `${resolved.provider}/${resolved.model}` : ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
296
577
|
return { index: index + 1, name: cleanText(item.name ?? `agent ${index + 1}`, 24), task: item.task, role, ...(model ? { model } : {}),
|
|
297
|
-
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: "" };
|
|
298
579
|
});
|
|
299
580
|
// One controller per run: aborting the tool call stops them all, the activity view stops one.
|
|
300
581
|
const controllers = runs.map(() => new AbortController());
|
|
301
582
|
const abortAll = () => { for (const controller of controllers) controller.abort(); };
|
|
302
583
|
if (signal?.aborted) abortAll(); else signal?.addEventListener("abort", abortAll, { once: true });
|
|
584
|
+
let handles: DelegateHandle[] = [];
|
|
303
585
|
// The key doubles as the role-status id, so views can tell our own teammates from other extensions'.
|
|
304
|
-
const records: SubagentRecord[] = runs.map((run, index) => ({
|
|
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
|
+
}));
|
|
305
591
|
registry.add(...records);
|
|
306
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
|
+
};
|
|
307
606
|
// Live teammates: the welcome TEAM row and the footer read this public status contract.
|
|
308
607
|
const publish = () => {
|
|
309
608
|
if (!ctx.hasUI) return;
|
|
@@ -315,15 +614,17 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
315
614
|
} catch { /* status is decoration */ }
|
|
316
615
|
}
|
|
317
616
|
};
|
|
318
|
-
// Details are persisted with the session: never the transcript, which the registry holds instead.
|
|
319
|
-
const details = (includeOutput = true) => ({
|
|
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
|
+
});
|
|
320
621
|
let updateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
321
622
|
let lastUpdateAt = 0;
|
|
322
623
|
const emitUpdate = () => {
|
|
323
624
|
updateTimer = undefined;
|
|
324
625
|
lastUpdateAt = Date.now();
|
|
325
626
|
publish();
|
|
326
|
-
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) });
|
|
327
628
|
};
|
|
328
629
|
const update = () => {
|
|
329
630
|
registry.notify();
|
|
@@ -341,17 +642,20 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
341
642
|
};
|
|
342
643
|
const refresh = setInterval(publish, STATUS_REFRESH_MS);
|
|
343
644
|
refresh.unref?.();
|
|
645
|
+
const hooks: DelegateHooks = { ...(baselines ? { env: { [CHILD_BASELINE_ENV]: baselines } } : {}), edited };
|
|
344
646
|
try {
|
|
345
|
-
|
|
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));
|
|
346
650
|
} finally {
|
|
347
651
|
clearInterval(refresh);
|
|
348
652
|
signal?.removeEventListener("abort", abortAll);
|
|
653
|
+
if (baselines) rmSync(baselines, { recursive: true, force: true });
|
|
349
654
|
for (const run of runs) if (run.state !== "done") { run.state = "failed"; run.endedAt ??= Date.now(); }
|
|
350
655
|
registry.notify();
|
|
351
656
|
flushUpdate();
|
|
352
657
|
}
|
|
353
|
-
|
|
354
|
-
return { content: [{ type: "text", text: report }], details: details() };
|
|
658
|
+
return { content: [{ type: "text", text: runs.map((run) => runReport(run)).join("\n\n") }], details: details() };
|
|
355
659
|
},
|
|
356
660
|
renderCall(args, theme) {
|
|
357
661
|
const count = Array.isArray(args.tasks) ? args.tasks.length : 0;
|
|
@@ -359,9 +663,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
359
663
|
+ theme.fg("dim", args.write ? " · can edit" : " · read-only"), 0, 0);
|
|
360
664
|
},
|
|
361
665
|
renderResult(result, { expanded }, theme) {
|
|
362
|
-
const runs = (result.details as { runs?: Array<
|
|
666
|
+
const runs = (result.details as { runs?: Array<Partial<RunDetails> & Pick<DelegateRun, "name" | "state" | "role">> } | undefined)?.runs ?? [];
|
|
363
667
|
const rows = runs.map((run) => {
|
|
364
|
-
const
|
|
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(" · ");
|
|
365
671
|
let row = theme.fg(color(run.state) as never, ` ${glyph(run.state)} ${run.name}`) + theme.fg("dim", " · " + meta);
|
|
366
672
|
if (expanded && run.output) row += "\n" + run.output.split("\n").map((line) => theme.fg("muted", " " + line)).join("\n");
|
|
367
673
|
return row;
|