pi-jar 0.1.5 → 0.1.7
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 +20 -8
- package/docs/DIFF-REVIEW.md +11 -0
- package/docs/INTEGRATIONS.md +2 -2
- package/docs/SUBAGENT-TOOLS.md +24 -0
- package/docs/WORKFLOWS.md +6 -4
- package/extensions/index.ts +36 -27
- package/package.json +1 -1
- package/src/activity-view.ts +11 -2
- package/src/advisor.ts +17 -8
- package/src/async-process.ts +144 -0
- package/src/changes.ts +98 -26
- package/src/commit.ts +5 -6
- package/src/compact-tools.ts +66 -28
- package/src/delegate-worktree.ts +72 -57
- package/src/delegate.ts +432 -133
- package/src/democracy.ts +44 -22
- package/src/diff-view.ts +141 -30
- package/src/discussion.ts +55 -19
- package/src/goal-loop.ts +3 -15
- package/src/plan.ts +4 -10
- package/src/shell-notifications.ts +104 -0
- package/src/shells.ts +130 -26
- package/src/subagent-tools.ts +45 -0
- package/src/working.ts +3 -5
package/src/delegate.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { signalProcessTree } from "./async-process.ts";
|
|
3
|
+
import { CHILD_TOOLS_ENV, RECURSIVE_TOOLS, WEB_TOOLS } from "./subagent-tools.ts";
|
|
4
|
+
import { existsSync, mkdtempSync } from "node:fs";
|
|
5
|
+
import { rm } from "node:fs/promises";
|
|
3
6
|
import { tmpdir } from "node:os";
|
|
4
7
|
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
5
8
|
import { fileURLToPath } from "node:url";
|
|
@@ -30,13 +33,25 @@ export const SUBAGENT_LIMIT_CHOICES = [2, 4, 6, 8, 16] as const;
|
|
|
30
33
|
/** Read-only subagents also keep jar_todo so their checklist shows in the activity view. */
|
|
31
34
|
export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "jar_todo", "jar_discuss"] as const;
|
|
32
35
|
/** 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;
|
|
36
|
+
export const WORKTREE_TOOLS = ["read", "edit", "write", "multi_file_edit", "grep", "find", "ls", "jar_todo", "jar_discuss"] as const;
|
|
37
|
+
const READ_ONLY_SAFE_TOOLS = new Set<string>([...READ_ONLY_TOOLS, ...WEB_TOOLS, "ffgrep", "fffind", "lsp_diagnostics", "obs_recall"]);
|
|
38
|
+
const WORKTREE_SAFE_TOOLS = new Set<string>([...WORKTREE_TOOLS, ...WEB_TOOLS]);
|
|
39
|
+
const allowedInMode = (mode: DelegateExecutionMode, name: string) => !RECURSIVE_TOOLS.has(name)
|
|
40
|
+
&& (mode === "direct" || (mode === "worktree" ? WORKTREE_SAFE_TOOLS.has(name) : READ_ONLY_SAFE_TOOLS.has(name)));
|
|
34
41
|
export type DelegateMode = "scout" | "fork" | "worktree";
|
|
35
42
|
type DelegateExecutionMode = DelegateMode | "direct";
|
|
36
43
|
const MAX_OUTPUT_CHARS = 12_000;
|
|
37
|
-
|
|
44
|
+
/** A working child that sends no RPC event for this long is treated as hung and killed. */
|
|
45
|
+
const IDLE_TIMEOUT_MS = 10 * 60_000;
|
|
46
|
+
/** After exit, grandchildren (MCP servers, shells) can hold the stdio pipes open; stop waiting for close. */
|
|
47
|
+
const CLOSE_GRACE_MS = 2000;
|
|
48
|
+
/** How long pause waits for the child to settle after abort before reporting that it has not paused yet. */
|
|
49
|
+
const PAUSE_GRACE_MS = 15_000;
|
|
38
50
|
const STATUS_REFRESH_MS = 20_000;
|
|
39
51
|
const LIVE_UPDATE_MS = 1000;
|
|
52
|
+
/** Visible message that wakes the moderator when a resumed subagent's turn ends. */
|
|
53
|
+
export const SUBAGENT_MESSAGE = "pi-jar.subagent";
|
|
54
|
+
const NOTIFY_COALESCE_MS = 250;
|
|
40
55
|
const MAX_EVENT_TEXT = 16_000;
|
|
41
56
|
/** Transcript entries kept per run; tool output and text are clipped so a run stays well under 2 MiB. */
|
|
42
57
|
const MAX_ENTRIES = 200;
|
|
@@ -138,7 +153,7 @@ export interface DelegateForkOptions {
|
|
|
138
153
|
|
|
139
154
|
/** CLI arguments for one subagent: fresh ephemeral session or a real Pi session fork. */
|
|
140
155
|
export function delegateArgs(model: string | undefined, thinking: string | undefined, write: boolean, argv = process.argv,
|
|
141
|
-
fork?: DelegateForkOptions): string[] {
|
|
156
|
+
fork?: DelegateForkOptions, toolOverride?: readonly string[]): string[] {
|
|
142
157
|
const session = fork ? ["--fork", fork.source, "--session-dir", fork.sessionDir] : ["--no-session"];
|
|
143
158
|
const extensions = extensionFlags(argv);
|
|
144
159
|
// Worktree cwd/session forks do not inherit project extension discovery from the parent.
|
|
@@ -149,7 +164,7 @@ export function delegateArgs(model: string | undefined, thinking: string | undef
|
|
|
149
164
|
const args = ["--mode", "rpc", ...session, ...extensions];
|
|
150
165
|
if (model) args.push("--model", model);
|
|
151
166
|
if (thinking) args.push("--thinking", thinking);
|
|
152
|
-
const tools = fork?.tools ?? (!write ? READ_ONLY_TOOLS : undefined);
|
|
167
|
+
const tools = fork?.tools ?? toolOverride ?? (!write ? READ_ONLY_TOOLS : undefined);
|
|
153
168
|
if (tools) args.push("--tools", tools.join(","));
|
|
154
169
|
return args;
|
|
155
170
|
}
|
|
@@ -181,6 +196,28 @@ export function delegatePrompt(task: string, write: boolean, mode: DelegateExecu
|
|
|
181
196
|
const cleanBlock = (value: string, limit: number): string => value.slice(0, limit * 2)
|
|
182
197
|
.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);
|
|
183
198
|
|
|
199
|
+
/** Whether `promise` settles within `ms`; the timer never keeps the process alive. */
|
|
200
|
+
const settlesWithin = (promise: Promise<unknown>, ms: number): Promise<boolean> => {
|
|
201
|
+
let timer: NodeJS.Timeout | undefined;
|
|
202
|
+
const timeout = new Promise<boolean>((resolve) => {
|
|
203
|
+
timer = setTimeout(() => resolve(false), ms);
|
|
204
|
+
timer.unref?.();
|
|
205
|
+
});
|
|
206
|
+
return Promise.race([promise.then(() => true, () => true), timeout]).finally(() => clearTimeout(timer));
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** `promise`, or undefined as soon as `signal` aborts; the work itself continues in the background. */
|
|
210
|
+
const untilAborted = <T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T | undefined> => {
|
|
211
|
+
if (!signal) return promise;
|
|
212
|
+
if (signal.aborted) return Promise.resolve(undefined);
|
|
213
|
+
let onAbort = () => {};
|
|
214
|
+
const aborted = new Promise<undefined>((resolve) => {
|
|
215
|
+
onAbort = () => resolve(undefined);
|
|
216
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
217
|
+
});
|
|
218
|
+
return Promise.race([promise, aborted]).finally(() => signal.removeEventListener("abort", onAbort));
|
|
219
|
+
};
|
|
220
|
+
|
|
184
221
|
const textOf = (message: { content?: unknown }, limit = MAX_EVENT_TEXT): string => {
|
|
185
222
|
if (typeof message.content === "string") return message.content.slice(0, limit);
|
|
186
223
|
if (!Array.isArray(message.content)) return "";
|
|
@@ -256,6 +293,7 @@ interface ChildEvent {
|
|
|
256
293
|
id?: unknown;
|
|
257
294
|
command?: unknown;
|
|
258
295
|
success?: unknown;
|
|
296
|
+
data?: { disposition?: unknown };
|
|
259
297
|
error?: unknown;
|
|
260
298
|
method?: unknown;
|
|
261
299
|
toolCallId?: unknown;
|
|
@@ -308,8 +346,8 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
308
346
|
let child: ChildProcess;
|
|
309
347
|
try {
|
|
310
348
|
const env = { ...process.env };
|
|
311
|
-
for (const key of [CHILD_BASELINE_ENV, CHILD_WORKTREE_ENV, DISCUSSION_FILE_ENV, SUBAGENT_KEY_ENV, SUBAGENT_NAME_ENV]) delete env[key];
|
|
312
|
-
child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, stdio: ["pipe", "pipe", "pipe"], env: { ...env, ...hooks.env, [CHILD_ENV]: "1" } });
|
|
349
|
+
for (const key of [CHILD_BASELINE_ENV, CHILD_WORKTREE_ENV, CHILD_TOOLS_ENV, DISCUSSION_FILE_ENV, SUBAGENT_KEY_ENV, SUBAGENT_NAME_ENV]) delete env[key];
|
|
350
|
+
child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], env: { ...env, ...hooks.env, [CHILD_ENV]: "1" } });
|
|
313
351
|
} catch (error) {
|
|
314
352
|
run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); run.endedAt = Date.now(); update();
|
|
315
353
|
firstResolved = true; resolveFirst(); resolveClosed(); return;
|
|
@@ -335,28 +373,56 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
335
373
|
let askBusy = false;
|
|
336
374
|
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
337
375
|
let turnTimer: ReturnType<typeof setTimeout> | undefined;
|
|
376
|
+
let closeTimer: NodeJS.Timeout | undefined;
|
|
377
|
+
/** Last RPC event while working; the watchdog only fires after this much silence. */
|
|
378
|
+
let activityAt = Date.now();
|
|
338
379
|
const settleWaiters: Array<() => void> = [];
|
|
339
380
|
|
|
340
381
|
const send = (command: object): boolean => {
|
|
341
382
|
const stdin = child.stdin;
|
|
342
383
|
if (!alive || !stdin || stdin.destroyed || stdin.writableEnded) return false;
|
|
384
|
+
// Node queues writes until drain. Bound that queue if a hung RPC child stops reading;
|
|
385
|
+
// accepted commands remain complete JSONL records, never partial/manual framing.
|
|
386
|
+
if (stdin.writableLength > 256 * 1024) return false;
|
|
343
387
|
try { stdin.write(JSON.stringify(command) + "\n"); return true; } catch { return false; }
|
|
344
388
|
};
|
|
345
389
|
const clearTurnTimer = () => { if (turnTimer) clearTimeout(turnTimer); turnTimer = undefined; };
|
|
346
390
|
const hardStop = () => {
|
|
347
391
|
if (!alive) return;
|
|
348
|
-
child
|
|
349
|
-
killTimer ??= setTimeout(() => {
|
|
392
|
+
signalProcessTree(child, "SIGTERM");
|
|
393
|
+
killTimer ??= setTimeout(() => {
|
|
394
|
+
// Kill the group even if its leader exited: descendants can ignore SIGTERM and keep pipes.
|
|
395
|
+
signalProcessTree(child, "SIGKILL");
|
|
396
|
+
child.stdout?.destroy?.();
|
|
397
|
+
child.stderr?.destroy?.();
|
|
398
|
+
exited(child.exitCode);
|
|
399
|
+
}, 3000);
|
|
350
400
|
killTimer.unref?.();
|
|
351
401
|
};
|
|
352
|
-
const armTurnTimer = () => {
|
|
402
|
+
const armTurnTimer = (delay = IDLE_TIMEOUT_MS) => {
|
|
353
403
|
clearTurnTimer();
|
|
354
404
|
turnTimer = setTimeout(() => {
|
|
405
|
+
turnTimer = undefined;
|
|
406
|
+
const quiet = Date.now() - activityAt;
|
|
407
|
+
if (quiet < IDLE_TIMEOUT_MS) { armTurnTimer(IDLE_TIMEOUT_MS - quiet); return; }
|
|
355
408
|
hardStop();
|
|
356
|
-
finish("failed",
|
|
357
|
-
},
|
|
409
|
+
finish("failed", `timed out: no activity for ${duration(IDLE_TIMEOUT_MS)}`);
|
|
410
|
+
}, delay);
|
|
358
411
|
turnTimer.unref?.();
|
|
359
412
|
};
|
|
413
|
+
/** High-frequency stream events repaint at most every LIVE_REPAINT_MS. */
|
|
414
|
+
const repaint = () => {
|
|
415
|
+
const now = Date.now();
|
|
416
|
+
if (now - liveAt >= LIVE_REPAINT_MS) {
|
|
417
|
+
liveAt = now;
|
|
418
|
+
if (liveTimer) { clearTimeout(liveTimer); liveTimer = undefined; }
|
|
419
|
+
update();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (liveTimer) return;
|
|
423
|
+
liveTimer = setTimeout(() => { liveTimer = undefined; liveAt = Date.now(); if (!finished) update(); }, LIVE_REPAINT_MS - (now - liveAt));
|
|
424
|
+
liveTimer.unref?.();
|
|
425
|
+
};
|
|
360
426
|
const resolveSettled = () => {
|
|
361
427
|
for (const waiter of settleWaiters.splice(0)) waiter();
|
|
362
428
|
};
|
|
@@ -375,7 +441,7 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
375
441
|
for (const entry of run.transcript) if (entry.kind === "tool" && entry.status === "running") { entry.status = "error"; entry.endedAt = Date.now(); entry.rev++; }
|
|
376
442
|
run.endedAt = Date.now();
|
|
377
443
|
resolveSettled();
|
|
378
|
-
if (!
|
|
444
|
+
if (!firstResolved) { firstResolved = true; resolveFirst(); }
|
|
379
445
|
update();
|
|
380
446
|
};
|
|
381
447
|
const abort = () => { hardStop(); finish("failed", signal?.reason === STOP_REASON ? "stopped" : "aborted"); };
|
|
@@ -396,21 +462,48 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
396
462
|
run.resumes++;
|
|
397
463
|
run.endedAt = undefined;
|
|
398
464
|
run.activity = "resuming";
|
|
465
|
+
activityAt = Date.now();
|
|
399
466
|
armTurnTimer();
|
|
400
467
|
update();
|
|
401
468
|
return true;
|
|
402
469
|
};
|
|
403
470
|
|
|
404
471
|
const running = new Map<string, { entry: ToolEntry; path?: string }>();
|
|
472
|
+
const settleTurn = () => {
|
|
473
|
+
if (settled || finished) return;
|
|
474
|
+
settled = true;
|
|
475
|
+
clearTurnTimer();
|
|
476
|
+
for (const call of running.values()) {
|
|
477
|
+
call.entry.status = "error";
|
|
478
|
+
call.entry.endedAt = Date.now();
|
|
479
|
+
call.entry.rev++;
|
|
480
|
+
}
|
|
481
|
+
running.clear();
|
|
482
|
+
run.live = "";
|
|
483
|
+
run.activity = undefined;
|
|
484
|
+
run.endedAt = Date.now();
|
|
485
|
+
run.state = run.error ? "failed" : pauseRequested ? "paused" : "idle";
|
|
486
|
+
pauseRequested = false;
|
|
487
|
+
if (!firstResolved && !run.error) { firstResolved = true; resolveFirst(); }
|
|
488
|
+
resolveSettled();
|
|
489
|
+
update();
|
|
490
|
+
if (run.error) { hardStop(); finish("failed", run.error); }
|
|
491
|
+
else if (!persistent && child.stdin && !child.stdin.writableEnded) child.stdin.end();
|
|
492
|
+
};
|
|
405
493
|
const line = (raw: string) => {
|
|
406
494
|
if (finished || !raw.trim()) return;
|
|
407
495
|
let parsed: unknown;
|
|
408
496
|
try { parsed = JSON.parse(raw); } catch { return; }
|
|
409
497
|
if (!parsed || typeof parsed !== "object") return;
|
|
410
498
|
const event = parsed as ChildEvent;
|
|
499
|
+
activityAt = Date.now();
|
|
411
500
|
switch (event.type) {
|
|
412
501
|
case "response":
|
|
413
|
-
|
|
502
|
+
// A handled prompt starts no run, so Pi deliberately sends no agent_settled event.
|
|
503
|
+
if (event.success !== false) {
|
|
504
|
+
if (event.command === "prompt" && event.data?.disposition === "handled") settleTurn();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
414
507
|
if (event.command === "prompt") { hardStop(); finish("failed", cleanText(String(event.error ?? "prompt rejected"), 300)); return; }
|
|
415
508
|
pushEntry(run, { kind: "note", rev: 0, text: `${cleanText(String(event.command ?? "command"), 24)} rejected: ${cleanText(String(event.error ?? ""), 200)}` });
|
|
416
509
|
update();
|
|
@@ -423,14 +516,9 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
423
516
|
if (delta?.type === "text_delta" && typeof delta.delta === "string") {
|
|
424
517
|
run.live = (run.live + delta.delta).slice(-MAX_LIVE_CHARS);
|
|
425
518
|
run.activity = "writing";
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
liveTimer = setTimeout(() => { liveTimer = undefined; liveAt = Date.now(); if (!finished) update(); }, LIVE_REPAINT_MS - (now - liveAt));
|
|
430
|
-
liveTimer.unref?.();
|
|
431
|
-
}
|
|
432
|
-
} else if (delta?.type === "thinking_start" || delta?.type === "thinking_delta") {
|
|
433
|
-
if (run.activity !== "thinking") run.activity = "thinking";
|
|
519
|
+
repaint();
|
|
520
|
+
} else if ((delta?.type === "thinking_start" || delta?.type === "thinking_delta") && run.activity !== "thinking") {
|
|
521
|
+
run.activity = "thinking";
|
|
434
522
|
update();
|
|
435
523
|
}
|
|
436
524
|
return;
|
|
@@ -455,7 +543,7 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
455
543
|
if (!call || !event.partialResult) return;
|
|
456
544
|
call.entry.output = cleanBlock(textOf(event.partialResult, MAX_ENTRY_TEXT * 2), MAX_ENTRY_TEXT);
|
|
457
545
|
call.entry.rev++;
|
|
458
|
-
|
|
546
|
+
repaint();
|
|
459
547
|
return;
|
|
460
548
|
}
|
|
461
549
|
case "tool_execution_end": {
|
|
@@ -491,30 +579,9 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
491
579
|
update();
|
|
492
580
|
return;
|
|
493
581
|
}
|
|
494
|
-
case "agent_settled":
|
|
495
|
-
|
|
496
|
-
clearTurnTimer();
|
|
497
|
-
for (const call of running.values()) {
|
|
498
|
-
call.entry.status = "error";
|
|
499
|
-
call.entry.endedAt = Date.now();
|
|
500
|
-
call.entry.rev++;
|
|
501
|
-
}
|
|
502
|
-
running.clear();
|
|
503
|
-
run.live = "";
|
|
504
|
-
run.activity = undefined;
|
|
505
|
-
run.endedAt = Date.now();
|
|
506
|
-
run.state = run.error ? "failed" : pauseRequested ? "paused" : "idle";
|
|
507
|
-
pauseRequested = false;
|
|
508
|
-
if (!firstResolved && !run.error) {
|
|
509
|
-
firstResolved = true;
|
|
510
|
-
resolveFirst();
|
|
511
|
-
}
|
|
512
|
-
resolveSettled();
|
|
513
|
-
update();
|
|
514
|
-
if (run.error) { hardStop(); finish("failed", run.error); }
|
|
515
|
-
else if (!persistent && child.stdin && !child.stdin.writableEnded) child.stdin.end();
|
|
582
|
+
case "agent_settled":
|
|
583
|
+
settleTurn();
|
|
516
584
|
return;
|
|
517
|
-
}
|
|
518
585
|
}
|
|
519
586
|
};
|
|
520
587
|
|
|
@@ -524,7 +591,7 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
524
591
|
let start = 0;
|
|
525
592
|
for (let end = text.indexOf("\n", start); end >= 0; end = text.indexOf("\n", start)) {
|
|
526
593
|
const item = buffer + text.slice(start, end);
|
|
527
|
-
if (!overflow) line(item.endsWith("\r") ? item.slice(0, -1) : item);
|
|
594
|
+
if (!overflow && item.length <= MAX_LINE_CHARS) line(item.endsWith("\r") ? item.slice(0, -1) : item);
|
|
528
595
|
buffer = "";
|
|
529
596
|
overflow = false;
|
|
530
597
|
start = end + 1;
|
|
@@ -534,28 +601,50 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
534
601
|
if (buffer.length > MAX_LINE_CHARS) { buffer = ""; overflow = true; }
|
|
535
602
|
});
|
|
536
603
|
child.stderr?.on("data", (chunk) => { stderr = (stderr + String(chunk)).slice(-4000); });
|
|
604
|
+
let closedOnce = false;
|
|
537
605
|
const exited = (code: number | null) => {
|
|
606
|
+
if (closedOnce) return;
|
|
607
|
+
closedOnce = true;
|
|
538
608
|
alive = false;
|
|
609
|
+
if (killTimer) signalProcessTree(child, "SIGKILL");
|
|
539
610
|
clearTimeout(killTimer);
|
|
611
|
+
clearTimeout(closeTimer);
|
|
540
612
|
if (buffer && !overflow) { line(buffer); buffer = ""; }
|
|
541
|
-
if (code === 0 && settled && !run.error) finish("done");
|
|
613
|
+
if ((closing || (code === 0 && settled)) && !run.error) finish("done");
|
|
542
614
|
else finish("failed", run.error ?? (cleanText(stderr, 300) || (settled ? `exited ${code}` : `exited ${code} before finishing`)));
|
|
543
615
|
if (!firstResolved) { firstResolved = true; resolveFirst(); }
|
|
544
616
|
resolveClosed();
|
|
545
617
|
};
|
|
546
|
-
|
|
618
|
+
// A grandchild that inherited stdout/stderr keeps them open after the child is gone, and 'close'
|
|
619
|
+
// then never fires; stop waiting after a short drain so stop/shutdown can never hang on it.
|
|
620
|
+
const drainThenClose = (code: number | null) => {
|
|
621
|
+
if (closedOnce || closeTimer) return;
|
|
622
|
+
closeTimer = setTimeout(() => {
|
|
623
|
+
signalProcessTree(child, "SIGKILL");
|
|
624
|
+
child.stdout?.destroy?.();
|
|
625
|
+
child.stderr?.destroy?.();
|
|
626
|
+
exited(code);
|
|
627
|
+
}, CLOSE_GRACE_MS);
|
|
628
|
+
closeTimer.unref?.();
|
|
629
|
+
};
|
|
630
|
+
child.on("error", (error) => {
|
|
631
|
+
hardStop();
|
|
632
|
+
finish("failed", error.message);
|
|
633
|
+
// A process that never spawned has no exit event to wait for.
|
|
634
|
+
if (child.pid === undefined) { alive = false; drainThenClose(null); }
|
|
635
|
+
});
|
|
547
636
|
// Exit retires control immediately, but finalization waits for close so stdio drains first.
|
|
548
637
|
child.once("exit", (code) => {
|
|
549
638
|
alive = false;
|
|
550
639
|
clearTurnTimer();
|
|
551
|
-
clearTimeout(killTimer);
|
|
552
640
|
if (!finished) {
|
|
553
|
-
run.state = code === 0 && settled && !run.error ? "done" : "failed";
|
|
641
|
+
run.state = (closing || (code === 0 && settled)) && !run.error ? "done" : "failed";
|
|
554
642
|
if (run.state === "failed") run.error ??= `exited ${code}`;
|
|
555
643
|
run.activity = undefined;
|
|
556
644
|
resolveSettled();
|
|
557
645
|
update();
|
|
558
646
|
}
|
|
647
|
+
drainThenClose(code);
|
|
559
648
|
});
|
|
560
649
|
child.once("close", exited);
|
|
561
650
|
|
|
@@ -585,7 +674,8 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
585
674
|
return false;
|
|
586
675
|
}
|
|
587
676
|
update();
|
|
588
|
-
|
|
677
|
+
// A child that ignores abort must not hang the moderator; it still pauses when it settles.
|
|
678
|
+
if (!await settlesWithin(waiting, PAUSE_GRACE_MS)) return false;
|
|
589
679
|
return (run.state as DelegateState) === "paused";
|
|
590
680
|
};
|
|
591
681
|
resume = (text) => {
|
|
@@ -603,13 +693,7 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
603
693
|
if (run.state === "working" && !await pause()) return undefined;
|
|
604
694
|
const instruction = "Moderator BTW: answer only this question in one compact paragraph. Preserve your assigned task scope for the next resume. Question: " + question;
|
|
605
695
|
if (!beginPrompt(instruction, wasPaused)) return undefined;
|
|
606
|
-
|
|
607
|
-
const timeout = new Promise<boolean>((resolve) => {
|
|
608
|
-
timer = setTimeout(() => resolve(false), 2 * 60_000);
|
|
609
|
-
timer.unref?.();
|
|
610
|
-
});
|
|
611
|
-
const answered = await Promise.race([waitSettled().then(() => true), timeout]);
|
|
612
|
-
clearTimeout(timer);
|
|
696
|
+
const answered = await settlesWithin(waitSettled(), 2 * 60_000);
|
|
613
697
|
if (!answered) { void pause(); return undefined; }
|
|
614
698
|
return !finished && !run.error ? run.output || undefined : undefined;
|
|
615
699
|
} finally { askBusy = false; }
|
|
@@ -623,13 +707,7 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
623
707
|
const waiting = waitSettled();
|
|
624
708
|
send({ type: "clear_queue" });
|
|
625
709
|
if (send({ type: "abort" })) {
|
|
626
|
-
|
|
627
|
-
const grace = new Promise<void>((resolve) => {
|
|
628
|
-
const timer = setTimeout(resolve, 3000);
|
|
629
|
-
timer.unref?.();
|
|
630
|
-
});
|
|
631
|
-
await Promise.race([waiting.then(() => { settledInTime = true; }), grace]);
|
|
632
|
-
if (!settledInTime && !finished) hardStop();
|
|
710
|
+
if (!await settlesWithin(waiting, 3000) && !finished) hardStop();
|
|
633
711
|
} else hardStop();
|
|
634
712
|
}
|
|
635
713
|
if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
|
|
@@ -842,7 +920,10 @@ const Parameters = Type.Object({
|
|
|
842
920
|
task: Type.String({ description: "Task instruction. scout sees only this task; fork/worktree also inherit the parent's active conversation branch." }),
|
|
843
921
|
name: Type.Optional(Type.String({ description: "Short label, e.g. \"auth scout\"." })),
|
|
844
922
|
role: Type.Optional(Type.String({ description: "pi-jar role override. Defaults by mode: scout→scout, fork→reviewer, worktree→worker; then role fallbacks/current model." })),
|
|
845
|
-
mode: Type.Optional(DelegateModeSchema)
|
|
923
|
+
mode: Type.Optional(DelegateModeSchema),
|
|
924
|
+
tools: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 80 }), { minItems: 1, maxItems: 64, uniqueItems: true,
|
|
925
|
+
description: "Explicit child tool allowlist. scout/fork remain read-only; worktree accepts only sandbox-aware file tools. Browser/web tools may be enabled when active in the parent." })),
|
|
926
|
+
inheritTools: Type.Optional(Type.Boolean({ description: "Inherit the parent's currently active tool names, subject to mode safety restrictions. Recursive pi-jar delegation tools are always removed." }))
|
|
846
927
|
}), { minItems: 1, maxItems: 16 }),
|
|
847
928
|
mode: Type.Optional(DelegateModeSchema),
|
|
848
929
|
write: Type.Optional(Type.Boolean({ description: "Deprecated compatibility switch. true keeps the old shared-workspace editing mode; prefer mode=\"worktree\"." }))
|
|
@@ -867,9 +948,13 @@ export interface DelegateController {
|
|
|
867
948
|
list(): SubagentReport[];
|
|
868
949
|
spawnScout(task: string, signal?: AbortSignal): Promise<SubagentReport>;
|
|
869
950
|
resumeScout(id: string, prompt: string, signal?: AbortSignal): Promise<SubagentReport>;
|
|
951
|
+
/** Retire an agent the orchestration spawned for itself, releasing its pool slot. */
|
|
952
|
+
stop(id: string): Promise<void>;
|
|
870
953
|
}
|
|
871
954
|
|
|
872
955
|
export interface DelegateOptions {
|
|
956
|
+
/** Snapshot names of tools active in the parent session. */
|
|
957
|
+
getActiveToolNames?: () => readonly string[];
|
|
873
958
|
/** Allowed sizes: 2, 4, 6, 8, 16; defaults to 4. Read on every launch. */
|
|
874
959
|
getMaxSubagents?: () => number;
|
|
875
960
|
/** Shares the delegate lifecycle and capacity with scout orchestration. */
|
|
@@ -902,22 +987,24 @@ const controlReport = (report: SubagentStopReport): string => [
|
|
|
902
987
|
report.last ? `last: ${cleanBlock(report.last, 1200)}` : ""
|
|
903
988
|
].filter(Boolean).join("\n");
|
|
904
989
|
|
|
990
|
+
/** Peek is task progress only, read from memory: no git, transcript or report reads. */
|
|
905
991
|
const peekRecord = (record: SubagentRecord): string => {
|
|
906
992
|
const run = record.run;
|
|
907
993
|
const leaves = leafTodos(run.todos);
|
|
908
|
-
const
|
|
909
|
-
const
|
|
910
|
-
const
|
|
994
|
+
const open = leaves.filter((todo) => todo.status !== "completed");
|
|
995
|
+
const current = open.find((todo) => todo.status === "in_progress");
|
|
996
|
+
const next = open.filter((todo) => todo !== current).slice(0, 3).map((todo) => cleanText(todo.title, 80));
|
|
997
|
+
const more = open.length - (current ? 1 : 0) - next.length;
|
|
911
998
|
return [
|
|
912
|
-
`${record.key} · ${run.name} · ${run.state}
|
|
913
|
-
`
|
|
914
|
-
|
|
915
|
-
`
|
|
916
|
-
remaining.length ? `remaining: ${remaining.join("; ")}` : "remaining: none explicitly tracked",
|
|
917
|
-
run.workspace ? `workspace: ${run.workspace}` : "",
|
|
918
|
-
run.output ? `last: ${cleanBlock(run.output, 700)}` : ""
|
|
999
|
+
`${record.key} · ${run.name} · ${run.state}`,
|
|
1000
|
+
`progress: ${leaves.length ? `${leaves.length - open.length}/${leaves.length} tasks` : "no checklist"}`
|
|
1001
|
+
+ (current ? ` · now: ${cleanText(current.title, 80)}` : "") + (run.activity ? ` · ${cleanText(run.activity, 60)}` : ""),
|
|
1002
|
+
next.length ? `next: ${next.join("; ")}${more > 0 ? ` (+${more} more)` : ""}` : ""
|
|
919
1003
|
].filter(Boolean).join("\n");
|
|
920
1004
|
};
|
|
1005
|
+
/** The wake-up after a resumed turn: its progress plus the bounded report the turn produced. */
|
|
1006
|
+
const settledRecord = (record: SubagentRecord): string =>
|
|
1007
|
+
peekRecord(record) + (record.run.output ? `\nlast: ${cleanBlock(record.run.output, 1200)}` : "");
|
|
921
1008
|
|
|
922
1009
|
/** jar_delegate plus jar_subagent: the parent remains a moderator while retained workers do the work. */
|
|
923
1010
|
export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, registry: DelegateRegistry, options: DelegateOptions = {}): void {
|
|
@@ -936,25 +1023,23 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
936
1023
|
return resolved ? [resolved] : [];
|
|
937
1024
|
};
|
|
938
1025
|
|
|
939
|
-
let moderatorContextDirty = true;
|
|
940
|
-
registry.subscribe(() => { moderatorContextDirty = true; });
|
|
941
1026
|
pi.on?.("before_agent_start", (_event, ctx) => {
|
|
942
1027
|
controllerContext = ctx;
|
|
943
|
-
moderatorContextDirty = true;
|
|
944
1028
|
const retained = registry.records().filter((record) => isRetained(record.run.state));
|
|
945
1029
|
if (!retained.length) return;
|
|
946
1030
|
const fleet = retained.map((record) =>
|
|
947
1031
|
`- ${record.key} · ${record.run.name} · ${record.run.state} · ${record.run.role}: ${cleanText(record.run.task, 140)}`).join("\n");
|
|
948
1032
|
return { message: { customType: "pi-jar.moderator-context", display: false, content: [
|
|
949
1033
|
"[PI-JAR MODERATOR MODE]",
|
|
950
|
-
"You are the coordinator while retained subagents do delegated work. Decompose and route work,
|
|
1034
|
+
"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.",
|
|
1035
|
+
"Initial/resumed turns and asynchronous ask/pause/stop operations wake you with pi-jar.subagent events. Accepted receipts are not completion: do not poll peek or claim files applied before the final event. Keep responding to user steering or do other work.",
|
|
951
1036
|
"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.",
|
|
952
1037
|
"Retained fleet:",
|
|
953
1038
|
fleet
|
|
954
1039
|
].join("\n") } };
|
|
955
1040
|
});
|
|
1041
|
+
// Context rewrites apply to one LLM call only, so the stale copies must be pruned on every call.
|
|
956
1042
|
pi.on?.("context", (event) => {
|
|
957
|
-
if (!moderatorContextDirty) return;
|
|
958
1043
|
const active = registry.retained() > 0;
|
|
959
1044
|
let latest = -1;
|
|
960
1045
|
let prune = false;
|
|
@@ -963,21 +1048,61 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
963
1048
|
if (!active || latest >= 0) prune = true;
|
|
964
1049
|
else latest = index;
|
|
965
1050
|
}
|
|
966
|
-
moderatorContextDirty = false;
|
|
967
1051
|
if (!prune) return;
|
|
968
1052
|
return { messages: event.messages.filter((raw, index) =>
|
|
969
1053
|
(raw as { customType?: string }).customType !== "pi-jar.moderator-context" || (active && index === latest)) };
|
|
970
1054
|
});
|
|
971
1055
|
|
|
1056
|
+
/** Native RPC drives registry state; Pi's message bus delivers frozen completion events to the moderator. */
|
|
1057
|
+
const eventBusAvailable = typeof pi.sendMessage === "function";
|
|
1058
|
+
const watched = new Map<string, SubagentRecord>();
|
|
1059
|
+
const settledQueue = new Map<string, { record: SubagentRecord; content: string; turn: number }>();
|
|
1060
|
+
const deleteSettled = (key: string) => {
|
|
1061
|
+
for (const [eventKey, event] of settledQueue) if (event.record.key === key) settledQueue.delete(eventKey);
|
|
1062
|
+
};
|
|
1063
|
+
let notifyTimer: NodeJS.Timeout | undefined;
|
|
1064
|
+
const flushSettled = () => {
|
|
1065
|
+
notifyTimer = undefined;
|
|
1066
|
+
const records = [...settledQueue.values()].filter(({ record }) => registry.get(record.key) === record);
|
|
1067
|
+
settledQueue.clear();
|
|
1068
|
+
if (!records.length) return;
|
|
1069
|
+
try {
|
|
1070
|
+
pi.sendMessage?.({
|
|
1071
|
+
customType: SUBAGENT_MESSAGE, display: true, details: {
|
|
1072
|
+
keys: [...new Set(records.map(({ record }) => record.key))],
|
|
1073
|
+
turns: records.map(({ record, turn }) => ({ key: record.key, turn }))
|
|
1074
|
+
},
|
|
1075
|
+
content: ["[PI-JAR SUBAGENT] A subagent turn ended. Review, then resume, ask or stop:", ...records.map(event => event.content)].join("\n\n")
|
|
1076
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1077
|
+
} catch (error) { console.error("pi-jar: could not deliver a subagent event", error); }
|
|
1078
|
+
};
|
|
1079
|
+
registry.subscribe(() => {
|
|
1080
|
+
if (!watched.size) return;
|
|
1081
|
+
for (const [key, record] of watched) {
|
|
1082
|
+
if (registry.get(key) !== record) { watched.delete(key); continue; }
|
|
1083
|
+
if (isRunning(record.run.state)) continue;
|
|
1084
|
+
watched.delete(key);
|
|
1085
|
+
if (record.run.state !== "stopped") {
|
|
1086
|
+
// Freeze reports at the settled boundary. A fast resume must not overwrite a queued
|
|
1087
|
+
// initial report, and multiple turns in the coalescing window must each be delivered.
|
|
1088
|
+
settledQueue.set(`${key}:${record.run.resumes}`, { record, turn: record.run.resumes, content: settledRecord(record) });
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
if (settledQueue.size && !notifyTimer) {
|
|
1092
|
+
notifyTimer = setTimeout(flushSettled, NOTIFY_COALESCE_MS);
|
|
1093
|
+
notifyTimer.unref?.();
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
|
|
972
1097
|
const delegateTool: ToolDefinition<typeof Parameters> = {
|
|
973
1098
|
name: DELEGATE_TOOL,
|
|
974
1099
|
label: "delegate",
|
|
975
|
-
description: `
|
|
976
|
-
promptSnippet: "Act as moderator: delegate parallel work,
|
|
1100
|
+
description: `Start retained session-scoped subagents within the configured live pool limit and return immediately. 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. Each task may provide an explicit tools allowlist or inheritTools=true to filter the parent's active tools by mode. Turn completions arrive as pi-jar.subagent event messages; control workers with jar_subagent. Worktree changes apply only when the moderator stops that agent.`,
|
|
1101
|
+
promptSnippet: "Act as moderator: delegate parallel work, then continue responding to the user while subagents run. Their turn completions arrive as pi-jar.subagent event messages; steer, pause or stop them with jar_subagent without polling.",
|
|
977
1102
|
promptGuidelines: [
|
|
978
1103
|
"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.",
|
|
979
|
-
"Use scout for cheap independent discovery, fork/reviewer for context-aware review, and worktree/worker for implementation. Explicit task.role overrides these mode defaults.",
|
|
980
|
-
"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.",
|
|
1104
|
+
"Use scout for cheap independent discovery, fork/reviewer for context-aware review, and worktree/worker for implementation. Explicit task.role overrides these mode defaults. Grant optional task.tools narrowly; task.inheritTools snapshots active parent tools then filters by mode. Scout/fork stay read-only; worktree only accepts path-guarded tools.",
|
|
1105
|
+
"Retained agents become idle after each turn. Initial and resumed turn completions arrive as pi-jar.subagent event messages, so keep responding to user steering while they work; do not poll jar_subagent peek. Reuse them with jar_subagent resume instead of spawning replacements; pause them when priorities change; stop completed workers to reconcile their worktree into /diff.",
|
|
981
1106
|
"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.",
|
|
982
1107
|
"Respect the configured pool limit, including idle and paused agents. Stop agents you no longer need."
|
|
983
1108
|
],
|
|
@@ -985,6 +1110,44 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
985
1110
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
986
1111
|
controllerContext = ctx;
|
|
987
1112
|
const requested = params.tasks;
|
|
1113
|
+
const parentToolNames = (): string[] | undefined => {
|
|
1114
|
+
try {
|
|
1115
|
+
const supplied = options.getActiveToolNames?.();
|
|
1116
|
+
if (supplied) return [...new Set(supplied.filter(name => typeof name === "string" && name.trim()).map(name => name.trim()))];
|
|
1117
|
+
const tools = pi.getActiveTools?.();
|
|
1118
|
+
return tools ? [...new Set(tools.filter(name => typeof name === "string" && name.trim()))] : undefined;
|
|
1119
|
+
} catch { return undefined; }
|
|
1120
|
+
};
|
|
1121
|
+
const activeNames = parentToolNames();
|
|
1122
|
+
const defaultWorktreeTools = WORKTREE_TOOLS.filter(name => name !== "multi_file_edit" || activeNames?.includes(name));
|
|
1123
|
+
for (const [index, item] of requested.entries()) {
|
|
1124
|
+
const mode = item.mode ?? params.mode ?? (params.write === true ? "direct" : "scout");
|
|
1125
|
+
const selected = item.tools ?? (item.inheritTools && activeNames ? activeNames.filter(name =>
|
|
1126
|
+
allowedInMode(mode, name)) : undefined);
|
|
1127
|
+
if ((item.inheritTools || item.tools) && !activeNames) {
|
|
1128
|
+
return { content: [{ type: "text", text: `Task ${index + 1}: this Pi host does not expose active tool names; configurable tools cannot be validated. Use default tools or a host with getActiveTools().` }], details: { runs: [], write: false }, isError: true };
|
|
1129
|
+
}
|
|
1130
|
+
if (item.inheritTools && !selected?.length) {
|
|
1131
|
+
return { content: [{ type: "text", text: `Task ${index + 1}: no active tools remain after applying ${mode} safety filters. Choose explicit safe tools or use the mode defaults.` }], details: { runs: [], write: false }, isError: true };
|
|
1132
|
+
}
|
|
1133
|
+
if (selected) {
|
|
1134
|
+
if (activeNames) {
|
|
1135
|
+
const missing = selected.filter(name => !activeNames.includes(name));
|
|
1136
|
+
if (missing.length) return { content: [{ type: "text", text: `Task ${index + 1}: tools are not currently active in the parent: ${missing.join(", ")}. Enable their extensions/tools first.` }], details: { runs: [], write: false }, isError: true };
|
|
1137
|
+
} else if (item.inheritTools) {
|
|
1138
|
+
return { content: [{ type: "text", text: `Task ${index + 1}: cannot resolve inherited tools from this Pi host.` }], details: { runs: [], write: false }, isError: true };
|
|
1139
|
+
}
|
|
1140
|
+
const recursive = selected.filter(name => RECURSIVE_TOOLS.has(name));
|
|
1141
|
+
if (recursive.length) return { content: [{ type: "text", text: `Task ${index + 1}: recursive delegation tools are never available to subagents: ${recursive.join(", ")}.` }], details: { runs: [], write: false }, isError: true };
|
|
1142
|
+
if ((mode === "scout" || mode === "fork") && selected.some(name => !READ_ONLY_SAFE_TOOLS.has(name))) {
|
|
1143
|
+
return { content: [{ type: "text", text: `Task ${index + 1}: ${mode} is read-only; remove edit/write/shell tools or use mode="worktree" for sandboxed code changes.` }], details: { runs: [], write: false }, isError: true };
|
|
1144
|
+
}
|
|
1145
|
+
if (mode === "worktree") {
|
|
1146
|
+
const unsafe = selected.filter(name => !WORKTREE_SAFE_TOOLS.has(name));
|
|
1147
|
+
if (unsafe.length) return { content: [{ type: "text", text: `Task ${index + 1}: worktree cannot safely use tools outside its path-guarded allowlist: ${unsafe.join(", ")}. Arbitrary extension tools remain disabled unless the worktree boundary validates them.` }], details: { runs: [], write: false }, isError: true };
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
988
1151
|
const maximum = maxSubagents();
|
|
989
1152
|
const releaseReservation = registry.reserve(requested.length, maximum);
|
|
990
1153
|
if (!releaseReservation) {
|
|
@@ -997,8 +1160,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
997
1160
|
let available = new Set<string>();
|
|
998
1161
|
try { available = new Set(ctx.modelRegistry.getAvailable().map((model) => model.provider + "/" + model.id)); } catch { /* current model remains the fallback */ }
|
|
999
1162
|
const thinkingByRun = new Map<number, string | undefined>();
|
|
1163
|
+
const toolsByRun = new Map<number, readonly string[] | undefined>();
|
|
1000
1164
|
const runs: DelegateRun[] = requested.map((item, index) => {
|
|
1001
1165
|
const mode: DelegateExecutionMode = item.mode ?? defaultMode;
|
|
1166
|
+
toolsByRun.set(index + 1, item.tools ?? (item.inheritTools && activeNames ? activeNames.filter(name =>
|
|
1167
|
+
allowedInMode(mode, name)) : mode === "worktree" ? defaultWorktreeTools : undefined));
|
|
1002
1168
|
const role = cleanText(item.role ?? roleForMode(mode), 32) || roleForMode(mode);
|
|
1003
1169
|
const candidates = [...roleCandidates(role), ...roleCandidates("default")];
|
|
1004
1170
|
const selected = candidates.find((candidate) => available.has(candidate.provider + "/" + candidate.model));
|
|
@@ -1033,13 +1199,18 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1033
1199
|
if (baseline !== undefined && tracker.adopt(absolute, baseline)) options.changed?.();
|
|
1034
1200
|
};
|
|
1035
1201
|
|
|
1202
|
+
/** Last footer state per record: child streams fire many updates, the status changes rarely. */
|
|
1203
|
+
const published = new Map<string, string>();
|
|
1036
1204
|
const publish = () => {
|
|
1037
1205
|
if (!ctx.hasUI) return;
|
|
1038
1206
|
for (const { key, run } of records) {
|
|
1207
|
+
const state = run.state === "done" || run.state === "failed" || run.state === "stopped" ? ""
|
|
1208
|
+
: run.state === "queued" || run.state === "paused" ? "waiting" : run.state === "idle" ? "idle" : "working";
|
|
1209
|
+
if (published.get(key) === state) continue;
|
|
1210
|
+
published.set(key, state);
|
|
1039
1211
|
try {
|
|
1040
|
-
ctx.ui.setStatus(`${ROLE_PREFIX}${key}`,
|
|
1041
|
-
name: run.name, label: run.name.slice(0, 12),
|
|
1042
|
-
state: run.state === "queued" || run.state === "paused" ? "waiting" : run.state === "idle" ? "idle" : "working",
|
|
1212
|
+
ctx.ui.setStatus(`${ROLE_PREFIX}${key}`, !state ? undefined : JSON.stringify({
|
|
1213
|
+
name: run.name, label: run.name.slice(0, 12), state,
|
|
1043
1214
|
task: cleanText(run.task, 60), expiresAt: Date.now() + 24 * 60 * 60_000
|
|
1044
1215
|
}));
|
|
1045
1216
|
} catch { /* status is decoration */ }
|
|
@@ -1074,13 +1245,13 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1074
1245
|
firstSettled: Promise.resolve(), closed: Promise.resolve(), steer: () => false, pause: async () => false,
|
|
1075
1246
|
resume: () => false, ask: async () => undefined, shutdown: async () => {}, alive: () => false
|
|
1076
1247
|
});
|
|
1077
|
-
const cleanupPrivate = (index: number, discardWorktree: boolean) => {
|
|
1248
|
+
const cleanupPrivate = async (index: number, discardWorktree: boolean) => {
|
|
1078
1249
|
const dir = sessionDirs.get(index);
|
|
1079
|
-
if (dir) {
|
|
1250
|
+
if (dir) { await rm(dir, { recursive: true, force: true }); sessionDirs.delete(index); }
|
|
1080
1251
|
if (discardWorktree) {
|
|
1081
1252
|
const worktree = worktrees.get(index);
|
|
1082
1253
|
if (worktree) {
|
|
1083
|
-
try { disposeDelegateWorktree(worktree); } catch { /* best effort */ }
|
|
1254
|
+
try { await disposeDelegateWorktree(worktree); } catch { /* best effort */ }
|
|
1084
1255
|
worktrees.delete(index);
|
|
1085
1256
|
}
|
|
1086
1257
|
}
|
|
@@ -1093,12 +1264,20 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1093
1264
|
};
|
|
1094
1265
|
|
|
1095
1266
|
let discarded = false;
|
|
1267
|
+
const markStartStopped = (run: DelegateRun) => {
|
|
1268
|
+
run.state = "stopped";
|
|
1269
|
+
run.error = undefined;
|
|
1270
|
+
run.endedAt = Date.now();
|
|
1271
|
+
update();
|
|
1272
|
+
};
|
|
1096
1273
|
handles = runs.map(failedHandle);
|
|
1274
|
+
const started = new Set<number>();
|
|
1097
1275
|
const readyResolvers: Array<() => void> = [];
|
|
1098
1276
|
const ready = runs.map(() => new Promise<void>((resolve) => readyResolvers.push(resolve)));
|
|
1099
|
-
const startRuns = () => runs.
|
|
1277
|
+
const startRuns = async () => Promise.all(runs.map(async (run, index) => {
|
|
1278
|
+
try {
|
|
1100
1279
|
if (discarded) { run.state = "stopped"; readyResolvers[index]!(); return; }
|
|
1101
|
-
const start = (): DelegateHandle => {
|
|
1280
|
+
const start = async (): Promise<DelegateHandle> => {
|
|
1102
1281
|
if ((run.mode === "fork" || run.mode === "worktree") && !parentSession) {
|
|
1103
1282
|
failBeforeStart(run, "fork/worktree mode requires a persisted parent Pi session");
|
|
1104
1283
|
return failedHandle();
|
|
@@ -1109,6 +1288,8 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1109
1288
|
[SUBAGENT_KEY_ENV]: `delegate-${id}-${run.index}`,
|
|
1110
1289
|
[SUBAGENT_NAME_ENV]: run.name
|
|
1111
1290
|
};
|
|
1291
|
+
const effectiveTools = toolsByRun.get(run.index) ?? (run.mode === "worktree" ? WORKTREE_TOOLS : run.mode === "direct" ? undefined : READ_ONLY_TOOLS);
|
|
1292
|
+
if (effectiveTools) env[CHILD_TOOLS_ENV] = JSON.stringify(effectiveTools);
|
|
1112
1293
|
const discussion = options.discussionFile?.();
|
|
1113
1294
|
if (discussion) env[DISCUSSION_FILE_ENV] = discussion;
|
|
1114
1295
|
const hooks: DelegateHooks = {};
|
|
@@ -1118,7 +1299,9 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1118
1299
|
try {
|
|
1119
1300
|
const sessionDir = mkdtempSync(join(tmpdir(), "pi-jar-fork-"));
|
|
1120
1301
|
sessionDirs.set(run.index, sessionDir);
|
|
1121
|
-
|
|
1302
|
+
const requestedTools = toolsByRun.get(run.index);
|
|
1303
|
+
fork = { source: parentSession!, sessionDir,
|
|
1304
|
+
...((run.mode === "worktree" || run.mode === "fork") ? { tools: requestedTools ?? (run.mode === "worktree" ? WORKTREE_TOOLS : READ_ONLY_TOOLS) } : {}) };
|
|
1122
1305
|
} catch (error) {
|
|
1123
1306
|
failBeforeStart(run, error);
|
|
1124
1307
|
return failedHandle();
|
|
@@ -1126,14 +1309,15 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1126
1309
|
}
|
|
1127
1310
|
if (run.mode === "worktree") {
|
|
1128
1311
|
try {
|
|
1129
|
-
const worktree = createDelegateWorktree(ctx.cwd);
|
|
1312
|
+
const worktree = await createDelegateWorktree(ctx.cwd, controllers[index]!.signal);
|
|
1130
1313
|
worktrees.set(run.index, worktree);
|
|
1131
1314
|
run.workspace = worktree.root;
|
|
1132
1315
|
cwd = worktree.cwd;
|
|
1133
1316
|
env[CHILD_WORKTREE_ENV] = worktree.root;
|
|
1134
1317
|
} catch (error) {
|
|
1135
|
-
cleanupPrivate(run.index, true);
|
|
1136
|
-
|
|
1318
|
+
await cleanupPrivate(run.index, true);
|
|
1319
|
+
if (discarded || stopRequested.has(index)) markStartStopped(run);
|
|
1320
|
+
else failBeforeStart(run, error);
|
|
1137
1321
|
return failedHandle();
|
|
1138
1322
|
}
|
|
1139
1323
|
} else if (run.mode === "direct" && baselines) {
|
|
@@ -1141,20 +1325,36 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1141
1325
|
hooks.edited = edited;
|
|
1142
1326
|
}
|
|
1143
1327
|
hooks.env = env;
|
|
1144
|
-
return startDelegate(run, delegateArgs(run.model, thinkingByRun.get(run.index), write, process.argv, fork
|
|
1328
|
+
return startDelegate(run, delegateArgs(run.model, thinkingByRun.get(run.index), write, process.argv, fork,
|
|
1329
|
+
run.mode === "scout" ? toolsByRun.get(run.index) : undefined),
|
|
1145
1330
|
delegatePrompt(run.task, write, run.mode), cwd, controllers[index]!.signal, update, options.spawnProcess, hooks, run.mode !== "direct");
|
|
1146
1331
|
};
|
|
1147
1332
|
let handle: DelegateHandle;
|
|
1148
|
-
try { handle = start(); }
|
|
1149
|
-
catch (error) {
|
|
1333
|
+
try { handle = await start(); }
|
|
1334
|
+
catch (error) {
|
|
1335
|
+
if (discarded || stopRequested.has(index)) markStartStopped(run);
|
|
1336
|
+
else failBeforeStart(run, error);
|
|
1337
|
+
handle = failedHandle();
|
|
1338
|
+
}
|
|
1150
1339
|
handles[index] = handle;
|
|
1340
|
+
started.add(index);
|
|
1151
1341
|
readyResolvers[index]!();
|
|
1152
|
-
void handle.closed.then(() => {
|
|
1153
|
-
cleanupPrivate(run.index, discarded || run.mode !== "worktree");
|
|
1342
|
+
void handle.closed.then(async () => {
|
|
1343
|
+
await cleanupPrivate(run.index, discarded || run.mode !== "worktree");
|
|
1344
|
+
update();
|
|
1345
|
+
}).catch((error) => {
|
|
1346
|
+
run.error ??= "subagent cleanup failed: " + cleanText(error instanceof Error ? error.message : String(error), 240);
|
|
1154
1347
|
update();
|
|
1155
1348
|
});
|
|
1156
|
-
if (discarded) void handle.shutdown().finally(() => cleanupPrivate(run.index, true))
|
|
1157
|
-
|
|
1349
|
+
if (discarded) void handle.shutdown().finally(() => cleanupPrivate(run.index, true))
|
|
1350
|
+
.catch(error => console.error("pi-jar: subagent cleanup failed", error));
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
failBeforeStart(run, error);
|
|
1353
|
+
} finally {
|
|
1354
|
+
// Even a filesystem cleanup or decoration failure must release lifecycle waiters.
|
|
1355
|
+
readyResolvers[index]!();
|
|
1356
|
+
}
|
|
1357
|
+
}));
|
|
1158
1358
|
|
|
1159
1359
|
const stopping = new Map<number, Promise<SubagentStopReport>>();
|
|
1160
1360
|
const stopRequested = new Set<number>();
|
|
@@ -1162,10 +1362,13 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1162
1362
|
const existing = stopping.get(index);
|
|
1163
1363
|
if (existing) return existing;
|
|
1164
1364
|
stopRequested.add(index);
|
|
1365
|
+
// Cancel asynchronous worktree setup as well as an already-running child before waiting
|
|
1366
|
+
// for ready; otherwise stop can queue behind a stalled startup operation.
|
|
1367
|
+
if (!started.has(index)) controllers[index]!.abort(STOP_REASON);
|
|
1165
1368
|
const promise = finalizeRun(index);
|
|
1166
1369
|
stopping.set(index, promise);
|
|
1167
1370
|
// A reconciliation conflict is retryable after the parent drift is resolved.
|
|
1168
|
-
void promise.then(() => { if (worktrees.has(runs[index]!.index)) stopping.delete(index); });
|
|
1371
|
+
void promise.then(() => { if (worktrees.has(runs[index]!.index)) stopping.delete(index); }, () => { stopping.delete(index); });
|
|
1169
1372
|
return promise;
|
|
1170
1373
|
};
|
|
1171
1374
|
const finalizeRun = async (index: number): Promise<SubagentStopReport> => {
|
|
@@ -1180,15 +1383,15 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1180
1383
|
let changed: string[] = [];
|
|
1181
1384
|
const worktree = worktrees.get(run.index);
|
|
1182
1385
|
if (worktree && !discarded) {
|
|
1183
|
-
try { changed = worktreeChangedFiles(worktree); run.filesEdited = changed; }
|
|
1386
|
+
try { changed = await worktreeChangedFiles(worktree, controllers[index]!.signal); run.filesEdited = changed; }
|
|
1184
1387
|
catch (error) { run.error = "could not inspect isolated changes: " + cleanText(String(error), 240); }
|
|
1185
1388
|
if (changed.length && !run.error) {
|
|
1186
1389
|
try {
|
|
1187
|
-
run.appliedFiles = applyDelegateWorktree(worktree, ctx.cwd, changed, options.changes?.());
|
|
1390
|
+
run.appliedFiles = await applyDelegateWorktree(worktree, ctx.cwd, changed, options.changes?.(), controllers[index]!.signal);
|
|
1188
1391
|
if (run.appliedFiles.length) {
|
|
1189
1392
|
try { options.changed?.(); } catch (error) { console.error("pi-jar: could not refresh parent change UI", error); }
|
|
1190
1393
|
}
|
|
1191
|
-
disposeDelegateWorktree(worktree);
|
|
1394
|
+
await disposeDelegateWorktree(worktree);
|
|
1192
1395
|
worktrees.delete(run.index);
|
|
1193
1396
|
run.workspace = undefined;
|
|
1194
1397
|
} catch (error) {
|
|
@@ -1196,12 +1399,13 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1196
1399
|
run.error = "changes not applied: " + cleanText(error instanceof Error ? error.message : String(error), 240);
|
|
1197
1400
|
}
|
|
1198
1401
|
} else if (!changed.length) {
|
|
1199
|
-
try { disposeDelegateWorktree(worktree); } catch { /* best effort */ }
|
|
1402
|
+
try { await disposeDelegateWorktree(worktree); } catch { /* best effort */ }
|
|
1200
1403
|
worktrees.delete(run.index);
|
|
1201
1404
|
run.workspace = undefined;
|
|
1202
1405
|
}
|
|
1203
1406
|
}
|
|
1204
|
-
cleanupPrivate(run.index,
|
|
1407
|
+
await cleanupPrivate(run.index, discarded);
|
|
1408
|
+
if (discarded) { run.error = undefined; run.workspace = undefined; }
|
|
1205
1409
|
run.state = run.error ? "failed" : "stopped";
|
|
1206
1410
|
run.endedAt ??= Date.now();
|
|
1207
1411
|
update();
|
|
@@ -1214,39 +1418,61 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1214
1418
|
records = runs.map((run, index) => ({
|
|
1215
1419
|
key: `delegate-${id}-${run.index}`, batch: id, run,
|
|
1216
1420
|
stop: () => stopRun(index),
|
|
1217
|
-
pause: () =>
|
|
1421
|
+
pause: async () => {
|
|
1422
|
+
await ready[index];
|
|
1423
|
+
return discarded || stopRequested.has(index) ? false : handles[index]!.pause();
|
|
1424
|
+
},
|
|
1218
1425
|
resume: (text) => !discarded && !stopRequested.has(index) && handles[index]!.resume(text),
|
|
1219
1426
|
steer: (text) => !discarded && !stopRequested.has(index) && handles[index]!.steer(text),
|
|
1220
|
-
ask: (text) =>
|
|
1427
|
+
ask: async (text) => {
|
|
1428
|
+
await ready[index];
|
|
1429
|
+
return discarded || stopRequested.has(index) ? undefined : handles[index]!.ask(text);
|
|
1430
|
+
},
|
|
1221
1431
|
alive: () => handles[index]!.alive(),
|
|
1222
1432
|
closed: () => ready[index]!.then(() => handles[index]!.closed),
|
|
1223
1433
|
changed: () => {
|
|
1224
1434
|
const worktree = worktrees.get(run.index);
|
|
1225
1435
|
if (!worktree) return [...run.filesEdited];
|
|
1226
|
-
|
|
1436
|
+
return [...run.filesEdited]; // synchronous activity snapshots never launch subprocesses
|
|
1227
1437
|
},
|
|
1228
1438
|
discard: () => {
|
|
1229
1439
|
discarded = true;
|
|
1230
1440
|
// Cancellation is synchronous: a concurrent stop must never apply discarded edits.
|
|
1231
1441
|
controllers[index]!.abort(STOP_REASON);
|
|
1232
|
-
void ready[index]!.then(() => handles[index]!.shutdown()).finally(() => cleanupPrivate(run.index, true))
|
|
1442
|
+
void ready[index]!.then(() => handles[index]!.shutdown()).finally(() => cleanupPrivate(run.index, true))
|
|
1443
|
+
.catch(error => console.error("pi-jar: subagent discard failed", error));
|
|
1233
1444
|
}
|
|
1234
1445
|
}));
|
|
1446
|
+
// Initial turns are event-driven too: the retained launch returns immediately so the
|
|
1447
|
+
// moderator can act on user messages instead of remaining trapped in this tool call.
|
|
1448
|
+
// The Pi RPC stream continues independently and the registry wakes the moderator on settle.
|
|
1449
|
+
if (eventBusAvailable) for (const record of records) watched.set(record.key, record);
|
|
1235
1450
|
// Transfer the reservation into queued records before notifying reentrant listeners.
|
|
1236
1451
|
releaseReservation();
|
|
1237
1452
|
registry.add(...records);
|
|
1238
|
-
startRuns();
|
|
1453
|
+
const startup = startRuns();
|
|
1454
|
+
if (eventBusAvailable) {
|
|
1455
|
+
void startup.then(async () => {
|
|
1456
|
+
// Legacy direct writers also run off the main tool path. Remove their shared baseline
|
|
1457
|
+
// directory only after all direct streams drained and their edit events were adopted.
|
|
1458
|
+
await Promise.all(handles.map((handle, index) => runs[index]!.mode === "direct" ? handle.closed : Promise.resolve()));
|
|
1459
|
+
if (baselines) await rm(baselines, { recursive: true, force: true });
|
|
1460
|
+
}).catch((error) => console.error("pi-jar: subagent startup/cleanup failed", error));
|
|
1461
|
+
signal?.removeEventListener("abort", abortAll);
|
|
1462
|
+
publish();
|
|
1463
|
+
toolReturned = true;
|
|
1464
|
+
return { content: [{ type: "text", text: runs.map((run) => runReport(run)).join("\n\n") + "\n\nSubagent launch is asynchronous; turn reports arrive as pi-jar.subagent events. You can continue working or steer the fleet with jar_subagent." }], details: details() };
|
|
1465
|
+
}
|
|
1466
|
+
await startup;
|
|
1239
1467
|
publish();
|
|
1240
1468
|
|
|
1241
1469
|
try {
|
|
1242
1470
|
await Promise.all(handles.map((handle, index) => runs[index]!.mode === "direct" ? handle.closed : handle.firstSettled));
|
|
1243
1471
|
} finally {
|
|
1244
1472
|
signal?.removeEventListener("abort", abortAll);
|
|
1245
|
-
if (baselines)
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
else if (run.state === "failed") cleanupPrivate(run.index, false);
|
|
1249
|
-
}
|
|
1473
|
+
if (baselines) await rm(baselines, { recursive: true, force: true });
|
|
1474
|
+
await Promise.all(runs.map(run => run.mode === "direct" || run.state === "failed"
|
|
1475
|
+
? cleanupPrivate(run.index, run.mode === "direct") : Promise.resolve()));
|
|
1250
1476
|
registry.notify();
|
|
1251
1477
|
flushUpdate();
|
|
1252
1478
|
toolReturned = true;
|
|
@@ -1285,6 +1511,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1285
1511
|
});
|
|
1286
1512
|
options.onController?.({
|
|
1287
1513
|
list: () => registry.records().map(snapshot),
|
|
1514
|
+
async stop(id) { await registry.stop(id); },
|
|
1288
1515
|
async spawnScout(task, signal) {
|
|
1289
1516
|
if (!controllerContext) throw new Error("Delegate controller has no active session context");
|
|
1290
1517
|
if (signal?.aborted) throw new Error("Scout launch aborted");
|
|
@@ -1293,7 +1520,25 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1293
1520
|
if (!launched) throw new Error(result.content.map((part) => "text" in part ? part.text : "").join("\n"));
|
|
1294
1521
|
const record = registry.get(`delegate-${launched}-1`);
|
|
1295
1522
|
if (!record) throw new Error("Scout session was discarded");
|
|
1296
|
-
|
|
1523
|
+
// This launch is an internal democracy vote; its caller is already awaiting the result,
|
|
1524
|
+
// so the moderator does not need a duplicate unsolicited completion notification.
|
|
1525
|
+
watched.delete(record.key);
|
|
1526
|
+
deleteSettled(record.key);
|
|
1527
|
+
await new Promise<void>((resolve, reject) => {
|
|
1528
|
+
let unsubscribe = () => {};
|
|
1529
|
+
const cleanup = () => { unsubscribe(); signal?.removeEventListener("abort", abort); };
|
|
1530
|
+
const check = () => {
|
|
1531
|
+
if (registry.get(record.key) !== record) { cleanup(); reject(new Error("Scout session was discarded")); return; }
|
|
1532
|
+
if (isRunning(record.run.state)) return;
|
|
1533
|
+
cleanup();
|
|
1534
|
+
if (record.run.error || record.run.state !== "idle") reject(new Error(record.run.error ?? "Scout failed to settle"));
|
|
1535
|
+
else resolve();
|
|
1536
|
+
};
|
|
1537
|
+
const abort = () => { cleanup(); void registry.stop(record.key); reject(new Error("Scout launch aborted")); };
|
|
1538
|
+
unsubscribe = registry.subscribe(check);
|
|
1539
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
1540
|
+
if (signal?.aborted) abort(); else check();
|
|
1541
|
+
});
|
|
1297
1542
|
return snapshot(record);
|
|
1298
1543
|
},
|
|
1299
1544
|
async resumeScout(id, prompt, signal) {
|
|
@@ -1321,14 +1566,28 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1321
1566
|
}
|
|
1322
1567
|
});
|
|
1323
1568
|
|
|
1569
|
+
let operationSequence = 0;
|
|
1570
|
+
const pendingControls = new Map<string, { id: string; action: string; message?: string }>();
|
|
1571
|
+
const deliverControl = (record: SubagentRecord, operation: { id: string; action: string }, result: { content: Array<{ text: string }>; details?: unknown; isError?: boolean }) => {
|
|
1572
|
+
// A session switch discards the old fleet. Never wake a new session with stale handoffs.
|
|
1573
|
+
if (registry.get(record.key) !== record) return;
|
|
1574
|
+
try {
|
|
1575
|
+
pi.sendMessage({
|
|
1576
|
+
customType: SUBAGENT_MESSAGE, display: true,
|
|
1577
|
+
details: { operationId: operation.id, action: operation.action, key: record.key, status: result.isError ? "failed" : "completed", report: result.details },
|
|
1578
|
+
content: `[PI-JAR SUBAGENT] ${operation.action} ${operation.id} completed:\n${result.content.map(part => part.text).join("\n")}`
|
|
1579
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1580
|
+
} catch (error) { console.error("pi-jar: could not deliver a control event", error); }
|
|
1581
|
+
};
|
|
1582
|
+
|
|
1324
1583
|
pi.registerTool({
|
|
1325
1584
|
name: "jar_subagent",
|
|
1326
1585
|
label: "subagent",
|
|
1327
|
-
description: "
|
|
1586
|
+
description: "Non-blocking moderator control for retained subagents. peek, steer and resume return immediately. ask, pause and stop return an accepted operation receipt; the final answer or handoff arrives as a pi-jar.subagent event. Stop completion safely reconciles worktree changes; acceptance does not mean changes are applied yet.",
|
|
1328
1587
|
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.",
|
|
1329
1588
|
promptGuidelines: [
|
|
1330
|
-
"
|
|
1331
|
-
"Use steer
|
|
1589
|
+
"peek returns only task progress. Do not poll: initial/resumed turns and control completions send pi-jar.subagent events. Stop returns an accepted receipt first, then changed files and the full handoff in its completion event.",
|
|
1590
|
+
"Use steer for active direction and ask for a brief BTW question. ask, pause and stop do not wait on the child; continue responding to the user until their completion event arrives.",
|
|
1332
1591
|
"Pause when an agent should stop spending tokens but keep its context/workspace. Resume the same agent later instead of spawning a replacement.",
|
|
1333
1592
|
"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."
|
|
1334
1593
|
],
|
|
@@ -1340,8 +1599,9 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1340
1599
|
agent: Type.Optional(Type.String({ description: "Subagent key or unique name. Omit only for peek to show all retained agents." })),
|
|
1341
1600
|
message: Type.Optional(Type.String({ description: "Direction, BTW question, or resume instruction." }))
|
|
1342
1601
|
}),
|
|
1343
|
-
async execute(_id, params) {
|
|
1344
|
-
const
|
|
1602
|
+
async execute(_id, params, signal) {
|
|
1603
|
+
if (signal?.aborted) return { content: [{ type: "text" as const, text: "Cancelled before accepting the operation." }], details: undefined, isError: true };
|
|
1604
|
+
const work = (async () => {
|
|
1345
1605
|
if (params.action === "peek") {
|
|
1346
1606
|
const targets = params.agent ? [registry.resolve(params.agent)].filter((item): item is SubagentRecord => !!item)
|
|
1347
1607
|
: registry.records().filter((record) => isRetained(record.run.state));
|
|
@@ -1351,30 +1611,69 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1351
1611
|
const agent = params.agent ?? "";
|
|
1352
1612
|
const record = registry.resolve(agent);
|
|
1353
1613
|
if (!record) return { content: [{ type: "text", text: "Subagent not found or name is ambiguous: " + agent }], isError: true };
|
|
1614
|
+
const stopping = pendingControls.get(record.key + ":stop");
|
|
1615
|
+
const controlling = pendingControls.get(record.key + ":control");
|
|
1616
|
+
if (stopping || (controlling && params.action !== "stop")) {
|
|
1617
|
+
const pending = stopping ?? controlling!;
|
|
1618
|
+
if (pending.action === params.action && pending.message === params.message) {
|
|
1619
|
+
return { content: [{ type: "text", text: `${pending.action} already accepted (${pending.id}); completion will arrive as an event.` }], details: { operationId: pending.id, status: "accepted", action: pending.action, key: record.key } };
|
|
1620
|
+
}
|
|
1621
|
+
return { content: [{ type: "text", text: `${record.run.name} has pending ${pending.action} (${pending.id}). Stop may preempt other controls; otherwise wait for its event.` }], isError: true };
|
|
1622
|
+
}
|
|
1354
1623
|
if (params.action === "steer") {
|
|
1355
1624
|
const message = cleanBlock(params.message ?? "", MAX_STEER_CHARS);
|
|
1356
1625
|
if (!message) return { content: [{ type: "text", text: "steer requires message." }], isError: true };
|
|
1357
1626
|
return { content: [{ type: "text", text: registry.steer(record.key, message) ? "Steered " + record.run.name + "." : "Subagent is not currently working; use resume instead." }] };
|
|
1358
1627
|
}
|
|
1628
|
+
// The moderator is handling this agent directly now; its settle no longer needs a wake-up.
|
|
1629
|
+
if (params.action !== "resume") watched.delete(record.key);
|
|
1630
|
+
const controlWork = async () => {
|
|
1359
1631
|
if (params.action === "ask") {
|
|
1360
1632
|
const message = cleanBlock(params.message ?? "", MAX_STEER_CHARS);
|
|
1361
1633
|
if (!message) return { content: [{ type: "text", text: "ask requires message." }], isError: true };
|
|
1362
1634
|
const answer = await registry.ask(record.key, message);
|
|
1363
|
-
return { content: [{ type: "text", text: answer ? record.run.name + ": " + cleanBlock(answer, 2000) : "No answer arrived before the BTW timeout." }] };
|
|
1635
|
+
return { content: [{ type: "text", text: answer ? record.run.name + ": " + cleanBlock(answer, 2000) : "No answer arrived before the BTW timeout or the worker stopped." }], isError: !answer };
|
|
1364
1636
|
}
|
|
1365
1637
|
if (params.action === "pause") {
|
|
1366
1638
|
const paused = await registry.pause(record.key);
|
|
1367
|
-
|
|
1639
|
+
if (!paused && isRunning(record.run.state) && !pendingControls.has(record.key + ":stop")) watched.set(record.key, record);
|
|
1640
|
+
const text = paused ? "Paused " + record.run.name + "; context and workspace are retained."
|
|
1641
|
+
: record.run.state === "working" ? record.run.name + " has not settled yet; it pauses when its current turn ends."
|
|
1642
|
+
: "Could not pause " + record.run.name + ".";
|
|
1643
|
+
return { content: [{ type: "text", text }], isError: !paused };
|
|
1368
1644
|
}
|
|
1369
1645
|
if (params.action === "resume") {
|
|
1370
1646
|
const resumed = registry.resume(record.key, params.message);
|
|
1371
|
-
|
|
1647
|
+
if (resumed) watched.set(record.key, record);
|
|
1648
|
+
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." }] };
|
|
1372
1649
|
}
|
|
1373
1650
|
const report = await registry.stop(record.key);
|
|
1374
1651
|
if (!report) return { content: [{ type: "text", text: "Subagent is already retired: " + record.run.name }] };
|
|
1375
|
-
return { content: [{ type: "text", text: controlReport(report) }], details: report };
|
|
1652
|
+
return { content: [{ type: "text", text: controlReport(report) }], details: report, isError: !!report.error };
|
|
1653
|
+
};
|
|
1654
|
+
// Internal registry APIs remain awaited for shutdown/reconciliation correctness. Only the
|
|
1655
|
+
// external tool boundary is detached, so parent steering never waits on a child turn or Git.
|
|
1656
|
+
if (eventBusAvailable && params.action !== "resume") {
|
|
1657
|
+
if (params.action === "ask" && !params.message?.trim()) return { content: [{ type: "text", text: "ask requires message." }], isError: true };
|
|
1658
|
+
const operation = { id: `subagent-op-${++operationSequence}`, action: params.action, message: params.message };
|
|
1659
|
+
const pendingKey = record.key + (params.action === "stop" ? ":stop" : ":control");
|
|
1660
|
+
pendingControls.set(pendingKey, operation);
|
|
1661
|
+
deleteSettled(record.key);
|
|
1662
|
+
void Promise.resolve().then(controlWork).then(result => {
|
|
1663
|
+
if (pendingControls.get(pendingKey) === operation) pendingControls.delete(pendingKey);
|
|
1664
|
+
deliverControl(record, operation, result);
|
|
1665
|
+
}, error => {
|
|
1666
|
+
if (pendingControls.get(pendingKey) === operation) pendingControls.delete(pendingKey);
|
|
1667
|
+
deliverControl(record, operation, { content: [{ text: error instanceof Error ? error.message : String(error) }], isError: true });
|
|
1668
|
+
});
|
|
1669
|
+
return { content: [{ type: "text", text: `${params.action} accepted for ${record.run.name} (${operation.id}). Completion and any reconciled changes will arrive as an event; keep responding to the user, do not poll.` }], details: { operationId: operation.id, status: "accepted", action: params.action, key: record.key } };
|
|
1670
|
+
}
|
|
1671
|
+
return controlWork();
|
|
1376
1672
|
})();
|
|
1377
|
-
|
|
1673
|
+
// Pi waits on a tool until it resolves, so Esc must never be stuck behind a child that ignores abort.
|
|
1674
|
+
const result = await untilAborted(work, signal)
|
|
1675
|
+
?? { content: [{ type: "text", text: "Cancelled; the subagent keeps its state. Check it with peek." }], isError: true };
|
|
1676
|
+
return { ...result, content: result.content.map((part) => ({ ...part, type: "text" as const })), details: "details" in result ? result.details : undefined };
|
|
1378
1677
|
}
|
|
1379
1678
|
});
|
|
1380
1679
|
}
|