pi-jar 0.1.4 → 0.1.6
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 +35 -19
- package/docs/INTEGRATIONS.md +2 -2
- package/docs/WORKFLOWS.md +34 -10
- package/extensions/index.ts +87 -15
- package/package.json +1 -1
- package/src/activity-view.ts +23 -7
- package/src/ask-tool.ts +12 -7
- package/src/compact-tools.ts +66 -28
- package/src/delegate-worktree.ts +360 -0
- package/src/delegate.ts +934 -130
- package/src/democracy.ts +214 -0
- package/src/discussion.ts +189 -0
- package/src/footer.ts +4 -3
- package/src/goal-loop.ts +11 -16
- package/src/model-roles.ts +58 -29
- package/src/plan.ts +4 -10
- package/src/roles-ui.ts +18 -10
- package/src/settings-ui.ts +41 -15
- package/src/settings.ts +9 -1
- package/src/status.ts +2 -1
- package/src/task-tool.ts +33 -18
- package/src/tasks.ts +7 -7
package/src/delegate.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { spawn, type ChildProcess } from "node:child_process";
|
|
|
2
2
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
5
|
-
import
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
6
7
|
import { Text } from "@earendil-works/pi-tui";
|
|
7
8
|
import { Type } from "typebox";
|
|
8
9
|
import type { ChangeTracker } from "./changes.ts";
|
|
@@ -10,17 +11,40 @@ import { CHILD_BASELINE_ENV, readChildBaseline } from "./child-baselines.ts";
|
|
|
10
11
|
import type { ModelRoleManager } from "./model-roles.ts";
|
|
11
12
|
import { cleanText, ROLE_PREFIX } from "./status.ts";
|
|
12
13
|
import type { Todo } from "./tasks.ts";
|
|
14
|
+
import { DISCUSSION_FILE_ENV, SUBAGENT_KEY_ENV, SUBAGENT_NAME_ENV } from "./discussion.ts";
|
|
15
|
+
import {
|
|
16
|
+
applyDelegateWorktree,
|
|
17
|
+
CHILD_WORKTREE_ENV,
|
|
18
|
+
createDelegateWorktree,
|
|
19
|
+
disposeDelegateWorktree,
|
|
20
|
+
worktreeChangedFiles,
|
|
21
|
+
WorktreeApplyError,
|
|
22
|
+
type DelegateWorktree
|
|
23
|
+
} from "./delegate-worktree.ts";
|
|
13
24
|
|
|
14
25
|
export const DELEGATE_TOOL = "jar_delegate";
|
|
15
26
|
/** Set in child processes so a subagent never delegates again. */
|
|
16
27
|
export const CHILD_ENV = "PI_JAR_CHILD";
|
|
17
28
|
export const MAX_DELEGATES = 4;
|
|
29
|
+
export const SUBAGENT_LIMIT_CHOICES = [2, 4, 6, 8, 16] as const;
|
|
18
30
|
/** 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;
|
|
31
|
+
export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "jar_todo", "jar_discuss"] as const;
|
|
32
|
+
/** Writable worktree children deliberately have no shell: edits stay inside path-guarded file tools. */
|
|
33
|
+
export const WORKTREE_TOOLS = ["read", "edit", "write", "grep", "find", "ls", "jar_todo", "jar_discuss"] as const;
|
|
34
|
+
export type DelegateMode = "scout" | "fork" | "worktree";
|
|
35
|
+
type DelegateExecutionMode = DelegateMode | "direct";
|
|
20
36
|
const MAX_OUTPUT_CHARS = 12_000;
|
|
21
|
-
|
|
37
|
+
/** A working child that sends no RPC event for this long is treated as hung and killed. */
|
|
38
|
+
const IDLE_TIMEOUT_MS = 10 * 60_000;
|
|
39
|
+
/** After exit, grandchildren (MCP servers, shells) can hold the stdio pipes open; stop waiting for close. */
|
|
40
|
+
const CLOSE_GRACE_MS = 2000;
|
|
41
|
+
/** How long pause waits for the child to settle after abort before reporting that it has not paused yet. */
|
|
42
|
+
const PAUSE_GRACE_MS = 15_000;
|
|
22
43
|
const STATUS_REFRESH_MS = 20_000;
|
|
23
44
|
const LIVE_UPDATE_MS = 1000;
|
|
45
|
+
/** Visible message that wakes the moderator when a resumed subagent's turn ends. */
|
|
46
|
+
export const SUBAGENT_MESSAGE = "pi-jar.subagent";
|
|
47
|
+
const NOTIFY_COALESCE_MS = 250;
|
|
24
48
|
const MAX_EVENT_TEXT = 16_000;
|
|
25
49
|
/** Transcript entries kept per run; tool output and text are clipped so a run stays well under 2 MiB. */
|
|
26
50
|
const MAX_ENTRIES = 200;
|
|
@@ -34,6 +58,8 @@ const MAX_LINE_CHARS = 16 * 1024 * 1024;
|
|
|
34
58
|
const MAX_FILES = 50;
|
|
35
59
|
const MAX_TODOS = 50;
|
|
36
60
|
const MAX_STEER_CHARS = 4000;
|
|
61
|
+
/** Resumed assignments/ballots can include bounded evidence and multiple options. */
|
|
62
|
+
export const MAX_RESUME_CHARS = 40_000;
|
|
37
63
|
/** Finished subagents the registry keeps for the activity view after their tool call returns. */
|
|
38
64
|
const MAX_FINISHED = 8;
|
|
39
65
|
/** Abort reason for stopping one subagent from the activity view (vs. aborting the whole tool call). */
|
|
@@ -44,7 +70,7 @@ const HINT_KEYS = ["command", "path", "file_path", "pattern", "query", "url"] as
|
|
|
44
70
|
const DIALOGS = new Set(["select", "confirm", "input", "editor"]);
|
|
45
71
|
const ANSI = /\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g;
|
|
46
72
|
|
|
47
|
-
export type DelegateState = "queued" | "working" | "done" | "failed";
|
|
73
|
+
export type DelegateState = "queued" | "working" | "idle" | "paused" | "stopped" | "done" | "failed";
|
|
48
74
|
export type ToolStatus = "running" | "done" | "error";
|
|
49
75
|
/** One transcript row; `rev` changes whenever the entry does, so views can cache their rendering. */
|
|
50
76
|
export type TranscriptEntry =
|
|
@@ -58,6 +84,7 @@ export interface DelegateRun {
|
|
|
58
84
|
task: string;
|
|
59
85
|
role: string;
|
|
60
86
|
model?: string;
|
|
87
|
+
mode: DelegateExecutionMode;
|
|
61
88
|
state: DelegateState;
|
|
62
89
|
activity?: string;
|
|
63
90
|
tools: number;
|
|
@@ -71,6 +98,12 @@ export interface DelegateRun {
|
|
|
71
98
|
toolCounts: Record<string, number>;
|
|
72
99
|
filesRead: string[];
|
|
73
100
|
filesEdited: string[];
|
|
101
|
+
/** Worktree changes successfully copied back into the parent workspace. */
|
|
102
|
+
appliedFiles: string[];
|
|
103
|
+
pauses: number;
|
|
104
|
+
resumes: number;
|
|
105
|
+
/** Kept only when isolated changes could not safely be applied automatically. */
|
|
106
|
+
workspace?: string;
|
|
74
107
|
/** The subagent's own jar_todo checklist, mirrored from its tool results. */
|
|
75
108
|
todos: Todo[];
|
|
76
109
|
steered: number;
|
|
@@ -105,23 +138,43 @@ export function extensionFlags(argv = process.argv, cwd = process.cwd()): string
|
|
|
105
138
|
return flags;
|
|
106
139
|
}
|
|
107
140
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
141
|
+
export interface DelegateForkOptions {
|
|
142
|
+
source: string;
|
|
143
|
+
sessionDir: string;
|
|
144
|
+
tools?: readonly string[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** CLI arguments for one subagent: fresh ephemeral session or a real Pi session fork. */
|
|
148
|
+
export function delegateArgs(model: string | undefined, thinking: string | undefined, write: boolean, argv = process.argv,
|
|
149
|
+
fork?: DelegateForkOptions): string[] {
|
|
150
|
+
const session = fork ? ["--fork", fork.source, "--session-dir", fork.sessionDir] : ["--no-session"];
|
|
151
|
+
const extensions = extensionFlags(argv);
|
|
152
|
+
// Worktree cwd/session forks do not inherit project extension discovery from the parent.
|
|
153
|
+
if (fork) {
|
|
154
|
+
const ownExtension = fileURLToPath(new URL("../extensions/index.ts", import.meta.url));
|
|
155
|
+
if (!extensions.includes(ownExtension)) extensions.push("--extension", ownExtension);
|
|
156
|
+
}
|
|
157
|
+
const args = ["--mode", "rpc", ...session, ...extensions];
|
|
111
158
|
if (model) args.push("--model", model);
|
|
112
159
|
if (thinking) args.push("--thinking", thinking);
|
|
113
|
-
|
|
160
|
+
const tools = fork?.tools ?? (!write ? READ_ONLY_TOOLS : undefined);
|
|
161
|
+
if (tools) args.push("--tools", tools.join(","));
|
|
114
162
|
return args;
|
|
115
163
|
}
|
|
116
164
|
|
|
117
165
|
/** The subagent's prompt: its rules, how to report back, then the task. */
|
|
118
|
-
export function delegatePrompt(task: string, write: boolean): string {
|
|
119
|
-
const rules =
|
|
120
|
-
? "You may edit
|
|
121
|
-
:
|
|
166
|
+
export function delegatePrompt(task: string, write: boolean, mode: DelegateExecutionMode = write ? "direct" : "scout"): string {
|
|
167
|
+
const rules = mode === "worktree"
|
|
168
|
+
? "You inherit the parent conversation but work inside an isolated disposable Git worktree. You may edit only with the provided file tools; shell tools are intentionally unavailable. Stay strictly within the task. Your changes remain private across idle/pause/resume and are reconciled into the parent for /diff review only when the moderator stops you."
|
|
169
|
+
: mode === "fork"
|
|
170
|
+
? "You are a read-only fork of the parent conversation: use the inherited context, investigate, and report without modifying files."
|
|
171
|
+
: write
|
|
172
|
+
? "You may edit files. Stay strictly within the task; other subagents may be working in the same repository at the same time."
|
|
173
|
+
: "You are read-only: investigate and report, do not attempt to modify anything.";
|
|
122
174
|
return [
|
|
123
175
|
`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
|
|
176
|
+
"Track multi-step work with jar_todo (when available) so your progress is visible. The moderator may steer, pause or resume you; preserve scope across those controls.",
|
|
177
|
+
"Use jar_discuss when available for short cross-agent questions and answers. Keep discussion entries narrow; do not turn the shared paper into a transcript.",
|
|
125
178
|
"Finish with a self-contained report in these sections:",
|
|
126
179
|
"## Summary — the outcome in one to three sentences.",
|
|
127
180
|
"## Details — findings or changes, with file paths (and line numbers where useful).",
|
|
@@ -136,6 +189,28 @@ export function delegatePrompt(task: string, write: boolean): string {
|
|
|
136
189
|
const cleanBlock = (value: string, limit: number): string => value.slice(0, limit * 2)
|
|
137
190
|
.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
191
|
|
|
192
|
+
/** Whether `promise` settles within `ms`; the timer never keeps the process alive. */
|
|
193
|
+
const settlesWithin = (promise: Promise<unknown>, ms: number): Promise<boolean> => {
|
|
194
|
+
let timer: NodeJS.Timeout | undefined;
|
|
195
|
+
const timeout = new Promise<boolean>((resolve) => {
|
|
196
|
+
timer = setTimeout(() => resolve(false), ms);
|
|
197
|
+
timer.unref?.();
|
|
198
|
+
});
|
|
199
|
+
return Promise.race([promise.then(() => true, () => true), timeout]).finally(() => clearTimeout(timer));
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/** `promise`, or undefined as soon as `signal` aborts; the work itself continues in the background. */
|
|
203
|
+
const untilAborted = <T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T | undefined> => {
|
|
204
|
+
if (!signal) return promise;
|
|
205
|
+
if (signal.aborted) return Promise.resolve(undefined);
|
|
206
|
+
let onAbort = () => {};
|
|
207
|
+
const aborted = new Promise<undefined>((resolve) => {
|
|
208
|
+
onAbort = () => resolve(undefined);
|
|
209
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
210
|
+
});
|
|
211
|
+
return Promise.race([promise, aborted]).finally(() => signal.removeEventListener("abort", onAbort));
|
|
212
|
+
};
|
|
213
|
+
|
|
139
214
|
const textOf = (message: { content?: unknown }, limit = MAX_EVENT_TEXT): string => {
|
|
140
215
|
if (typeof message.content === "string") return message.content.slice(0, limit);
|
|
141
216
|
if (!Array.isArray(message.content)) return "";
|
|
@@ -229,47 +304,116 @@ export interface DelegateHooks {
|
|
|
229
304
|
/** A successful edit/write by the child, with the path it gave. */
|
|
230
305
|
edited?(path: string): void;
|
|
231
306
|
}
|
|
232
|
-
export interface DelegateHandle {
|
|
307
|
+
export interface DelegateHandle {
|
|
308
|
+
/** First point where the child is idle after its initial task. */
|
|
309
|
+
firstSettled: Promise<void>;
|
|
310
|
+
/** Process lifetime; persistent agents stay alive across many settled turns. */
|
|
311
|
+
closed: Promise<void>;
|
|
312
|
+
steer(text: string): boolean;
|
|
313
|
+
pause(): Promise<boolean>;
|
|
314
|
+
resume(text?: string): boolean;
|
|
315
|
+
ask(text: string): Promise<string | undefined>;
|
|
316
|
+
shutdown(): Promise<void>;
|
|
317
|
+
alive(): boolean;
|
|
318
|
+
}
|
|
233
319
|
|
|
234
320
|
/**
|
|
235
|
-
* Run one child Pi in RPC mode
|
|
236
|
-
*
|
|
321
|
+
* Run one child Pi in RPC mode. Persistent children remain alive after agent_settled so the
|
|
322
|
+
* moderator can peek, steer, pause, resume and ask them questions without rebuilding context.
|
|
237
323
|
*/
|
|
238
324
|
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 {
|
|
325
|
+
update: () => void, spawnProcess: Spawn = spawn as Spawn, hooks: DelegateHooks = {}, persistent = true): DelegateHandle {
|
|
240
326
|
let steer: (text: string) => boolean = () => false;
|
|
241
|
-
|
|
327
|
+
let pause: () => Promise<boolean> = async () => false;
|
|
328
|
+
let resume: (text?: string) => boolean = () => false;
|
|
329
|
+
let ask: (text: string) => Promise<string | undefined> = async () => undefined;
|
|
330
|
+
let shutdown: () => Promise<void> = async () => {};
|
|
331
|
+
let alive = false;
|
|
332
|
+
let resolveFirst!: () => void;
|
|
333
|
+
let firstResolved = false;
|
|
334
|
+
const firstSettled = new Promise<void>((resolve) => { resolveFirst = resolve; });
|
|
335
|
+
|
|
336
|
+
const closed = new Promise<void>((resolveClosed) => {
|
|
242
337
|
const invocation = piInvocation(args);
|
|
243
338
|
let child: ChildProcess;
|
|
244
339
|
try {
|
|
245
|
-
|
|
340
|
+
const env = { ...process.env };
|
|
341
|
+
for (const key of [CHILD_BASELINE_ENV, CHILD_WORKTREE_ENV, DISCUSSION_FILE_ENV, SUBAGENT_KEY_ENV, SUBAGENT_NAME_ENV]) delete env[key];
|
|
342
|
+
child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, stdio: ["pipe", "pipe", "pipe"], env: { ...env, ...hooks.env, [CHILD_ENV]: "1" } });
|
|
246
343
|
} catch (error) {
|
|
247
|
-
run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); run.endedAt = Date.now(); update();
|
|
344
|
+
run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); run.endedAt = Date.now(); update();
|
|
345
|
+
firstResolved = true; resolveFirst(); resolveClosed(); return;
|
|
248
346
|
}
|
|
347
|
+
|
|
348
|
+
alive = true;
|
|
249
349
|
run.state = "working";
|
|
250
350
|
run.startedAt = Date.now();
|
|
351
|
+
run.endedAt = undefined;
|
|
251
352
|
run.activity = "starting";
|
|
252
353
|
update();
|
|
354
|
+
|
|
253
355
|
let buffer = "";
|
|
254
|
-
// A JSON line past this is dropped whole rather than buffered without bound.
|
|
255
356
|
let overflow = false;
|
|
256
|
-
// Streamed text repaints at most every LIVE_REPAINT_MS; the trailing timer shows the last tokens.
|
|
257
357
|
let liveAt = 0;
|
|
258
358
|
let liveTimer: ReturnType<typeof setTimeout> | undefined;
|
|
259
359
|
let stderr = "";
|
|
260
360
|
let finished = false;
|
|
261
361
|
let settled = false;
|
|
362
|
+
let pauseRequested = false;
|
|
363
|
+
let closing = false;
|
|
364
|
+
let shutdownPromise: Promise<void> | undefined;
|
|
365
|
+
let askBusy = false;
|
|
366
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
367
|
+
let turnTimer: ReturnType<typeof setTimeout> | undefined;
|
|
368
|
+
let closeTimer: NodeJS.Timeout | undefined;
|
|
369
|
+
/** Last RPC event while working; the watchdog only fires after this much silence. */
|
|
370
|
+
let activityAt = Date.now();
|
|
371
|
+
const settleWaiters: Array<() => void> = [];
|
|
372
|
+
|
|
262
373
|
const send = (command: object): boolean => {
|
|
263
374
|
const stdin = child.stdin;
|
|
264
|
-
if (!stdin || stdin.destroyed || stdin.writableEnded) return false;
|
|
375
|
+
if (!alive || !stdin || stdin.destroyed || stdin.writableEnded) return false;
|
|
265
376
|
try { stdin.write(JSON.stringify(command) + "\n"); return true; } catch { return false; }
|
|
266
377
|
};
|
|
267
|
-
|
|
268
|
-
|
|
378
|
+
const clearTurnTimer = () => { if (turnTimer) clearTimeout(turnTimer); turnTimer = undefined; };
|
|
379
|
+
const hardStop = () => {
|
|
380
|
+
if (!alive) return;
|
|
381
|
+
child.kill("SIGTERM");
|
|
382
|
+
killTimer ??= setTimeout(() => { if (alive) child.kill("SIGKILL"); }, 3000);
|
|
383
|
+
killTimer.unref?.();
|
|
384
|
+
};
|
|
385
|
+
const armTurnTimer = (delay = IDLE_TIMEOUT_MS) => {
|
|
386
|
+
clearTurnTimer();
|
|
387
|
+
turnTimer = setTimeout(() => {
|
|
388
|
+
turnTimer = undefined;
|
|
389
|
+
const quiet = Date.now() - activityAt;
|
|
390
|
+
if (quiet < IDLE_TIMEOUT_MS) { armTurnTimer(IDLE_TIMEOUT_MS - quiet); return; }
|
|
391
|
+
hardStop();
|
|
392
|
+
finish("failed", `timed out: no activity for ${duration(IDLE_TIMEOUT_MS)}`);
|
|
393
|
+
}, delay);
|
|
394
|
+
turnTimer.unref?.();
|
|
395
|
+
};
|
|
396
|
+
/** High-frequency stream events repaint at most every LIVE_REPAINT_MS. */
|
|
397
|
+
const repaint = () => {
|
|
398
|
+
const now = Date.now();
|
|
399
|
+
if (now - liveAt >= LIVE_REPAINT_MS) {
|
|
400
|
+
liveAt = now;
|
|
401
|
+
if (liveTimer) { clearTimeout(liveTimer); liveTimer = undefined; }
|
|
402
|
+
update();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (liveTimer) return;
|
|
406
|
+
liveTimer = setTimeout(() => { liveTimer = undefined; liveAt = Date.now(); if (!finished) update(); }, LIVE_REPAINT_MS - (now - liveAt));
|
|
407
|
+
liveTimer.unref?.();
|
|
408
|
+
};
|
|
409
|
+
const resolveSettled = () => {
|
|
410
|
+
for (const waiter of settleWaiters.splice(0)) waiter();
|
|
411
|
+
};
|
|
412
|
+
const waitSettled = () => settled || finished ? Promise.resolve() : new Promise<void>((resolve) => settleWaiters.push(resolve));
|
|
269
413
|
const finish = (state: DelegateState, error?: string) => {
|
|
270
414
|
if (finished) return;
|
|
271
415
|
finished = true;
|
|
272
|
-
|
|
416
|
+
clearTurnTimer();
|
|
273
417
|
clearTimeout(liveTimer);
|
|
274
418
|
signal?.removeEventListener("abort", abort);
|
|
275
419
|
if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
|
|
@@ -279,29 +423,46 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
279
423
|
run.live = "";
|
|
280
424
|
for (const entry of run.transcript) if (entry.kind === "tool" && entry.status === "running") { entry.status = "error"; entry.endedAt = Date.now(); entry.rev++; }
|
|
281
425
|
run.endedAt = Date.now();
|
|
426
|
+
resolveSettled();
|
|
427
|
+
if (!firstResolved) { firstResolved = true; resolveFirst(); }
|
|
282
428
|
update();
|
|
283
|
-
resolveDone();
|
|
284
429
|
};
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const timer = setTimeout(() => { stop(); finish("failed", "timed out"); }, TIMEOUT_MS);
|
|
289
|
-
timer.unref?.();
|
|
290
|
-
if (signal?.aborted) { abort(); return; }
|
|
430
|
+
const abort = () => { hardStop(); finish("failed", signal?.reason === STOP_REASON ? "stopped" : "aborted"); };
|
|
431
|
+
|
|
432
|
+
child.stdin?.on("error", (error) => { if (!finished) stderr = (stderr + "\n" + error.message).slice(-4000); });
|
|
291
433
|
signal?.addEventListener("abort", abort, { once: true });
|
|
434
|
+
armTurnTimer();
|
|
435
|
+
|
|
436
|
+
const beginPrompt = (message: string, pauseAfter = false): boolean => {
|
|
437
|
+
const clean = cleanBlock(message, MAX_RESUME_CHARS);
|
|
438
|
+
if (finished || closing || !alive || !settled || !clean) return false;
|
|
439
|
+
if (!send({ id: "prompt-" + (run.resumes + 1), type: "prompt", message: clean })) return false;
|
|
440
|
+
settled = false;
|
|
441
|
+
run.error = undefined;
|
|
442
|
+
run.output = "";
|
|
443
|
+
pauseRequested = pauseAfter;
|
|
444
|
+
run.state = "working";
|
|
445
|
+
run.resumes++;
|
|
446
|
+
run.endedAt = undefined;
|
|
447
|
+
run.activity = "resuming";
|
|
448
|
+
activityAt = Date.now();
|
|
449
|
+
armTurnTimer();
|
|
450
|
+
update();
|
|
451
|
+
return true;
|
|
452
|
+
};
|
|
453
|
+
|
|
292
454
|
const running = new Map<string, { entry: ToolEntry; path?: string }>();
|
|
293
455
|
const line = (raw: string) => {
|
|
294
|
-
// Output that arrives after an abort or timeout must not revive a finished run's activity.
|
|
295
456
|
if (finished || !raw.trim()) return;
|
|
296
457
|
let parsed: unknown;
|
|
297
458
|
try { parsed = JSON.parse(raw); } catch { return; }
|
|
298
459
|
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
460
|
const event = parsed as ChildEvent;
|
|
461
|
+
activityAt = Date.now();
|
|
301
462
|
switch (event.type) {
|
|
302
463
|
case "response":
|
|
303
464
|
if (event.success !== false) return;
|
|
304
|
-
if (event.command === "prompt") {
|
|
465
|
+
if (event.command === "prompt") { hardStop(); finish("failed", cleanText(String(event.error ?? "prompt rejected"), 300)); return; }
|
|
305
466
|
pushEntry(run, { kind: "note", rev: 0, text: `${cleanText(String(event.command ?? "command"), 24)} rejected: ${cleanText(String(event.error ?? ""), 200)}` });
|
|
306
467
|
update();
|
|
307
468
|
return;
|
|
@@ -313,12 +474,7 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
313
474
|
if (delta?.type === "text_delta" && typeof delta.delta === "string") {
|
|
314
475
|
run.live = (run.live + delta.delta).slice(-MAX_LIVE_CHARS);
|
|
315
476
|
run.activity = "writing";
|
|
316
|
-
|
|
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
|
-
}
|
|
477
|
+
repaint();
|
|
322
478
|
} else if ((delta?.type === "thinking_start" || delta?.type === "thinking_delta") && run.activity !== "thinking") {
|
|
323
479
|
run.activity = "thinking";
|
|
324
480
|
update();
|
|
@@ -345,7 +501,7 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
345
501
|
if (!call || !event.partialResult) return;
|
|
346
502
|
call.entry.output = cleanBlock(textOf(event.partialResult, MAX_ENTRY_TEXT * 2), MAX_ENTRY_TEXT);
|
|
347
503
|
call.entry.rev++;
|
|
348
|
-
|
|
504
|
+
repaint();
|
|
349
505
|
return;
|
|
350
506
|
}
|
|
351
507
|
case "tool_execution_end": {
|
|
@@ -374,22 +530,40 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
374
530
|
run.cost += Number(event.message.usage?.cost?.total) || 0;
|
|
375
531
|
const text = textOf(event.message).trim();
|
|
376
532
|
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"
|
|
533
|
+
if (event.message.stopReason === "error") run.error = cleanText(String(event.message.errorMessage ?? "assistant turn failed"), 200);
|
|
534
|
+
if (event.message.stopReason === "aborted" && !pauseRequested && !closing) run.error = "assistant turn aborted";
|
|
378
535
|
run.live = "";
|
|
379
536
|
run.activity = "thinking";
|
|
380
537
|
update();
|
|
381
538
|
return;
|
|
382
539
|
}
|
|
383
|
-
case "agent_settled":
|
|
384
|
-
// Nothing more will run on its own: close stdin so Pi shuts down in order.
|
|
540
|
+
case "agent_settled": {
|
|
385
541
|
settled = true;
|
|
386
|
-
|
|
387
|
-
|
|
542
|
+
clearTurnTimer();
|
|
543
|
+
for (const call of running.values()) {
|
|
544
|
+
call.entry.status = "error";
|
|
545
|
+
call.entry.endedAt = Date.now();
|
|
546
|
+
call.entry.rev++;
|
|
547
|
+
}
|
|
548
|
+
running.clear();
|
|
549
|
+
run.live = "";
|
|
550
|
+
run.activity = undefined;
|
|
551
|
+
run.endedAt = Date.now();
|
|
552
|
+
run.state = run.error ? "failed" : pauseRequested ? "paused" : "idle";
|
|
553
|
+
pauseRequested = false;
|
|
554
|
+
if (!firstResolved && !run.error) {
|
|
555
|
+
firstResolved = true;
|
|
556
|
+
resolveFirst();
|
|
557
|
+
}
|
|
558
|
+
resolveSettled();
|
|
388
559
|
update();
|
|
560
|
+
if (run.error) { hardStop(); finish("failed", run.error); }
|
|
561
|
+
else if (!persistent && child.stdin && !child.stdin.writableEnded) child.stdin.end();
|
|
389
562
|
return;
|
|
563
|
+
}
|
|
390
564
|
}
|
|
391
565
|
};
|
|
392
|
-
|
|
566
|
+
|
|
393
567
|
child.stdout?.setEncoding?.("utf8");
|
|
394
568
|
child.stdout?.on("data", (chunk) => {
|
|
395
569
|
const text = String(chunk);
|
|
@@ -406,28 +580,167 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
406
580
|
if (buffer.length > MAX_LINE_CHARS) { buffer = ""; overflow = true; }
|
|
407
581
|
});
|
|
408
582
|
child.stderr?.on("data", (chunk) => { stderr = (stderr + String(chunk)).slice(-4000); });
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
if (
|
|
583
|
+
let closedOnce = false;
|
|
584
|
+
const exited = (code: number | null) => {
|
|
585
|
+
if (closedOnce) return;
|
|
586
|
+
closedOnce = true;
|
|
587
|
+
alive = false;
|
|
588
|
+
clearTimeout(killTimer);
|
|
589
|
+
clearTimeout(closeTimer);
|
|
590
|
+
if (buffer && !overflow) { line(buffer); buffer = ""; }
|
|
412
591
|
if (code === 0 && settled && !run.error) finish("done");
|
|
413
592
|
else finish("failed", run.error ?? (cleanText(stderr, 300) || (settled ? `exited ${code}` : `exited ${code} before finishing`)));
|
|
593
|
+
if (!firstResolved) { firstResolved = true; resolveFirst(); }
|
|
594
|
+
resolveClosed();
|
|
595
|
+
};
|
|
596
|
+
// A grandchild that inherited stdout/stderr keeps them open after the child is gone, and 'close'
|
|
597
|
+
// then never fires; stop waiting after a short drain so stop/shutdown can never hang on it.
|
|
598
|
+
const drainThenClose = (code: number | null) => {
|
|
599
|
+
if (closedOnce || closeTimer) return;
|
|
600
|
+
closeTimer = setTimeout(() => {
|
|
601
|
+
child.stdout?.destroy?.();
|
|
602
|
+
child.stderr?.destroy?.();
|
|
603
|
+
exited(code);
|
|
604
|
+
}, CLOSE_GRACE_MS);
|
|
605
|
+
closeTimer.unref?.();
|
|
606
|
+
};
|
|
607
|
+
child.on("error", (error) => {
|
|
608
|
+
hardStop();
|
|
609
|
+
finish("failed", error.message);
|
|
610
|
+
// A process that never spawned has no exit event to wait for.
|
|
611
|
+
if (child.pid === undefined) { alive = false; drainThenClose(null); }
|
|
414
612
|
});
|
|
613
|
+
// Exit retires control immediately, but finalization waits for close so stdio drains first.
|
|
614
|
+
child.once("exit", (code) => {
|
|
615
|
+
alive = false;
|
|
616
|
+
clearTurnTimer();
|
|
617
|
+
clearTimeout(killTimer);
|
|
618
|
+
if (!finished) {
|
|
619
|
+
run.state = code === 0 && settled && !run.error ? "done" : "failed";
|
|
620
|
+
if (run.state === "failed") run.error ??= `exited ${code}`;
|
|
621
|
+
run.activity = undefined;
|
|
622
|
+
resolveSettled();
|
|
623
|
+
update();
|
|
624
|
+
}
|
|
625
|
+
drainThenClose(code);
|
|
626
|
+
});
|
|
627
|
+
child.once("close", exited);
|
|
628
|
+
|
|
415
629
|
steer = (text) => {
|
|
416
630
|
const message = cleanBlock(text, MAX_STEER_CHARS);
|
|
417
|
-
if (finished ||
|
|
631
|
+
if (finished || closing || !message || run.state !== "working" || !send({ type: "steer", message })) return false;
|
|
418
632
|
pushEntry(run, { kind: "steer", rev: 0, text: message });
|
|
419
633
|
run.steered++;
|
|
420
634
|
update();
|
|
421
635
|
return true;
|
|
422
636
|
};
|
|
423
|
-
|
|
637
|
+
pause = async () => {
|
|
638
|
+
if (finished || closing || !alive) return false;
|
|
639
|
+
if (run.state === "paused") return true;
|
|
640
|
+
if (run.state === "idle") { run.state = "paused"; run.pauses++; update(); return true; }
|
|
641
|
+
if (run.state !== "working") return false;
|
|
642
|
+
pauseRequested = true;
|
|
643
|
+
run.pauses++;
|
|
644
|
+
run.activity = "pausing";
|
|
645
|
+
const waiting = waitSettled();
|
|
646
|
+
send({ type: "clear_queue" });
|
|
647
|
+
if (!send({ type: "abort" })) {
|
|
648
|
+
pauseRequested = false;
|
|
649
|
+
run.pauses = Math.max(0, run.pauses - 1);
|
|
650
|
+
run.activity = "thinking";
|
|
651
|
+
update();
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
update();
|
|
655
|
+
// A child that ignores abort must not hang the moderator; it still pauses when it settles.
|
|
656
|
+
if (!await settlesWithin(waiting, PAUSE_GRACE_MS)) return false;
|
|
657
|
+
return (run.state as DelegateState) === "paused";
|
|
658
|
+
};
|
|
659
|
+
resume = (text) => {
|
|
660
|
+
if (run.state !== "paused" && run.state !== "idle") return false;
|
|
661
|
+
const message = text?.trim() || "Continue the assigned task from where you stopped. Review your current checklist and remaining work first.";
|
|
662
|
+
return beginPrompt(message);
|
|
663
|
+
};
|
|
664
|
+
ask = async (text) => {
|
|
665
|
+
const question = cleanBlock(text, MAX_STEER_CHARS);
|
|
666
|
+
if (!question || finished || closing || askBusy) return undefined;
|
|
667
|
+
askBusy = true;
|
|
668
|
+
const wasPaused = run.state === "paused";
|
|
669
|
+
try {
|
|
670
|
+
// Cross a settled boundary so the active task's report cannot answer the question.
|
|
671
|
+
if (run.state === "working" && !await pause()) return undefined;
|
|
672
|
+
const instruction = "Moderator BTW: answer only this question in one compact paragraph. Preserve your assigned task scope for the next resume. Question: " + question;
|
|
673
|
+
if (!beginPrompt(instruction, wasPaused)) return undefined;
|
|
674
|
+
const answered = await settlesWithin(waitSettled(), 2 * 60_000);
|
|
675
|
+
if (!answered) { void pause(); return undefined; }
|
|
676
|
+
return !finished && !run.error ? run.output || undefined : undefined;
|
|
677
|
+
} finally { askBusy = false; }
|
|
678
|
+
};
|
|
679
|
+
shutdown = () => {
|
|
680
|
+
if (shutdownPromise) return shutdownPromise;
|
|
681
|
+
closing = true;
|
|
682
|
+
shutdownPromise = Promise.resolve().then(async () => {
|
|
683
|
+
if (finished) { await closed; return; }
|
|
684
|
+
if (run.state === "working") {
|
|
685
|
+
const waiting = waitSettled();
|
|
686
|
+
send({ type: "clear_queue" });
|
|
687
|
+
if (send({ type: "abort" })) {
|
|
688
|
+
if (!await settlesWithin(waiting, 3000) && !finished) hardStop();
|
|
689
|
+
} else hardStop();
|
|
690
|
+
}
|
|
691
|
+
if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
|
|
692
|
+
const killer = setTimeout(() => { if (child.exitCode === null) hardStop(); }, 3000);
|
|
693
|
+
killer.unref?.();
|
|
694
|
+
await closed;
|
|
695
|
+
clearTimeout(killer);
|
|
696
|
+
});
|
|
697
|
+
return shutdownPromise;
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
if (signal?.aborted) { abort(); return; }
|
|
701
|
+
if (!send({ id: "prompt", type: "prompt", message: prompt })) { hardStop(); finish("failed", "could not send the task to the subagent"); }
|
|
424
702
|
});
|
|
425
|
-
return {
|
|
703
|
+
return {
|
|
704
|
+
firstSettled,
|
|
705
|
+
closed,
|
|
706
|
+
steer: (text) => steer(text),
|
|
707
|
+
pause: () => pause(),
|
|
708
|
+
resume: (text) => resume(text),
|
|
709
|
+
ask: (text) => ask(text),
|
|
710
|
+
shutdown: () => shutdown(),
|
|
711
|
+
alive: () => alive
|
|
712
|
+
};
|
|
426
713
|
}
|
|
427
714
|
|
|
428
|
-
const
|
|
715
|
+
const isRunning = (state: DelegateState) => state === "queued" || state === "working";
|
|
716
|
+
const isRetained = (state: DelegateState) => isRunning(state) || state === "idle" || state === "paused";
|
|
429
717
|
|
|
430
|
-
export interface
|
|
718
|
+
export interface SubagentStopReport {
|
|
719
|
+
key: string;
|
|
720
|
+
name: string;
|
|
721
|
+
state: DelegateState;
|
|
722
|
+
task: string;
|
|
723
|
+
workspace?: string;
|
|
724
|
+
changed: string[];
|
|
725
|
+
applied: string[];
|
|
726
|
+
remaining: string[];
|
|
727
|
+
last: string;
|
|
728
|
+
error?: string;
|
|
729
|
+
}
|
|
730
|
+
export interface SubagentRecord {
|
|
731
|
+
key: string;
|
|
732
|
+
batch: number;
|
|
733
|
+
run: DelegateRun;
|
|
734
|
+
stop(): Promise<SubagentStopReport>;
|
|
735
|
+
pause(): Promise<boolean>;
|
|
736
|
+
resume(text?: string): boolean;
|
|
737
|
+
steer(text: string): boolean;
|
|
738
|
+
ask(text: string): Promise<string | undefined>;
|
|
739
|
+
changed(): string[];
|
|
740
|
+
discard(): void;
|
|
741
|
+
alive?(): boolean;
|
|
742
|
+
closed?(): Promise<void>;
|
|
743
|
+
}
|
|
431
744
|
|
|
432
745
|
/**
|
|
433
746
|
* Live subagents for the activity view. Tool details are saved in the session, so they stay small;
|
|
@@ -437,12 +750,22 @@ export class DelegateRegistry {
|
|
|
437
750
|
private entries = new Map<string, { record: SubagentRecord; finished?: number }>();
|
|
438
751
|
private listeners = new Set<() => void>();
|
|
439
752
|
private sequence = 0;
|
|
753
|
+
private reservations = 0;
|
|
754
|
+
private draining = new Set<SubagentRecord>();
|
|
755
|
+
|
|
756
|
+
/** Synchronous reservation prevents concurrent/reentrant batches from oversubscribing. */
|
|
757
|
+
reserve(count: number, maximum: number): (() => void) | undefined {
|
|
758
|
+
if (count < 1 || this.retained() + this.reservations + count > maximum) return undefined;
|
|
759
|
+
this.reservations += count;
|
|
760
|
+
let released = false;
|
|
761
|
+
return () => { if (!released) { released = true; this.reservations -= count; } };
|
|
762
|
+
}
|
|
440
763
|
|
|
441
764
|
subscribe(listener: () => void): () => void {
|
|
442
765
|
this.listeners.add(listener);
|
|
443
766
|
return () => { this.listeners.delete(listener); };
|
|
444
767
|
}
|
|
445
|
-
/**
|
|
768
|
+
/** Retained agents first (oldest first), then retired ones newest first. */
|
|
446
769
|
records(): SubagentRecord[] {
|
|
447
770
|
this.sweep();
|
|
448
771
|
const live: SubagentRecord[] = [];
|
|
@@ -454,53 +777,87 @@ export class DelegateRegistry {
|
|
|
454
777
|
return [...live, ...finished.map((entry) => entry.record)];
|
|
455
778
|
}
|
|
456
779
|
get(key: string): SubagentRecord | undefined { return this.entries.get(key)?.record; }
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
record.
|
|
780
|
+
resolve(ref: string): SubagentRecord | undefined {
|
|
781
|
+
const exact = this.get(ref);
|
|
782
|
+
if (exact) return exact;
|
|
783
|
+
const name = cleanText(ref, 48).toLowerCase();
|
|
784
|
+
const matches = this.records().filter((record) => record.run.name.toLowerCase() === name);
|
|
785
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
786
|
+
}
|
|
787
|
+
async stop(key: string): Promise<SubagentStopReport | undefined> {
|
|
788
|
+
const record = this.resolve(key);
|
|
789
|
+
if (!record || record.run.state === "stopped" || (record.run.state === "done" && !record.run.workspace)) return undefined;
|
|
790
|
+
const report = await record.stop();
|
|
791
|
+
this.notify();
|
|
792
|
+
return report;
|
|
793
|
+
}
|
|
794
|
+
async pause(key: string): Promise<boolean> {
|
|
795
|
+
const record = this.resolve(key);
|
|
796
|
+
if (!record || !isRetained(record.run.state)) return false;
|
|
797
|
+
const paused = await record.pause();
|
|
462
798
|
this.notify();
|
|
463
|
-
return
|
|
799
|
+
return paused;
|
|
800
|
+
}
|
|
801
|
+
resume(key: string, text?: string): boolean {
|
|
802
|
+
const record = this.resolve(key);
|
|
803
|
+
if (!record || !isRetained(record.run.state)) return false;
|
|
804
|
+
const resumed = record.resume(text);
|
|
805
|
+
if (resumed) this.notify();
|
|
806
|
+
return resumed;
|
|
464
807
|
}
|
|
465
|
-
/** Send a steering message to one working run; false when it cannot take one. */
|
|
466
808
|
steer(key: string, text: string): boolean {
|
|
467
|
-
const record = this.
|
|
809
|
+
const record = this.resolve(key);
|
|
468
810
|
return !!record && record.run.state === "working" && record.steer(text);
|
|
469
811
|
}
|
|
812
|
+
async ask(key: string, text: string): Promise<string | undefined> {
|
|
813
|
+
const record = this.resolve(key);
|
|
814
|
+
if (!record || !isRetained(record.run.state)) return undefined;
|
|
815
|
+
return record.ask(text);
|
|
816
|
+
}
|
|
470
817
|
running(): number {
|
|
471
818
|
let count = 0;
|
|
472
|
-
for (const { record } of this.entries.values()) if (
|
|
819
|
+
for (const { record } of this.entries.values()) if (isRunning(record.run.state)) count++;
|
|
473
820
|
return count;
|
|
474
821
|
}
|
|
475
|
-
|
|
822
|
+
retained(): number {
|
|
823
|
+
let count = this.draining.size;
|
|
824
|
+
for (const { record } of this.entries.values()) if (isRetained(record.run.state) || record.alive?.()) count++;
|
|
825
|
+
return count;
|
|
826
|
+
}
|
|
827
|
+
/** Session changes discard retained workers and their private workspaces; they never auto-apply here. */
|
|
476
828
|
clear(): void {
|
|
477
|
-
const
|
|
829
|
+
const records = [...this.entries.values()].map(({ record }) => record);
|
|
478
830
|
this.entries.clear();
|
|
479
|
-
for (const
|
|
831
|
+
for (const record of records) {
|
|
832
|
+
if (record.closed && (isRetained(record.run.state) || record.alive?.())) {
|
|
833
|
+
this.draining.add(record);
|
|
834
|
+
void record.closed().finally(() => { this.draining.delete(record); this.notify(); });
|
|
835
|
+
}
|
|
836
|
+
try { record.discard(); } catch { /* best effort */ }
|
|
837
|
+
}
|
|
480
838
|
this.notify();
|
|
481
839
|
}
|
|
482
|
-
/** jar_delegate's side: list new runs. */
|
|
483
840
|
add(...records: SubagentRecord[]): void {
|
|
484
841
|
for (const record of records) this.entries.set(record.key, { record });
|
|
485
842
|
this.notify();
|
|
486
843
|
}
|
|
487
|
-
/** jar_delegate's side: a run changed. */
|
|
488
844
|
notify(): void {
|
|
489
845
|
this.sweep();
|
|
490
846
|
for (const listener of this.listeners) {
|
|
491
847
|
try { listener(); } catch { /* views are decoration; a broken one must not break the run */ }
|
|
492
848
|
}
|
|
493
849
|
}
|
|
494
|
-
/** Stamp runs in the order they finish and keep only the newest MAX_FINISHED of them. */
|
|
495
850
|
private sweep(): void {
|
|
496
851
|
let finished = 0;
|
|
497
852
|
for (const entry of this.entries.values()) {
|
|
498
|
-
if (
|
|
853
|
+
if (isRetained(entry.record.run.state) || entry.record.alive?.()) { entry.finished = undefined; continue; }
|
|
499
854
|
entry.finished ??= ++this.sequence;
|
|
500
855
|
finished++;
|
|
501
856
|
}
|
|
502
857
|
if (finished <= MAX_FINISHED) return;
|
|
503
|
-
|
|
858
|
+
// Conflicted/failed workspaces are recovery state, not disposable transcript history.
|
|
859
|
+
const oldest = [...this.entries].filter(([, entry]) => entry.finished !== undefined && !entry.record.run.workspace)
|
|
860
|
+
.sort((a, b) => a[1].finished! - b[1].finished!);
|
|
504
861
|
for (const [key] of oldest.slice(0, finished - MAX_FINISHED)) this.entries.delete(key);
|
|
505
862
|
}
|
|
506
863
|
}
|
|
@@ -520,7 +877,10 @@ export function runReport(run: DelegateRun, now = Date.now()): string {
|
|
|
520
877
|
run.cost ? `$${run.cost.toFixed(3)}` : "", run.steered ? `steered ${run.steered}×` : ""
|
|
521
878
|
].filter(Boolean).join(" · ");
|
|
522
879
|
const lines = [`## [${run.index}] ${run.name} (${run.role}${run.model ? " · " + run.model : ""}) — ${run.state}${run.error ? ": " + run.error : ""}`, `- ${facts}`];
|
|
880
|
+
if (run.mode === "fork" || run.mode === "worktree") lines.push(`- mode: ${run.mode}`);
|
|
523
881
|
if (run.filesEdited.length) lines.push(`- files edited: ${fileList(run.filesEdited, 20)}`);
|
|
882
|
+
if (run.appliedFiles.length) lines.push(`- applied to parent: ${fileList(run.appliedFiles, 20)}`);
|
|
883
|
+
if (run.workspace) lines.push(`- workspace: ${run.workspace}`);
|
|
524
884
|
if (run.filesRead.length) lines.push(`- files read: ${fileList(run.filesRead, 12)}`);
|
|
525
885
|
if (run.todos.length) {
|
|
526
886
|
const leaves = leafTodos(run.todos);
|
|
@@ -532,69 +892,225 @@ export function runReport(run: DelegateRun, now = Date.now()): string {
|
|
|
532
892
|
return lines.join("\n");
|
|
533
893
|
}
|
|
534
894
|
|
|
895
|
+
const DelegateModeSchema = Type.Union([Type.Literal("scout"), Type.Literal("fork"), Type.Literal("worktree")]);
|
|
535
896
|
const Parameters = Type.Object({
|
|
536
897
|
tasks: Type.Array(Type.Object({
|
|
537
|
-
task: Type.String({ description: "
|
|
898
|
+
task: Type.String({ description: "Task instruction. scout sees only this task; fork/worktree also inherit the parent's active conversation branch." }),
|
|
538
899
|
name: Type.Optional(Type.String({ description: "Short label, e.g. \"auth scout\"." })),
|
|
539
|
-
role: Type.Optional(Type.String({ description: "pi-jar role
|
|
540
|
-
|
|
541
|
-
|
|
900
|
+
role: Type.Optional(Type.String({ description: "pi-jar role override. Defaults by mode: scout→scout, fork→reviewer, worktree→worker; then role fallbacks/current model." })),
|
|
901
|
+
mode: Type.Optional(DelegateModeSchema)
|
|
902
|
+
}), { minItems: 1, maxItems: 16 }),
|
|
903
|
+
mode: Type.Optional(DelegateModeSchema),
|
|
904
|
+
write: Type.Optional(Type.Boolean({ description: "Deprecated compatibility switch. true keeps the old shared-workspace editing mode; prefer mode=\"worktree\"." }))
|
|
542
905
|
});
|
|
543
906
|
|
|
544
|
-
const glyph = (state: DelegateState) => state === "done" ? "✔" : state === "failed" ? "✖" : state === "working" ? "●" : "○";
|
|
907
|
+
const glyph = (state: DelegateState) => state === "done" ? "✔" : state === "failed" ? "✖" : state === "stopped" ? "■" : state === "working" ? "●" : "○";
|
|
545
908
|
const color = (state: DelegateState) => state === "done" ? "success" : state === "failed" ? "error" : state === "working" ? "accent" : "dim";
|
|
546
909
|
type RunDetails = Omit<DelegateRun, "transcript" | "live">;
|
|
547
910
|
|
|
911
|
+
/** Plain orchestration snapshots. id/key are the stable registry key. */
|
|
912
|
+
export interface SubagentReport {
|
|
913
|
+
id: string;
|
|
914
|
+
key: string;
|
|
915
|
+
name: string;
|
|
916
|
+
task: string;
|
|
917
|
+
mode: DelegateExecutionMode;
|
|
918
|
+
state: DelegateState;
|
|
919
|
+
output: string;
|
|
920
|
+
error?: string;
|
|
921
|
+
}
|
|
922
|
+
export interface DelegateController {
|
|
923
|
+
list(): SubagentReport[];
|
|
924
|
+
spawnScout(task: string, signal?: AbortSignal): Promise<SubagentReport>;
|
|
925
|
+
resumeScout(id: string, prompt: string, signal?: AbortSignal): Promise<SubagentReport>;
|
|
926
|
+
/** Retire an agent the orchestration spawned for itself, releasing its pool slot. */
|
|
927
|
+
stop(id: string): Promise<void>;
|
|
928
|
+
}
|
|
929
|
+
|
|
548
930
|
export interface DelegateOptions {
|
|
931
|
+
/** Allowed sizes: 2, 4, 6, 8, 16; defaults to 4. Read on every launch. */
|
|
932
|
+
getMaxSubagents?: () => number;
|
|
933
|
+
/** Shares the delegate lifecycle and capacity with scout orchestration. */
|
|
934
|
+
onController?: (controller: DelegateController) => void;
|
|
549
935
|
spawnProcess?: Spawn;
|
|
550
|
-
/** The parent's change tracker:
|
|
936
|
+
/** The parent's change tracker: finalized worktree edits join /diff. */
|
|
551
937
|
changes?: () => ChangeTracker | undefined;
|
|
552
938
|
/** Called after a subagent edit was added to the tracker. */
|
|
553
939
|
changed?: () => void;
|
|
940
|
+
/** Session-scoped shared discussion paper. */
|
|
941
|
+
discussionFile?: () => string | undefined;
|
|
554
942
|
}
|
|
555
943
|
|
|
556
|
-
|
|
944
|
+
const roleForMode = (mode: DelegateExecutionMode): string =>
|
|
945
|
+
mode === "scout" ? "scout" : mode === "fork" ? "reviewer" : mode === "worktree" ? "worker" : "task";
|
|
946
|
+
|
|
947
|
+
const remainingTasks = (run: DelegateRun): string[] => {
|
|
948
|
+
const leaves = leafTodos(run.todos);
|
|
949
|
+
return leaves.filter((todo) => todo.status !== "completed").map((todo) => todo.title).slice(0, 20);
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
const controlReport = (report: SubagentStopReport): string => [
|
|
953
|
+
`${report.key} · ${report.name} · ${report.state}`,
|
|
954
|
+
`task: ${report.task}`,
|
|
955
|
+
`workspace: ${report.workspace ?? "none"}`,
|
|
956
|
+
`changed: ${report.changed.length ? report.changed.join(", ") : "none"}`,
|
|
957
|
+
`applied: ${report.applied.length ? report.applied.join(", ") : "none"}`,
|
|
958
|
+
`remaining: ${report.remaining.length ? report.remaining.join("; ") : "none explicitly tracked"}`,
|
|
959
|
+
report.error ? `error: ${report.error}` : "",
|
|
960
|
+
report.last ? `last: ${cleanBlock(report.last, 1200)}` : ""
|
|
961
|
+
].filter(Boolean).join("\n");
|
|
962
|
+
|
|
963
|
+
/** Peek is task progress only, read from memory: no git, transcript or report reads. */
|
|
964
|
+
const peekRecord = (record: SubagentRecord): string => {
|
|
965
|
+
const run = record.run;
|
|
966
|
+
const leaves = leafTodos(run.todos);
|
|
967
|
+
const open = leaves.filter((todo) => todo.status !== "completed");
|
|
968
|
+
const current = open.find((todo) => todo.status === "in_progress");
|
|
969
|
+
const next = open.filter((todo) => todo !== current).slice(0, 3).map((todo) => cleanText(todo.title, 80));
|
|
970
|
+
const more = open.length - (current ? 1 : 0) - next.length;
|
|
971
|
+
return [
|
|
972
|
+
`${record.key} · ${run.name} · ${run.state}`,
|
|
973
|
+
`progress: ${leaves.length ? `${leaves.length - open.length}/${leaves.length} tasks` : "no checklist"}`
|
|
974
|
+
+ (current ? ` · now: ${cleanText(current.title, 80)}` : "") + (run.activity ? ` · ${cleanText(run.activity, 60)}` : ""),
|
|
975
|
+
next.length ? `next: ${next.join("; ")}${more > 0 ? ` (+${more} more)` : ""}` : ""
|
|
976
|
+
].filter(Boolean).join("\n");
|
|
977
|
+
};
|
|
978
|
+
/** The wake-up after a resumed turn: its progress plus the bounded report the turn produced. */
|
|
979
|
+
const settledRecord = (record: SubagentRecord): string =>
|
|
980
|
+
peekRecord(record) + (record.run.output ? `\nlast: ${cleanBlock(record.run.output, 1200)}` : "");
|
|
981
|
+
|
|
982
|
+
/** jar_delegate plus jar_subagent: the parent remains a moderator while retained workers do the work. */
|
|
557
983
|
export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, registry: DelegateRegistry, options: DelegateOptions = {}): void {
|
|
558
984
|
if (process.env[CHILD_ENV] || typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool !== "function") return;
|
|
559
985
|
let batch = 0;
|
|
560
|
-
|
|
986
|
+
let controllerContext: ExtensionContext | undefined;
|
|
987
|
+
const maxSubagents = () => {
|
|
988
|
+
const value = options.getMaxSubagents?.() ?? MAX_DELEGATES;
|
|
989
|
+
return SUBAGENT_LIMIT_CHOICES.includes(value as never) ? value : MAX_DELEGATES;
|
|
990
|
+
};
|
|
991
|
+
pi.on?.("session_start", (_event, ctx) => { controllerContext = ctx; });
|
|
992
|
+
const roleCandidates = (role: string) => {
|
|
993
|
+
const manager = roles as ModelRoleManager & { resolveCandidates?: (name: string) => ReturnType<ModelRoleManager["resolveCandidates"]> };
|
|
994
|
+
if (typeof manager.resolveCandidates === "function") return manager.resolveCandidates(role);
|
|
995
|
+
const resolved = roles.resolve(role);
|
|
996
|
+
return resolved ? [resolved] : [];
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
pi.on?.("before_agent_start", (_event, ctx) => {
|
|
1000
|
+
controllerContext = ctx;
|
|
1001
|
+
const retained = registry.records().filter((record) => isRetained(record.run.state));
|
|
1002
|
+
if (!retained.length) return;
|
|
1003
|
+
const fleet = retained.map((record) =>
|
|
1004
|
+
`- ${record.key} · ${record.run.name} · ${record.run.state} · ${record.run.role}: ${cleanText(record.run.task, 140)}`).join("\n");
|
|
1005
|
+
return { message: { customType: "pi-jar.moderator-context", display: false, content: [
|
|
1006
|
+
"[PI-JAR MODERATOR MODE]",
|
|
1007
|
+
"You are the coordinator while retained subagents do delegated work. Decompose and route work, steer only to correct direction, use ask for terse BTW questions, and use jar_discuss for structured cross-agent Q/A.",
|
|
1008
|
+
"A resumed agent wakes you with a pi-jar.subagent message when its turn ends: do not poll jar_subagent peek while you wait; end your turn or do other work.",
|
|
1009
|
+
"Do not duplicate work already owned by a retained subagent. Stop completed workers to obtain their handoff and reconcile worktree changes into /diff. Synthesize the final answer from their reports and evidence.",
|
|
1010
|
+
"Retained fleet:",
|
|
1011
|
+
fleet
|
|
1012
|
+
].join("\n") } };
|
|
1013
|
+
});
|
|
1014
|
+
// Context rewrites apply to one LLM call only, so the stale copies must be pruned on every call.
|
|
1015
|
+
pi.on?.("context", (event) => {
|
|
1016
|
+
const active = registry.retained() > 0;
|
|
1017
|
+
let latest = -1;
|
|
1018
|
+
let prune = false;
|
|
1019
|
+
for (let index = event.messages.length - 1; index >= 0; index--) {
|
|
1020
|
+
if ((event.messages[index] as { customType?: string }).customType !== "pi-jar.moderator-context") continue;
|
|
1021
|
+
if (!active || latest >= 0) prune = true;
|
|
1022
|
+
else latest = index;
|
|
1023
|
+
}
|
|
1024
|
+
if (!prune) return;
|
|
1025
|
+
return { messages: event.messages.filter((raw, index) =>
|
|
1026
|
+
(raw as { customType?: string }).customType !== "pi-jar.moderator-context" || (active && index === latest)) };
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
/** Agents resumed by the moderator: their next settled turn wakes it, so it never has to poll peek. */
|
|
1030
|
+
const watched = new Map<string, SubagentRecord>();
|
|
1031
|
+
const settledQueue = new Map<string, SubagentRecord>();
|
|
1032
|
+
let notifyTimer: NodeJS.Timeout | undefined;
|
|
1033
|
+
const flushSettled = () => {
|
|
1034
|
+
notifyTimer = undefined;
|
|
1035
|
+
const records = [...settledQueue.values()].filter((record) => registry.get(record.key) === record);
|
|
1036
|
+
settledQueue.clear();
|
|
1037
|
+
if (!records.length) return;
|
|
1038
|
+
try {
|
|
1039
|
+
pi.sendMessage?.({
|
|
1040
|
+
customType: SUBAGENT_MESSAGE, display: true, details: { keys: records.map((record) => record.key) },
|
|
1041
|
+
content: ["[PI-JAR SUBAGENT] A resumed turn ended. Review, then resume, ask or stop:", ...records.map(settledRecord)].join("\n\n")
|
|
1042
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1043
|
+
} catch (error) { console.error("pi-jar: could not deliver a subagent event", error); }
|
|
1044
|
+
};
|
|
1045
|
+
registry.subscribe(() => {
|
|
1046
|
+
if (!watched.size) return;
|
|
1047
|
+
for (const [key, record] of watched) {
|
|
1048
|
+
if (registry.get(key) !== record) { watched.delete(key); continue; }
|
|
1049
|
+
if (isRunning(record.run.state)) continue;
|
|
1050
|
+
watched.delete(key);
|
|
1051
|
+
if (record.run.state !== "stopped") settledQueue.set(key, record);
|
|
1052
|
+
}
|
|
1053
|
+
if (settledQueue.size && !notifyTimer) {
|
|
1054
|
+
notifyTimer = setTimeout(flushSettled, NOTIFY_COALESCE_MS);
|
|
1055
|
+
notifyTimer.unref?.();
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
const delegateTool: ToolDefinition<typeof Parameters> = {
|
|
561
1060
|
name: DELEGATE_TOOL,
|
|
562
1061
|
label: "delegate",
|
|
563
|
-
description: `
|
|
564
|
-
promptSnippet: "
|
|
1062
|
+
description: `Spawn retained session-scoped subagents within the configured live pool limit. scout is fresh/read-only and defaults to the scout role; fork inherits the parent conversation read-only and defaults to reviewer; worktree inherits context, defaults to worker, and edits in an isolated Git worktree. Retained agents become idle after a turn and are controlled with jar_subagent. Worktree changes apply only when the moderator stops that agent.`,
|
|
1063
|
+
promptSnippet: "Act as moderator: delegate parallel work, inspect progress with jar_subagent peek, steer only when needed, and synthesize results instead of duplicating subagent work.",
|
|
565
1064
|
promptGuidelines: [
|
|
566
|
-
"
|
|
567
|
-
"
|
|
1065
|
+
"When useful work can be delegated, act as the moderator: decompose, assign, monitor, resolve disagreements, and synthesize. Do not redo a delegated implementation yourself while its worker is active.",
|
|
1066
|
+
"Use scout for cheap independent discovery, fork/reviewer for context-aware review, and worktree/worker for implementation. Explicit task.role overrides these mode defaults.",
|
|
1067
|
+
"Retained agents become idle after each turn. Reuse them with jar_subagent resume instead of spawning replacements; pause them when priorities change; stop completed workers to reconcile their worktree into /diff.",
|
|
1068
|
+
"Use jar_subagent ask for a brief BTW question to one worker. For agent-to-agent questions, have them use the bounded jar_discuss paper so answers stay structured and cheap.",
|
|
1069
|
+
"Respect the configured pool limit, including idle and paused agents. Stop agents you no longer need."
|
|
568
1070
|
],
|
|
569
1071
|
parameters: Parameters,
|
|
570
1072
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1073
|
+
controllerContext = ctx;
|
|
1074
|
+
const requested = params.tasks;
|
|
1075
|
+
const maximum = maxSubagents();
|
|
1076
|
+
const releaseReservation = registry.reserve(requested.length, maximum);
|
|
1077
|
+
if (!releaseReservation) {
|
|
1078
|
+
return { content: [{ type: "text", text: `Subagent pool is full: ${registry.retained()}/${maximum} retained. Reuse or stop an existing agent with jar_subagent.` }], details: { runs: [], write: false }, isError: true };
|
|
1079
|
+
}
|
|
1080
|
+
try {
|
|
1081
|
+
|
|
571
1082
|
const id = ++batch;
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
1083
|
+
const defaultMode: DelegateExecutionMode = params.mode ?? (params.write === true ? "direct" : "scout");
|
|
1084
|
+
let available = new Set<string>();
|
|
1085
|
+
try { available = new Set(ctx.modelRegistry.getAvailable().map((model) => model.provider + "/" + model.id)); } catch { /* current model remains the fallback */ }
|
|
1086
|
+
const thinkingByRun = new Map<number, string | undefined>();
|
|
1087
|
+
const runs: DelegateRun[] = requested.map((item, index) => {
|
|
1088
|
+
const mode: DelegateExecutionMode = item.mode ?? defaultMode;
|
|
1089
|
+
const role = cleanText(item.role ?? roleForMode(mode), 32) || roleForMode(mode);
|
|
1090
|
+
const candidates = [...roleCandidates(role), ...roleCandidates("default")];
|
|
1091
|
+
const selected = candidates.find((candidate) => available.has(candidate.provider + "/" + candidate.model));
|
|
1092
|
+
const model = selected ? `${selected.provider}/${selected.model}` : ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
1093
|
+
thinkingByRun.set(index + 1, selected?.thinking);
|
|
1094
|
+
return {
|
|
1095
|
+
index: index + 1, name: cleanText(item.name ?? `${role} ${index + 1}`, 24), task: item.task, role, mode, ...(model ? { model } : {}),
|
|
1096
|
+
state: "queued", tools: 0, turns: 0, cost: 0, output: "", toolCounts: {}, filesRead: [], filesEdited: [], appliedFiles: [],
|
|
1097
|
+
pauses: 0, resumes: 0, todos: [], steered: 0, transcript: [], live: ""
|
|
1098
|
+
};
|
|
579
1099
|
});
|
|
580
|
-
|
|
1100
|
+
|
|
581
1101
|
const controllers = runs.map(() => new AbortController());
|
|
582
1102
|
const abortAll = () => { for (const controller of controllers) controller.abort(); };
|
|
583
1103
|
if (signal?.aborted) abortAll(); else signal?.addEventListener("abort", abortAll, { once: true });
|
|
584
1104
|
let handles: DelegateHandle[] = [];
|
|
585
|
-
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
}));
|
|
591
|
-
registry.add(...records);
|
|
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.
|
|
1105
|
+
let records: SubagentRecord[] = [];
|
|
1106
|
+
const worktrees = new Map<number, DelegateWorktree>();
|
|
1107
|
+
const sessionDirs = new Map<number, string>();
|
|
1108
|
+
const parentSession = ctx.sessionManager?.getSessionFile?.();
|
|
1109
|
+
|
|
594
1110
|
let baselines: string | undefined;
|
|
595
|
-
if (
|
|
1111
|
+
if (runs.some((run) => run.mode === "direct") && options.changes) {
|
|
596
1112
|
try { baselines = mkdtempSync(join(tmpdir(), "pi-jar-baselines-")); }
|
|
597
|
-
catch (error) { console.error("pi-jar: subagent edits will not join /diff", error); }
|
|
1113
|
+
catch (error) { console.error("pi-jar: direct subagent edits will not join /diff", error); }
|
|
598
1114
|
}
|
|
599
1115
|
const edited = (path: string) => {
|
|
600
1116
|
const tracker = options.changes?.();
|
|
@@ -603,76 +1119,364 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
603
1119
|
const baseline = readChildBaseline(baselines, absolute);
|
|
604
1120
|
if (baseline !== undefined && tracker.adopt(absolute, baseline)) options.changed?.();
|
|
605
1121
|
};
|
|
606
|
-
|
|
1122
|
+
|
|
1123
|
+
/** Last footer state per record: child streams fire many updates, the status changes rarely. */
|
|
1124
|
+
const published = new Map<string, string>();
|
|
607
1125
|
const publish = () => {
|
|
608
1126
|
if (!ctx.hasUI) return;
|
|
609
1127
|
for (const { key, run } of records) {
|
|
1128
|
+
const state = run.state === "done" || run.state === "failed" || run.state === "stopped" ? ""
|
|
1129
|
+
: run.state === "queued" || run.state === "paused" ? "waiting" : run.state === "idle" ? "idle" : "working";
|
|
1130
|
+
if (published.get(key) === state) continue;
|
|
1131
|
+
published.set(key, state);
|
|
610
1132
|
try {
|
|
611
|
-
ctx.ui.setStatus(`${ROLE_PREFIX}${key}`,
|
|
612
|
-
name: run.name, label: run.name.slice(0, 12), state
|
|
1133
|
+
ctx.ui.setStatus(`${ROLE_PREFIX}${key}`, !state ? undefined : JSON.stringify({
|
|
1134
|
+
name: run.name, label: run.name.slice(0, 12), state,
|
|
1135
|
+
task: cleanText(run.task, 60), expiresAt: Date.now() + 24 * 60 * 60_000
|
|
613
1136
|
}));
|
|
614
1137
|
} catch { /* status is decoration */ }
|
|
615
1138
|
}
|
|
616
1139
|
};
|
|
617
|
-
// Details are persisted with the session: never the transcript or live text, which the registry holds instead.
|
|
618
1140
|
const details = (includeOutput = true) => ({
|
|
619
|
-
|
|
1141
|
+
batch: id,
|
|
1142
|
+
runs: runs.map(({ transcript: _transcript, live: _live, ...run }): RunDetails => includeOutput ? run : { ...run, output: "" }),
|
|
1143
|
+
write: runs.some((run) => run.mode === "direct" || run.mode === "worktree")
|
|
620
1144
|
});
|
|
621
1145
|
let updateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
622
1146
|
let lastUpdateAt = 0;
|
|
1147
|
+
let toolReturned = false;
|
|
623
1148
|
const emitUpdate = () => {
|
|
624
1149
|
updateTimer = undefined;
|
|
625
1150
|
lastUpdateAt = Date.now();
|
|
626
1151
|
publish();
|
|
627
|
-
onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}${run.activity ?
|
|
1152
|
+
onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}${run.activity ? " · " + run.activity : ""}`).join("\n") }], details: details(false) });
|
|
628
1153
|
};
|
|
629
1154
|
const update = () => {
|
|
630
1155
|
registry.notify();
|
|
631
|
-
if (!onUpdate) { publish(); return; }
|
|
1156
|
+
if (toolReturned || !onUpdate) { publish(); return; }
|
|
632
1157
|
const wait = Math.max(0, LIVE_UPDATE_MS - (Date.now() - lastUpdateAt));
|
|
633
|
-
if (wait === 0) { if (updateTimer)
|
|
634
|
-
if (!updateTimer) {
|
|
635
|
-
updateTimer = setTimeout(emitUpdate, wait);
|
|
636
|
-
updateTimer.unref?.();
|
|
637
|
-
}
|
|
1158
|
+
if (wait === 0) { if (updateTimer) clearTimeout(updateTimer); emitUpdate(); return; }
|
|
1159
|
+
if (!updateTimer) { updateTimer = setTimeout(emitUpdate, wait); updateTimer.unref?.(); }
|
|
638
1160
|
};
|
|
639
1161
|
const flushUpdate = () => {
|
|
640
1162
|
if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; }
|
|
641
1163
|
emitUpdate();
|
|
642
1164
|
};
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
1165
|
+
const failedHandle = (): DelegateHandle => ({
|
|
1166
|
+
firstSettled: Promise.resolve(), closed: Promise.resolve(), steer: () => false, pause: async () => false,
|
|
1167
|
+
resume: () => false, ask: async () => undefined, shutdown: async () => {}, alive: () => false
|
|
1168
|
+
});
|
|
1169
|
+
const cleanupPrivate = (index: number, discardWorktree: boolean) => {
|
|
1170
|
+
const dir = sessionDirs.get(index);
|
|
1171
|
+
if (dir) { rmSync(dir, { recursive: true, force: true }); sessionDirs.delete(index); }
|
|
1172
|
+
if (discardWorktree) {
|
|
1173
|
+
const worktree = worktrees.get(index);
|
|
1174
|
+
if (worktree) {
|
|
1175
|
+
try { disposeDelegateWorktree(worktree); } catch { /* best effort */ }
|
|
1176
|
+
worktrees.delete(index);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
const failBeforeStart = (run: DelegateRun, error: unknown) => {
|
|
1181
|
+
run.state = "failed";
|
|
1182
|
+
run.error = cleanText(error instanceof Error ? error.message : String(error), 300);
|
|
1183
|
+
run.endedAt = Date.now();
|
|
1184
|
+
update();
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1187
|
+
let discarded = false;
|
|
1188
|
+
handles = runs.map(failedHandle);
|
|
1189
|
+
const readyResolvers: Array<() => void> = [];
|
|
1190
|
+
const ready = runs.map(() => new Promise<void>((resolve) => readyResolvers.push(resolve)));
|
|
1191
|
+
const startRuns = () => runs.forEach((run, index) => {
|
|
1192
|
+
if (discarded) { run.state = "stopped"; readyResolvers[index]!(); return; }
|
|
1193
|
+
const start = (): DelegateHandle => {
|
|
1194
|
+
if ((run.mode === "fork" || run.mode === "worktree") && !parentSession) {
|
|
1195
|
+
failBeforeStart(run, "fork/worktree mode requires a persisted parent Pi session");
|
|
1196
|
+
return failedHandle();
|
|
1197
|
+
}
|
|
1198
|
+
let cwd = ctx.cwd;
|
|
1199
|
+
let fork: DelegateForkOptions | undefined;
|
|
1200
|
+
const env: Record<string, string> = {
|
|
1201
|
+
[SUBAGENT_KEY_ENV]: `delegate-${id}-${run.index}`,
|
|
1202
|
+
[SUBAGENT_NAME_ENV]: run.name
|
|
1203
|
+
};
|
|
1204
|
+
const discussion = options.discussionFile?.();
|
|
1205
|
+
if (discussion) env[DISCUSSION_FILE_ENV] = discussion;
|
|
1206
|
+
const hooks: DelegateHooks = {};
|
|
1207
|
+
const write = run.mode === "direct" || run.mode === "worktree";
|
|
1208
|
+
|
|
1209
|
+
if (run.mode === "fork" || run.mode === "worktree") {
|
|
1210
|
+
try {
|
|
1211
|
+
const sessionDir = mkdtempSync(join(tmpdir(), "pi-jar-fork-"));
|
|
1212
|
+
sessionDirs.set(run.index, sessionDir);
|
|
1213
|
+
fork = { source: parentSession!, sessionDir, ...(run.mode === "worktree" ? { tools: WORKTREE_TOOLS } : {}) };
|
|
1214
|
+
} catch (error) {
|
|
1215
|
+
failBeforeStart(run, error);
|
|
1216
|
+
return failedHandle();
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
if (run.mode === "worktree") {
|
|
1220
|
+
try {
|
|
1221
|
+
const worktree = createDelegateWorktree(ctx.cwd);
|
|
1222
|
+
worktrees.set(run.index, worktree);
|
|
1223
|
+
run.workspace = worktree.root;
|
|
1224
|
+
cwd = worktree.cwd;
|
|
1225
|
+
env[CHILD_WORKTREE_ENV] = worktree.root;
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
cleanupPrivate(run.index, true);
|
|
1228
|
+
failBeforeStart(run, error);
|
|
1229
|
+
return failedHandle();
|
|
1230
|
+
}
|
|
1231
|
+
} else if (run.mode === "direct" && baselines) {
|
|
1232
|
+
env[CHILD_BASELINE_ENV] = baselines;
|
|
1233
|
+
hooks.edited = edited;
|
|
1234
|
+
}
|
|
1235
|
+
hooks.env = env;
|
|
1236
|
+
return startDelegate(run, delegateArgs(run.model, thinkingByRun.get(run.index), write, process.argv, fork),
|
|
1237
|
+
delegatePrompt(run.task, write, run.mode), cwd, controllers[index]!.signal, update, options.spawnProcess, hooks, run.mode !== "direct");
|
|
1238
|
+
};
|
|
1239
|
+
let handle: DelegateHandle;
|
|
1240
|
+
try { handle = start(); }
|
|
1241
|
+
catch (error) { failBeforeStart(run, error); handle = failedHandle(); }
|
|
1242
|
+
handles[index] = handle;
|
|
1243
|
+
readyResolvers[index]!();
|
|
1244
|
+
void handle.closed.then(() => {
|
|
1245
|
+
cleanupPrivate(run.index, discarded || run.mode !== "worktree");
|
|
1246
|
+
update();
|
|
1247
|
+
});
|
|
1248
|
+
if (discarded) void handle.shutdown().finally(() => cleanupPrivate(run.index, true));
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
const stopping = new Map<number, Promise<SubagentStopReport>>();
|
|
1252
|
+
const stopRequested = new Set<number>();
|
|
1253
|
+
const stopRun = (index: number): Promise<SubagentStopReport> => {
|
|
1254
|
+
const existing = stopping.get(index);
|
|
1255
|
+
if (existing) return existing;
|
|
1256
|
+
stopRequested.add(index);
|
|
1257
|
+
const promise = finalizeRun(index);
|
|
1258
|
+
stopping.set(index, promise);
|
|
1259
|
+
// A reconciliation conflict is retryable after the parent drift is resolved.
|
|
1260
|
+
void promise.then(() => { if (worktrees.has(runs[index]!.index)) stopping.delete(index); });
|
|
1261
|
+
return promise;
|
|
1262
|
+
};
|
|
1263
|
+
const finalizeRun = async (index: number): Promise<SubagentStopReport> => {
|
|
1264
|
+
const run = runs[index]!;
|
|
1265
|
+
const key = `delegate-${id}-${run.index}`;
|
|
1266
|
+
const workspace = run.workspace;
|
|
1267
|
+
if (run.error?.startsWith("changes not applied:")) run.error = undefined;
|
|
1268
|
+
await ready[index];
|
|
1269
|
+
const handle = handles[index]!;
|
|
1270
|
+
await handle.shutdown();
|
|
1271
|
+
|
|
1272
|
+
let changed: string[] = [];
|
|
1273
|
+
const worktree = worktrees.get(run.index);
|
|
1274
|
+
if (worktree && !discarded) {
|
|
1275
|
+
try { changed = worktreeChangedFiles(worktree); run.filesEdited = changed; }
|
|
1276
|
+
catch (error) { run.error = "could not inspect isolated changes: " + cleanText(String(error), 240); }
|
|
1277
|
+
if (changed.length && !run.error) {
|
|
1278
|
+
try {
|
|
1279
|
+
run.appliedFiles = applyDelegateWorktree(worktree, ctx.cwd, changed, options.changes?.());
|
|
1280
|
+
if (run.appliedFiles.length) {
|
|
1281
|
+
try { options.changed?.(); } catch (error) { console.error("pi-jar: could not refresh parent change UI", error); }
|
|
1282
|
+
}
|
|
1283
|
+
disposeDelegateWorktree(worktree);
|
|
1284
|
+
worktrees.delete(run.index);
|
|
1285
|
+
run.workspace = undefined;
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
if (error instanceof WorktreeApplyError) run.appliedFiles = error.appliedFiles;
|
|
1288
|
+
run.error = "changes not applied: " + cleanText(error instanceof Error ? error.message : String(error), 240);
|
|
1289
|
+
}
|
|
1290
|
+
} else if (!changed.length) {
|
|
1291
|
+
try { disposeDelegateWorktree(worktree); } catch { /* best effort */ }
|
|
1292
|
+
worktrees.delete(run.index);
|
|
1293
|
+
run.workspace = undefined;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
cleanupPrivate(run.index, false);
|
|
1297
|
+
run.state = run.error ? "failed" : "stopped";
|
|
1298
|
+
run.endedAt ??= Date.now();
|
|
1299
|
+
update();
|
|
1300
|
+
return {
|
|
1301
|
+
key, name: run.name, state: run.state, task: run.task, ...(workspace ? { workspace } : {}),
|
|
1302
|
+
changed, applied: [...run.appliedFiles], remaining: remainingTasks(run), last: run.output, ...(run.error ? { error: run.error } : {})
|
|
1303
|
+
};
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
records = runs.map((run, index) => ({
|
|
1307
|
+
key: `delegate-${id}-${run.index}`, batch: id, run,
|
|
1308
|
+
stop: () => stopRun(index),
|
|
1309
|
+
pause: () => discarded || stopRequested.has(index) ? Promise.resolve(false) : handles[index]!.pause(),
|
|
1310
|
+
resume: (text) => !discarded && !stopRequested.has(index) && handles[index]!.resume(text),
|
|
1311
|
+
steer: (text) => !discarded && !stopRequested.has(index) && handles[index]!.steer(text),
|
|
1312
|
+
ask: (text) => discarded || stopRequested.has(index) ? Promise.resolve(undefined) : handles[index]!.ask(text),
|
|
1313
|
+
alive: () => handles[index]!.alive(),
|
|
1314
|
+
closed: () => ready[index]!.then(() => handles[index]!.closed),
|
|
1315
|
+
changed: () => {
|
|
1316
|
+
const worktree = worktrees.get(run.index);
|
|
1317
|
+
if (!worktree) return [...run.filesEdited];
|
|
1318
|
+
try { return worktreeChangedFiles(worktree); } catch { return [...run.filesEdited]; }
|
|
1319
|
+
},
|
|
1320
|
+
discard: () => {
|
|
1321
|
+
discarded = true;
|
|
1322
|
+
// Cancellation is synchronous: a concurrent stop must never apply discarded edits.
|
|
1323
|
+
controllers[index]!.abort(STOP_REASON);
|
|
1324
|
+
void ready[index]!.then(() => handles[index]!.shutdown()).finally(() => cleanupPrivate(run.index, true));
|
|
1325
|
+
}
|
|
1326
|
+
}));
|
|
1327
|
+
// Transfer the reservation into queued records before notifying reentrant listeners.
|
|
1328
|
+
releaseReservation();
|
|
1329
|
+
registry.add(...records);
|
|
1330
|
+
startRuns();
|
|
1331
|
+
publish();
|
|
1332
|
+
|
|
646
1333
|
try {
|
|
647
|
-
|
|
648
|
-
ctx.cwd, controllers[index]!.signal, update, options.spawnProcess, hooks));
|
|
649
|
-
await Promise.all(handles.map((handle) => handle.done));
|
|
1334
|
+
await Promise.all(handles.map((handle, index) => runs[index]!.mode === "direct" ? handle.closed : handle.firstSettled));
|
|
650
1335
|
} finally {
|
|
651
|
-
clearInterval(refresh);
|
|
652
1336
|
signal?.removeEventListener("abort", abortAll);
|
|
653
1337
|
if (baselines) rmSync(baselines, { recursive: true, force: true });
|
|
654
|
-
for (const run of runs)
|
|
1338
|
+
for (const run of runs) {
|
|
1339
|
+
if (run.mode === "direct") cleanupPrivate(run.index, true);
|
|
1340
|
+
else if (run.state === "failed") cleanupPrivate(run.index, false);
|
|
1341
|
+
}
|
|
655
1342
|
registry.notify();
|
|
656
1343
|
flushUpdate();
|
|
1344
|
+
toolReturned = true;
|
|
657
1345
|
}
|
|
658
|
-
return { content: [{ type: "text", text: runs.map((run) => runReport(run)).join("\n\n") }], details: details()
|
|
1346
|
+
return { content: [{ type: "text", text: runs.map((run) => runReport(run)).join("\n\n") }], details: details(),
|
|
1347
|
+
...(runs.some((run) => run.state === "failed") ? { isError: true } : {}) };
|
|
1348
|
+
} finally { releaseReservation(); }
|
|
659
1349
|
},
|
|
660
1350
|
renderCall(args, theme) {
|
|
661
1351
|
const count = Array.isArray(args.tasks) ? args.tasks.length : 0;
|
|
1352
|
+
const requested = args.mode ?? (args.write ? "direct" : "scout");
|
|
662
1353
|
return new Text(theme.fg("toolTitle", theme.bold("delegate")) + " " + theme.fg("accent", `${count} subagent${count === 1 ? "" : "s"}`)
|
|
663
|
-
+ theme.fg("dim",
|
|
1354
|
+
+ theme.fg("dim", ` · ${requested}`), 0, 0);
|
|
664
1355
|
},
|
|
665
1356
|
renderResult(result, { expanded }, theme) {
|
|
666
1357
|
const runs = (result.details as { runs?: Array<Partial<RunDetails> & Pick<DelegateRun, "name" | "state" | "role">> } | undefined)?.runs ?? [];
|
|
667
1358
|
const rows = runs.map((run) => {
|
|
668
1359
|
const leaves = leafTodos(run.todos ?? []);
|
|
669
|
-
const meta = [run.role, run.
|
|
670
|
-
|
|
1360
|
+
const meta = [run.role, run.mode === "fork" || run.mode === "worktree" ? run.mode : "", run.activity, run.tools ? `${run.tools} tools` : "",
|
|
1361
|
+
leaves.length ? `${leaves.filter((todo) => todo.done).length}/${leaves.length} tasks` : "",
|
|
1362
|
+
run.appliedFiles?.length ? `${run.appliedFiles.length} applied` : run.filesEdited?.length ? `${run.filesEdited.length} edited` : "",
|
|
1363
|
+
run.cost ? `$${run.cost.toFixed(3)}` : "", run.error].filter(Boolean).join(" · ");
|
|
671
1364
|
let row = theme.fg(color(run.state) as never, ` ${glyph(run.state)} ${run.name}`) + theme.fg("dim", " · " + meta);
|
|
672
1365
|
if (expanded && run.output) row += "\n" + run.output.split("\n").map((line) => theme.fg("muted", " " + line)).join("\n");
|
|
673
1366
|
return row;
|
|
674
1367
|
});
|
|
675
1368
|
return new Text(rows.join("\n") || theme.fg("dim", "No subagents."), 0, 0);
|
|
676
1369
|
}
|
|
1370
|
+
};
|
|
1371
|
+
pi.registerTool(delegateTool);
|
|
1372
|
+
|
|
1373
|
+
const snapshot = (record: SubagentRecord): SubagentReport => ({
|
|
1374
|
+
id: record.key, key: record.key, name: record.run.name, task: record.run.task,
|
|
1375
|
+
mode: record.run.mode, state: record.run.state, output: record.run.output,
|
|
1376
|
+
...(record.run.error ? { error: record.run.error } : {})
|
|
1377
|
+
});
|
|
1378
|
+
options.onController?.({
|
|
1379
|
+
list: () => registry.records().map(snapshot),
|
|
1380
|
+
async stop(id) { await registry.stop(id); },
|
|
1381
|
+
async spawnScout(task, signal) {
|
|
1382
|
+
if (!controllerContext) throw new Error("Delegate controller has no active session context");
|
|
1383
|
+
if (signal?.aborted) throw new Error("Scout launch aborted");
|
|
1384
|
+
const result = await delegateTool.execute("scout", { tasks: [{ task, mode: "scout" }] }, signal, undefined, controllerContext);
|
|
1385
|
+
const launched = (result.details as { batch?: number }).batch;
|
|
1386
|
+
if (!launched) throw new Error(result.content.map((part) => "text" in part ? part.text : "").join("\n"));
|
|
1387
|
+
const record = registry.get(`delegate-${launched}-1`);
|
|
1388
|
+
if (!record) throw new Error("Scout session was discarded");
|
|
1389
|
+
if (record.run.error || record.run.state !== "idle") throw new Error(record.run.error ?? "Scout failed to settle");
|
|
1390
|
+
return snapshot(record);
|
|
1391
|
+
},
|
|
1392
|
+
async resumeScout(id, prompt, signal) {
|
|
1393
|
+
const record = registry.get(id);
|
|
1394
|
+
if (!record || record.run.mode !== "scout") throw new Error("Only fresh read-only scouts can vote");
|
|
1395
|
+
if (!prompt.trim()) throw new Error("Scout resume requires a prompt");
|
|
1396
|
+
if (signal?.aborted) throw new Error("Scout resume aborted");
|
|
1397
|
+
const result = new Promise<SubagentReport>((resolve, reject) => {
|
|
1398
|
+
let unsubscribe = () => {};
|
|
1399
|
+
const cleanup = () => { unsubscribe(); signal?.removeEventListener("abort", abort); };
|
|
1400
|
+
const check = () => {
|
|
1401
|
+
if (registry.get(id) !== record) { cleanup(); reject(new Error("Scout session was discarded")); return; }
|
|
1402
|
+
if (isRunning(record.run.state)) return;
|
|
1403
|
+
cleanup();
|
|
1404
|
+
if (record.run.error || !isRetained(record.run.state)) reject(new Error(record.run.error ?? "Scout exited before reporting"));
|
|
1405
|
+
else resolve(snapshot(record));
|
|
1406
|
+
};
|
|
1407
|
+
const abort = () => { cleanup(); void registry.stop(id); reject(new Error("Scout resume aborted")); };
|
|
1408
|
+
unsubscribe = registry.subscribe(check);
|
|
1409
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
1410
|
+
if (!registry.resume(id, prompt)) { cleanup(); reject(new Error("Scout is not idle or paused")); }
|
|
1411
|
+
else check();
|
|
1412
|
+
});
|
|
1413
|
+
return result;
|
|
1414
|
+
}
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
pi.registerTool({
|
|
1418
|
+
name: "jar_subagent",
|
|
1419
|
+
label: "subagent",
|
|
1420
|
+
description: "Moderator control for retained subagents: peek task progress, steer a working agent, ask a brief BTW question, pause without losing context, resume the same agent, or stop it and receive a progress/change/remaining-work handoff. Stopping a worktree agent safely applies reviewable changes to the parent.",
|
|
1421
|
+
promptSnippet: "Use jar_subagent as the moderator control plane. Prefer peek over rereading transcripts; reuse paused/idle agents with resume; stop finished workers to reconcile their work.",
|
|
1422
|
+
promptGuidelines: [
|
|
1423
|
+
"peek returns only task progress (done/total, the in-progress task, activity, the next few open tasks). Do not poll it: after resume, a pi-jar.subagent message wakes you (or queues for your current run) with that progress plus the turn's report when the agent's turn ends; stop returns changed files and the full handoff.",
|
|
1424
|
+
"Use steer to correct direction while an agent is working. Use ask for a short BTW question; it waits for the agent's next compact answer.",
|
|
1425
|
+
"Pause when an agent should stop spending tokens but keep its context/workspace. Resume the same agent later instead of spawning a replacement.",
|
|
1426
|
+
"Stop when an agent is no longer needed. Stop aborts any active turn, retires the process, reports progress/workspace/changes/remaining work, and reconciles safe worktree changes into /diff."
|
|
1427
|
+
],
|
|
1428
|
+
parameters: Type.Object({
|
|
1429
|
+
action: Type.Union([
|
|
1430
|
+
Type.Literal("peek"), Type.Literal("steer"), Type.Literal("ask"),
|
|
1431
|
+
Type.Literal("pause"), Type.Literal("resume"), Type.Literal("stop")
|
|
1432
|
+
]),
|
|
1433
|
+
agent: Type.Optional(Type.String({ description: "Subagent key or unique name. Omit only for peek to show all retained agents." })),
|
|
1434
|
+
message: Type.Optional(Type.String({ description: "Direction, BTW question, or resume instruction." }))
|
|
1435
|
+
}),
|
|
1436
|
+
async execute(_id, params, signal) {
|
|
1437
|
+
const work = (async () => {
|
|
1438
|
+
if (params.action === "peek") {
|
|
1439
|
+
const targets = params.agent ? [registry.resolve(params.agent)].filter((item): item is SubagentRecord => !!item)
|
|
1440
|
+
: registry.records().filter((record) => isRetained(record.run.state));
|
|
1441
|
+
if (!targets.length) return { content: [{ type: "text", text: params.agent ? "Subagent not found: " + params.agent : "No retained subagents." }] };
|
|
1442
|
+
return { content: [{ type: "text", text: targets.map(peekRecord).join("\n\n") }] };
|
|
1443
|
+
}
|
|
1444
|
+
const agent = params.agent ?? "";
|
|
1445
|
+
const record = registry.resolve(agent);
|
|
1446
|
+
if (!record) return { content: [{ type: "text", text: "Subagent not found or name is ambiguous: " + agent }], isError: true };
|
|
1447
|
+
if (params.action === "steer") {
|
|
1448
|
+
const message = cleanBlock(params.message ?? "", MAX_STEER_CHARS);
|
|
1449
|
+
if (!message) return { content: [{ type: "text", text: "steer requires message." }], isError: true };
|
|
1450
|
+
return { content: [{ type: "text", text: registry.steer(record.key, message) ? "Steered " + record.run.name + "." : "Subagent is not currently working; use resume instead." }] };
|
|
1451
|
+
}
|
|
1452
|
+
// The moderator is handling this agent directly now; its settle no longer needs a wake-up.
|
|
1453
|
+
if (params.action !== "resume") watched.delete(record.key);
|
|
1454
|
+
if (params.action === "ask") {
|
|
1455
|
+
const message = cleanBlock(params.message ?? "", MAX_STEER_CHARS);
|
|
1456
|
+
if (!message) return { content: [{ type: "text", text: "ask requires message." }], isError: true };
|
|
1457
|
+
const answer = await registry.ask(record.key, message);
|
|
1458
|
+
return { content: [{ type: "text", text: answer ? record.run.name + ": " + cleanBlock(answer, 2000) : "No answer arrived before the BTW timeout." }] };
|
|
1459
|
+
}
|
|
1460
|
+
if (params.action === "pause") {
|
|
1461
|
+
const paused = await registry.pause(record.key);
|
|
1462
|
+
const text = paused ? "Paused " + record.run.name + "; context and workspace are retained."
|
|
1463
|
+
: record.run.state === "working" ? record.run.name + " has not settled yet; it pauses when its current turn ends."
|
|
1464
|
+
: "Could not pause " + record.run.name + ".";
|
|
1465
|
+
return { content: [{ type: "text", text }] };
|
|
1466
|
+
}
|
|
1467
|
+
if (params.action === "resume") {
|
|
1468
|
+
const resumed = registry.resume(record.key, params.message);
|
|
1469
|
+
if (resumed) watched.set(record.key, record);
|
|
1470
|
+
return { content: [{ type: "text", text: resumed ? "Resumed " + record.run.name + ". You will be woken when its turn ends; do not poll peek." : record.run.name + " is not idle or paused." }] };
|
|
1471
|
+
}
|
|
1472
|
+
const report = await registry.stop(record.key);
|
|
1473
|
+
if (!report) return { content: [{ type: "text", text: "Subagent is already retired: " + record.run.name }] };
|
|
1474
|
+
return { content: [{ type: "text", text: controlReport(report) }], details: report };
|
|
1475
|
+
})();
|
|
1476
|
+
// Pi waits on a tool until it resolves, so Esc must never be stuck behind a child that ignores abort.
|
|
1477
|
+
const result = await untilAborted(work, signal)
|
|
1478
|
+
?? { content: [{ type: "text", text: "Cancelled; the subagent keeps its state. Check it with peek." }], isError: true };
|
|
1479
|
+
return { ...result, content: result.content.map((part) => ({ ...part, type: "text" as const })), details: "details" in result ? result.details : undefined };
|
|
1480
|
+
}
|
|
677
1481
|
});
|
|
678
1482
|
}
|