pi-jar 0.1.6 → 0.1.8
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 +19 -7
- package/docs/DIFF-REVIEW.md +14 -0
- package/docs/SUBAGENT-TOOLS.md +24 -0
- package/docs/WORKFLOWS.md +2 -2
- package/extensions/index.ts +21 -23
- package/package.json +1 -1
- package/src/advisor.ts +17 -8
- package/src/async-process.ts +144 -0
- package/src/changes.ts +235 -28
- package/src/commit.ts +5 -6
- package/src/delegate-worktree.ts +135 -58
- package/src/delegate.ts +291 -88
- package/src/diff-inline.ts +80 -0
- package/src/diff-view.ts +292 -44
- package/src/discussion.ts +59 -21
- package/src/quota.ts +12 -1
- 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,7 +33,11 @@ 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;
|
|
@@ -146,7 +153,7 @@ export interface DelegateForkOptions {
|
|
|
146
153
|
|
|
147
154
|
/** CLI arguments for one subagent: fresh ephemeral session or a real Pi session fork. */
|
|
148
155
|
export function delegateArgs(model: string | undefined, thinking: string | undefined, write: boolean, argv = process.argv,
|
|
149
|
-
fork?: DelegateForkOptions): string[] {
|
|
156
|
+
fork?: DelegateForkOptions, toolOverride?: readonly string[]): string[] {
|
|
150
157
|
const session = fork ? ["--fork", fork.source, "--session-dir", fork.sessionDir] : ["--no-session"];
|
|
151
158
|
const extensions = extensionFlags(argv);
|
|
152
159
|
// Worktree cwd/session forks do not inherit project extension discovery from the parent.
|
|
@@ -157,7 +164,7 @@ export function delegateArgs(model: string | undefined, thinking: string | undef
|
|
|
157
164
|
const args = ["--mode", "rpc", ...session, ...extensions];
|
|
158
165
|
if (model) args.push("--model", model);
|
|
159
166
|
if (thinking) args.push("--thinking", thinking);
|
|
160
|
-
const tools = fork?.tools ?? (!write ? READ_ONLY_TOOLS : undefined);
|
|
167
|
+
const tools = fork?.tools ?? toolOverride ?? (!write ? READ_ONLY_TOOLS : undefined);
|
|
161
168
|
if (tools) args.push("--tools", tools.join(","));
|
|
162
169
|
return args;
|
|
163
170
|
}
|
|
@@ -286,6 +293,7 @@ interface ChildEvent {
|
|
|
286
293
|
id?: unknown;
|
|
287
294
|
command?: unknown;
|
|
288
295
|
success?: unknown;
|
|
296
|
+
data?: { disposition?: unknown };
|
|
289
297
|
error?: unknown;
|
|
290
298
|
method?: unknown;
|
|
291
299
|
toolCallId?: unknown;
|
|
@@ -338,8 +346,8 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
338
346
|
let child: ChildProcess;
|
|
339
347
|
try {
|
|
340
348
|
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" } });
|
|
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" } });
|
|
343
351
|
} catch (error) {
|
|
344
352
|
run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); run.endedAt = Date.now(); update();
|
|
345
353
|
firstResolved = true; resolveFirst(); resolveClosed(); return;
|
|
@@ -373,13 +381,22 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
373
381
|
const send = (command: object): boolean => {
|
|
374
382
|
const stdin = child.stdin;
|
|
375
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;
|
|
376
387
|
try { stdin.write(JSON.stringify(command) + "\n"); return true; } catch { return false; }
|
|
377
388
|
};
|
|
378
389
|
const clearTurnTimer = () => { if (turnTimer) clearTimeout(turnTimer); turnTimer = undefined; };
|
|
379
390
|
const hardStop = () => {
|
|
380
391
|
if (!alive) return;
|
|
381
|
-
child
|
|
382
|
-
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);
|
|
383
400
|
killTimer.unref?.();
|
|
384
401
|
};
|
|
385
402
|
const armTurnTimer = (delay = IDLE_TIMEOUT_MS) => {
|
|
@@ -452,6 +469,27 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
452
469
|
};
|
|
453
470
|
|
|
454
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
|
+
};
|
|
455
493
|
const line = (raw: string) => {
|
|
456
494
|
if (finished || !raw.trim()) return;
|
|
457
495
|
let parsed: unknown;
|
|
@@ -461,7 +499,11 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
461
499
|
activityAt = Date.now();
|
|
462
500
|
switch (event.type) {
|
|
463
501
|
case "response":
|
|
464
|
-
|
|
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
|
+
}
|
|
465
507
|
if (event.command === "prompt") { hardStop(); finish("failed", cleanText(String(event.error ?? "prompt rejected"), 300)); return; }
|
|
466
508
|
pushEntry(run, { kind: "note", rev: 0, text: `${cleanText(String(event.command ?? "command"), 24)} rejected: ${cleanText(String(event.error ?? ""), 200)}` });
|
|
467
509
|
update();
|
|
@@ -537,30 +579,9 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
537
579
|
update();
|
|
538
580
|
return;
|
|
539
581
|
}
|
|
540
|
-
case "agent_settled":
|
|
541
|
-
|
|
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();
|
|
559
|
-
update();
|
|
560
|
-
if (run.error) { hardStop(); finish("failed", run.error); }
|
|
561
|
-
else if (!persistent && child.stdin && !child.stdin.writableEnded) child.stdin.end();
|
|
582
|
+
case "agent_settled":
|
|
583
|
+
settleTurn();
|
|
562
584
|
return;
|
|
563
|
-
}
|
|
564
585
|
}
|
|
565
586
|
};
|
|
566
587
|
|
|
@@ -570,7 +591,7 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
570
591
|
let start = 0;
|
|
571
592
|
for (let end = text.indexOf("\n", start); end >= 0; end = text.indexOf("\n", start)) {
|
|
572
593
|
const item = buffer + text.slice(start, end);
|
|
573
|
-
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);
|
|
574
595
|
buffer = "";
|
|
575
596
|
overflow = false;
|
|
576
597
|
start = end + 1;
|
|
@@ -585,10 +606,11 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
585
606
|
if (closedOnce) return;
|
|
586
607
|
closedOnce = true;
|
|
587
608
|
alive = false;
|
|
609
|
+
if (killTimer) signalProcessTree(child, "SIGKILL");
|
|
588
610
|
clearTimeout(killTimer);
|
|
589
611
|
clearTimeout(closeTimer);
|
|
590
612
|
if (buffer && !overflow) { line(buffer); buffer = ""; }
|
|
591
|
-
if (code === 0 && settled && !run.error) finish("done");
|
|
613
|
+
if ((closing || (code === 0 && settled)) && !run.error) finish("done");
|
|
592
614
|
else finish("failed", run.error ?? (cleanText(stderr, 300) || (settled ? `exited ${code}` : `exited ${code} before finishing`)));
|
|
593
615
|
if (!firstResolved) { firstResolved = true; resolveFirst(); }
|
|
594
616
|
resolveClosed();
|
|
@@ -598,6 +620,7 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
598
620
|
const drainThenClose = (code: number | null) => {
|
|
599
621
|
if (closedOnce || closeTimer) return;
|
|
600
622
|
closeTimer = setTimeout(() => {
|
|
623
|
+
signalProcessTree(child, "SIGKILL");
|
|
601
624
|
child.stdout?.destroy?.();
|
|
602
625
|
child.stderr?.destroy?.();
|
|
603
626
|
exited(code);
|
|
@@ -614,9 +637,8 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
614
637
|
child.once("exit", (code) => {
|
|
615
638
|
alive = false;
|
|
616
639
|
clearTurnTimer();
|
|
617
|
-
clearTimeout(killTimer);
|
|
618
640
|
if (!finished) {
|
|
619
|
-
run.state = code === 0 && settled && !run.error ? "done" : "failed";
|
|
641
|
+
run.state = (closing || (code === 0 && settled)) && !run.error ? "done" : "failed";
|
|
620
642
|
if (run.state === "failed") run.error ??= `exited ${code}`;
|
|
621
643
|
run.activity = undefined;
|
|
622
644
|
resolveSettled();
|
|
@@ -898,7 +920,10 @@ const Parameters = Type.Object({
|
|
|
898
920
|
task: Type.String({ description: "Task instruction. scout sees only this task; fork/worktree also inherit the parent's active conversation branch." }),
|
|
899
921
|
name: Type.Optional(Type.String({ description: "Short label, e.g. \"auth scout\"." })),
|
|
900
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." })),
|
|
901
|
-
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." }))
|
|
902
927
|
}), { minItems: 1, maxItems: 16 }),
|
|
903
928
|
mode: Type.Optional(DelegateModeSchema),
|
|
904
929
|
write: Type.Optional(Type.Boolean({ description: "Deprecated compatibility switch. true keeps the old shared-workspace editing mode; prefer mode=\"worktree\"." }))
|
|
@@ -928,6 +953,8 @@ export interface DelegateController {
|
|
|
928
953
|
}
|
|
929
954
|
|
|
930
955
|
export interface DelegateOptions {
|
|
956
|
+
/** Snapshot names of tools active in the parent session. */
|
|
957
|
+
getActiveToolNames?: () => readonly string[];
|
|
931
958
|
/** Allowed sizes: 2, 4, 6, 8, 16; defaults to 4. Read on every launch. */
|
|
932
959
|
getMaxSubagents?: () => number;
|
|
933
960
|
/** Shares the delegate lifecycle and capacity with scout orchestration. */
|
|
@@ -949,9 +976,15 @@ const remainingTasks = (run: DelegateRun): string[] => {
|
|
|
949
976
|
return leaves.filter((todo) => todo.status !== "completed").map((todo) => todo.title).slice(0, 20);
|
|
950
977
|
};
|
|
951
978
|
|
|
979
|
+
const taskLabel = (task: string): string => {
|
|
980
|
+
const text = cleanText(task, 4096);
|
|
981
|
+
return text.length > 160 ? text.slice(0, 159) + "…" : text;
|
|
982
|
+
};
|
|
983
|
+
|
|
952
984
|
const controlReport = (report: SubagentStopReport): string => [
|
|
953
985
|
`${report.key} · ${report.name} · ${report.state}`,
|
|
954
|
-
|
|
986
|
+
// The moderator already holds the full task in its own jar_delegate call; echo only a label.
|
|
987
|
+
`task: ${taskLabel(report.task)}`,
|
|
955
988
|
`workspace: ${report.workspace ?? "none"}`,
|
|
956
989
|
`changed: ${report.changed.length ? report.changed.join(", ") : "none"}`,
|
|
957
990
|
`applied: ${report.applied.length ? report.applied.join(", ") : "none"}`,
|
|
@@ -1005,7 +1038,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1005
1038
|
return { message: { customType: "pi-jar.moderator-context", display: false, content: [
|
|
1006
1039
|
"[PI-JAR MODERATOR MODE]",
|
|
1007
1040
|
"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
|
-
"
|
|
1041
|
+
"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.",
|
|
1009
1042
|
"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
1043
|
"Retained fleet:",
|
|
1011
1044
|
fleet
|
|
@@ -1026,19 +1059,26 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1026
1059
|
(raw as { customType?: string }).customType !== "pi-jar.moderator-context" || (active && index === latest)) };
|
|
1027
1060
|
});
|
|
1028
1061
|
|
|
1029
|
-
/**
|
|
1062
|
+
/** Native RPC drives registry state; Pi's message bus delivers frozen completion events to the moderator. */
|
|
1063
|
+
const eventBusAvailable = typeof pi.sendMessage === "function";
|
|
1030
1064
|
const watched = new Map<string, SubagentRecord>();
|
|
1031
|
-
const settledQueue = new Map<string, SubagentRecord>();
|
|
1065
|
+
const settledQueue = new Map<string, { record: SubagentRecord; content: string; turn: number }>();
|
|
1066
|
+
const deleteSettled = (key: string) => {
|
|
1067
|
+
for (const [eventKey, event] of settledQueue) if (event.record.key === key) settledQueue.delete(eventKey);
|
|
1068
|
+
};
|
|
1032
1069
|
let notifyTimer: NodeJS.Timeout | undefined;
|
|
1033
1070
|
const flushSettled = () => {
|
|
1034
1071
|
notifyTimer = undefined;
|
|
1035
|
-
const records = [...settledQueue.values()].filter((record) => registry.get(record.key) === record);
|
|
1072
|
+
const records = [...settledQueue.values()].filter(({ record }) => registry.get(record.key) === record);
|
|
1036
1073
|
settledQueue.clear();
|
|
1037
1074
|
if (!records.length) return;
|
|
1038
1075
|
try {
|
|
1039
1076
|
pi.sendMessage?.({
|
|
1040
|
-
customType: SUBAGENT_MESSAGE, display: true, details: {
|
|
1041
|
-
|
|
1077
|
+
customType: SUBAGENT_MESSAGE, display: true, details: {
|
|
1078
|
+
keys: [...new Set(records.map(({ record }) => record.key))],
|
|
1079
|
+
turns: records.map(({ record, turn }) => ({ key: record.key, turn }))
|
|
1080
|
+
},
|
|
1081
|
+
content: ["[PI-JAR SUBAGENT] A subagent turn ended. Review, then resume, ask or stop:", ...records.map(event => event.content)].join("\n\n")
|
|
1042
1082
|
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1043
1083
|
} catch (error) { console.error("pi-jar: could not deliver a subagent event", error); }
|
|
1044
1084
|
};
|
|
@@ -1048,7 +1088,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1048
1088
|
if (registry.get(key) !== record) { watched.delete(key); continue; }
|
|
1049
1089
|
if (isRunning(record.run.state)) continue;
|
|
1050
1090
|
watched.delete(key);
|
|
1051
|
-
if (record.run.state !== "stopped")
|
|
1091
|
+
if (record.run.state !== "stopped") {
|
|
1092
|
+
// Freeze reports at the settled boundary. A fast resume must not overwrite a queued
|
|
1093
|
+
// initial report, and multiple turns in the coalescing window must each be delivered.
|
|
1094
|
+
settledQueue.set(`${key}:${record.run.resumes}`, { record, turn: record.run.resumes, content: settledRecord(record) });
|
|
1095
|
+
}
|
|
1052
1096
|
}
|
|
1053
1097
|
if (settledQueue.size && !notifyTimer) {
|
|
1054
1098
|
notifyTimer = setTimeout(flushSettled, NOTIFY_COALESCE_MS);
|
|
@@ -1059,12 +1103,12 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1059
1103
|
const delegateTool: ToolDefinition<typeof Parameters> = {
|
|
1060
1104
|
name: DELEGATE_TOOL,
|
|
1061
1105
|
label: "delegate",
|
|
1062
|
-
description: `
|
|
1063
|
-
promptSnippet: "Act as moderator: delegate parallel work,
|
|
1106
|
+
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.`,
|
|
1107
|
+
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.",
|
|
1064
1108
|
promptGuidelines: [
|
|
1065
1109
|
"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.",
|
|
1110
|
+
"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.",
|
|
1111
|
+
"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.",
|
|
1068
1112
|
"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
1113
|
"Respect the configured pool limit, including idle and paused agents. Stop agents you no longer need."
|
|
1070
1114
|
],
|
|
@@ -1072,6 +1116,44 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1072
1116
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1073
1117
|
controllerContext = ctx;
|
|
1074
1118
|
const requested = params.tasks;
|
|
1119
|
+
const parentToolNames = (): string[] | undefined => {
|
|
1120
|
+
try {
|
|
1121
|
+
const supplied = options.getActiveToolNames?.();
|
|
1122
|
+
if (supplied) return [...new Set(supplied.filter(name => typeof name === "string" && name.trim()).map(name => name.trim()))];
|
|
1123
|
+
const tools = pi.getActiveTools?.();
|
|
1124
|
+
return tools ? [...new Set(tools.filter(name => typeof name === "string" && name.trim()))] : undefined;
|
|
1125
|
+
} catch { return undefined; }
|
|
1126
|
+
};
|
|
1127
|
+
const activeNames = parentToolNames();
|
|
1128
|
+
const defaultWorktreeTools = WORKTREE_TOOLS.filter(name => name !== "multi_file_edit" || activeNames?.includes(name));
|
|
1129
|
+
for (const [index, item] of requested.entries()) {
|
|
1130
|
+
const mode = item.mode ?? params.mode ?? (params.write === true ? "direct" : "scout");
|
|
1131
|
+
const selected = item.tools ?? (item.inheritTools && activeNames ? activeNames.filter(name =>
|
|
1132
|
+
allowedInMode(mode, name)) : undefined);
|
|
1133
|
+
if ((item.inheritTools || item.tools) && !activeNames) {
|
|
1134
|
+
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 };
|
|
1135
|
+
}
|
|
1136
|
+
if (item.inheritTools && !selected?.length) {
|
|
1137
|
+
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 };
|
|
1138
|
+
}
|
|
1139
|
+
if (selected) {
|
|
1140
|
+
if (activeNames) {
|
|
1141
|
+
const missing = selected.filter(name => !activeNames.includes(name));
|
|
1142
|
+
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 };
|
|
1143
|
+
} else if (item.inheritTools) {
|
|
1144
|
+
return { content: [{ type: "text", text: `Task ${index + 1}: cannot resolve inherited tools from this Pi host.` }], details: { runs: [], write: false }, isError: true };
|
|
1145
|
+
}
|
|
1146
|
+
const recursive = selected.filter(name => RECURSIVE_TOOLS.has(name));
|
|
1147
|
+
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 };
|
|
1148
|
+
if ((mode === "scout" || mode === "fork") && selected.some(name => !READ_ONLY_SAFE_TOOLS.has(name))) {
|
|
1149
|
+
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 };
|
|
1150
|
+
}
|
|
1151
|
+
if (mode === "worktree") {
|
|
1152
|
+
const unsafe = selected.filter(name => !WORKTREE_SAFE_TOOLS.has(name));
|
|
1153
|
+
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 };
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1075
1157
|
const maximum = maxSubagents();
|
|
1076
1158
|
const releaseReservation = registry.reserve(requested.length, maximum);
|
|
1077
1159
|
if (!releaseReservation) {
|
|
@@ -1084,8 +1166,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1084
1166
|
let available = new Set<string>();
|
|
1085
1167
|
try { available = new Set(ctx.modelRegistry.getAvailable().map((model) => model.provider + "/" + model.id)); } catch { /* current model remains the fallback */ }
|
|
1086
1168
|
const thinkingByRun = new Map<number, string | undefined>();
|
|
1169
|
+
const toolsByRun = new Map<number, readonly string[] | undefined>();
|
|
1087
1170
|
const runs: DelegateRun[] = requested.map((item, index) => {
|
|
1088
1171
|
const mode: DelegateExecutionMode = item.mode ?? defaultMode;
|
|
1172
|
+
toolsByRun.set(index + 1, item.tools ?? (item.inheritTools && activeNames ? activeNames.filter(name =>
|
|
1173
|
+
allowedInMode(mode, name)) : mode === "worktree" ? defaultWorktreeTools : undefined));
|
|
1089
1174
|
const role = cleanText(item.role ?? roleForMode(mode), 32) || roleForMode(mode);
|
|
1090
1175
|
const candidates = [...roleCandidates(role), ...roleCandidates("default")];
|
|
1091
1176
|
const selected = candidates.find((candidate) => available.has(candidate.provider + "/" + candidate.model));
|
|
@@ -1166,13 +1251,13 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1166
1251
|
firstSettled: Promise.resolve(), closed: Promise.resolve(), steer: () => false, pause: async () => false,
|
|
1167
1252
|
resume: () => false, ask: async () => undefined, shutdown: async () => {}, alive: () => false
|
|
1168
1253
|
});
|
|
1169
|
-
const cleanupPrivate = (index: number, discardWorktree: boolean) => {
|
|
1254
|
+
const cleanupPrivate = async (index: number, discardWorktree: boolean) => {
|
|
1170
1255
|
const dir = sessionDirs.get(index);
|
|
1171
|
-
if (dir) {
|
|
1256
|
+
if (dir) { await rm(dir, { recursive: true, force: true }); sessionDirs.delete(index); }
|
|
1172
1257
|
if (discardWorktree) {
|
|
1173
1258
|
const worktree = worktrees.get(index);
|
|
1174
1259
|
if (worktree) {
|
|
1175
|
-
try { disposeDelegateWorktree(worktree); } catch { /* best effort */ }
|
|
1260
|
+
try { await disposeDelegateWorktree(worktree); } catch { /* best effort */ }
|
|
1176
1261
|
worktrees.delete(index);
|
|
1177
1262
|
}
|
|
1178
1263
|
}
|
|
@@ -1185,12 +1270,20 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1185
1270
|
};
|
|
1186
1271
|
|
|
1187
1272
|
let discarded = false;
|
|
1273
|
+
const markStartStopped = (run: DelegateRun) => {
|
|
1274
|
+
run.state = "stopped";
|
|
1275
|
+
run.error = undefined;
|
|
1276
|
+
run.endedAt = Date.now();
|
|
1277
|
+
update();
|
|
1278
|
+
};
|
|
1188
1279
|
handles = runs.map(failedHandle);
|
|
1280
|
+
const started = new Set<number>();
|
|
1189
1281
|
const readyResolvers: Array<() => void> = [];
|
|
1190
1282
|
const ready = runs.map(() => new Promise<void>((resolve) => readyResolvers.push(resolve)));
|
|
1191
|
-
const startRuns = () => runs.
|
|
1283
|
+
const startRuns = async () => Promise.all(runs.map(async (run, index) => {
|
|
1284
|
+
try {
|
|
1192
1285
|
if (discarded) { run.state = "stopped"; readyResolvers[index]!(); return; }
|
|
1193
|
-
const start = (): DelegateHandle => {
|
|
1286
|
+
const start = async (): Promise<DelegateHandle> => {
|
|
1194
1287
|
if ((run.mode === "fork" || run.mode === "worktree") && !parentSession) {
|
|
1195
1288
|
failBeforeStart(run, "fork/worktree mode requires a persisted parent Pi session");
|
|
1196
1289
|
return failedHandle();
|
|
@@ -1201,6 +1294,8 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1201
1294
|
[SUBAGENT_KEY_ENV]: `delegate-${id}-${run.index}`,
|
|
1202
1295
|
[SUBAGENT_NAME_ENV]: run.name
|
|
1203
1296
|
};
|
|
1297
|
+
const effectiveTools = toolsByRun.get(run.index) ?? (run.mode === "worktree" ? WORKTREE_TOOLS : run.mode === "direct" ? undefined : READ_ONLY_TOOLS);
|
|
1298
|
+
if (effectiveTools) env[CHILD_TOOLS_ENV] = JSON.stringify(effectiveTools);
|
|
1204
1299
|
const discussion = options.discussionFile?.();
|
|
1205
1300
|
if (discussion) env[DISCUSSION_FILE_ENV] = discussion;
|
|
1206
1301
|
const hooks: DelegateHooks = {};
|
|
@@ -1210,7 +1305,9 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1210
1305
|
try {
|
|
1211
1306
|
const sessionDir = mkdtempSync(join(tmpdir(), "pi-jar-fork-"));
|
|
1212
1307
|
sessionDirs.set(run.index, sessionDir);
|
|
1213
|
-
|
|
1308
|
+
const requestedTools = toolsByRun.get(run.index);
|
|
1309
|
+
fork = { source: parentSession!, sessionDir,
|
|
1310
|
+
...((run.mode === "worktree" || run.mode === "fork") ? { tools: requestedTools ?? (run.mode === "worktree" ? WORKTREE_TOOLS : READ_ONLY_TOOLS) } : {}) };
|
|
1214
1311
|
} catch (error) {
|
|
1215
1312
|
failBeforeStart(run, error);
|
|
1216
1313
|
return failedHandle();
|
|
@@ -1218,14 +1315,15 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1218
1315
|
}
|
|
1219
1316
|
if (run.mode === "worktree") {
|
|
1220
1317
|
try {
|
|
1221
|
-
const worktree = createDelegateWorktree(ctx.cwd);
|
|
1318
|
+
const worktree = await createDelegateWorktree(ctx.cwd, controllers[index]!.signal);
|
|
1222
1319
|
worktrees.set(run.index, worktree);
|
|
1223
1320
|
run.workspace = worktree.root;
|
|
1224
1321
|
cwd = worktree.cwd;
|
|
1225
1322
|
env[CHILD_WORKTREE_ENV] = worktree.root;
|
|
1226
1323
|
} catch (error) {
|
|
1227
|
-
cleanupPrivate(run.index, true);
|
|
1228
|
-
|
|
1324
|
+
await cleanupPrivate(run.index, true);
|
|
1325
|
+
if (discarded || stopRequested.has(index)) markStartStopped(run);
|
|
1326
|
+
else failBeforeStart(run, error);
|
|
1229
1327
|
return failedHandle();
|
|
1230
1328
|
}
|
|
1231
1329
|
} else if (run.mode === "direct" && baselines) {
|
|
@@ -1233,20 +1331,36 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1233
1331
|
hooks.edited = edited;
|
|
1234
1332
|
}
|
|
1235
1333
|
hooks.env = env;
|
|
1236
|
-
return startDelegate(run, delegateArgs(run.model, thinkingByRun.get(run.index), write, process.argv, fork
|
|
1334
|
+
return startDelegate(run, delegateArgs(run.model, thinkingByRun.get(run.index), write, process.argv, fork,
|
|
1335
|
+
run.mode === "scout" ? toolsByRun.get(run.index) : undefined),
|
|
1237
1336
|
delegatePrompt(run.task, write, run.mode), cwd, controllers[index]!.signal, update, options.spawnProcess, hooks, run.mode !== "direct");
|
|
1238
1337
|
};
|
|
1239
1338
|
let handle: DelegateHandle;
|
|
1240
|
-
try { handle = start(); }
|
|
1241
|
-
catch (error) {
|
|
1339
|
+
try { handle = await start(); }
|
|
1340
|
+
catch (error) {
|
|
1341
|
+
if (discarded || stopRequested.has(index)) markStartStopped(run);
|
|
1342
|
+
else failBeforeStart(run, error);
|
|
1343
|
+
handle = failedHandle();
|
|
1344
|
+
}
|
|
1242
1345
|
handles[index] = handle;
|
|
1346
|
+
started.add(index);
|
|
1243
1347
|
readyResolvers[index]!();
|
|
1244
|
-
void handle.closed.then(() => {
|
|
1245
|
-
cleanupPrivate(run.index, discarded || run.mode !== "worktree");
|
|
1348
|
+
void handle.closed.then(async () => {
|
|
1349
|
+
await cleanupPrivate(run.index, discarded || run.mode !== "worktree");
|
|
1350
|
+
update();
|
|
1351
|
+
}).catch((error) => {
|
|
1352
|
+
run.error ??= "subagent cleanup failed: " + cleanText(error instanceof Error ? error.message : String(error), 240);
|
|
1246
1353
|
update();
|
|
1247
1354
|
});
|
|
1248
|
-
if (discarded) void handle.shutdown().finally(() => cleanupPrivate(run.index, true))
|
|
1249
|
-
|
|
1355
|
+
if (discarded) void handle.shutdown().finally(() => cleanupPrivate(run.index, true))
|
|
1356
|
+
.catch(error => console.error("pi-jar: subagent cleanup failed", error));
|
|
1357
|
+
} catch (error) {
|
|
1358
|
+
failBeforeStart(run, error);
|
|
1359
|
+
} finally {
|
|
1360
|
+
// Even a filesystem cleanup or decoration failure must release lifecycle waiters.
|
|
1361
|
+
readyResolvers[index]!();
|
|
1362
|
+
}
|
|
1363
|
+
}));
|
|
1250
1364
|
|
|
1251
1365
|
const stopping = new Map<number, Promise<SubagentStopReport>>();
|
|
1252
1366
|
const stopRequested = new Set<number>();
|
|
@@ -1254,10 +1368,13 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1254
1368
|
const existing = stopping.get(index);
|
|
1255
1369
|
if (existing) return existing;
|
|
1256
1370
|
stopRequested.add(index);
|
|
1371
|
+
// Cancel asynchronous worktree setup as well as an already-running child before waiting
|
|
1372
|
+
// for ready; otherwise stop can queue behind a stalled startup operation.
|
|
1373
|
+
if (!started.has(index)) controllers[index]!.abort(STOP_REASON);
|
|
1257
1374
|
const promise = finalizeRun(index);
|
|
1258
1375
|
stopping.set(index, promise);
|
|
1259
1376
|
// A reconciliation conflict is retryable after the parent drift is resolved.
|
|
1260
|
-
void promise.then(() => { if (worktrees.has(runs[index]!.index)) stopping.delete(index); });
|
|
1377
|
+
void promise.then(() => { if (worktrees.has(runs[index]!.index)) stopping.delete(index); }, () => { stopping.delete(index); });
|
|
1261
1378
|
return promise;
|
|
1262
1379
|
};
|
|
1263
1380
|
const finalizeRun = async (index: number): Promise<SubagentStopReport> => {
|
|
@@ -1272,15 +1389,15 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1272
1389
|
let changed: string[] = [];
|
|
1273
1390
|
const worktree = worktrees.get(run.index);
|
|
1274
1391
|
if (worktree && !discarded) {
|
|
1275
|
-
try { changed = worktreeChangedFiles(worktree); run.filesEdited = changed; }
|
|
1392
|
+
try { changed = await worktreeChangedFiles(worktree, controllers[index]!.signal); run.filesEdited = changed; }
|
|
1276
1393
|
catch (error) { run.error = "could not inspect isolated changes: " + cleanText(String(error), 240); }
|
|
1277
1394
|
if (changed.length && !run.error) {
|
|
1278
1395
|
try {
|
|
1279
|
-
run.appliedFiles = applyDelegateWorktree(worktree, ctx.cwd, changed, options.changes?.());
|
|
1396
|
+
run.appliedFiles = await applyDelegateWorktree(worktree, ctx.cwd, changed, options.changes?.(), controllers[index]!.signal);
|
|
1280
1397
|
if (run.appliedFiles.length) {
|
|
1281
1398
|
try { options.changed?.(); } catch (error) { console.error("pi-jar: could not refresh parent change UI", error); }
|
|
1282
1399
|
}
|
|
1283
|
-
disposeDelegateWorktree(worktree);
|
|
1400
|
+
await disposeDelegateWorktree(worktree);
|
|
1284
1401
|
worktrees.delete(run.index);
|
|
1285
1402
|
run.workspace = undefined;
|
|
1286
1403
|
} catch (error) {
|
|
@@ -1288,12 +1405,13 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1288
1405
|
run.error = "changes not applied: " + cleanText(error instanceof Error ? error.message : String(error), 240);
|
|
1289
1406
|
}
|
|
1290
1407
|
} else if (!changed.length) {
|
|
1291
|
-
try { disposeDelegateWorktree(worktree); } catch { /* best effort */ }
|
|
1408
|
+
try { await disposeDelegateWorktree(worktree); } catch { /* best effort */ }
|
|
1292
1409
|
worktrees.delete(run.index);
|
|
1293
1410
|
run.workspace = undefined;
|
|
1294
1411
|
}
|
|
1295
1412
|
}
|
|
1296
|
-
cleanupPrivate(run.index,
|
|
1413
|
+
await cleanupPrivate(run.index, discarded);
|
|
1414
|
+
if (discarded) { run.error = undefined; run.workspace = undefined; }
|
|
1297
1415
|
run.state = run.error ? "failed" : "stopped";
|
|
1298
1416
|
run.endedAt ??= Date.now();
|
|
1299
1417
|
update();
|
|
@@ -1306,39 +1424,61 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1306
1424
|
records = runs.map((run, index) => ({
|
|
1307
1425
|
key: `delegate-${id}-${run.index}`, batch: id, run,
|
|
1308
1426
|
stop: () => stopRun(index),
|
|
1309
|
-
pause: () =>
|
|
1427
|
+
pause: async () => {
|
|
1428
|
+
await ready[index];
|
|
1429
|
+
return discarded || stopRequested.has(index) ? false : handles[index]!.pause();
|
|
1430
|
+
},
|
|
1310
1431
|
resume: (text) => !discarded && !stopRequested.has(index) && handles[index]!.resume(text),
|
|
1311
1432
|
steer: (text) => !discarded && !stopRequested.has(index) && handles[index]!.steer(text),
|
|
1312
|
-
ask: (text) =>
|
|
1433
|
+
ask: async (text) => {
|
|
1434
|
+
await ready[index];
|
|
1435
|
+
return discarded || stopRequested.has(index) ? undefined : handles[index]!.ask(text);
|
|
1436
|
+
},
|
|
1313
1437
|
alive: () => handles[index]!.alive(),
|
|
1314
1438
|
closed: () => ready[index]!.then(() => handles[index]!.closed),
|
|
1315
1439
|
changed: () => {
|
|
1316
1440
|
const worktree = worktrees.get(run.index);
|
|
1317
1441
|
if (!worktree) return [...run.filesEdited];
|
|
1318
|
-
|
|
1442
|
+
return [...run.filesEdited]; // synchronous activity snapshots never launch subprocesses
|
|
1319
1443
|
},
|
|
1320
1444
|
discard: () => {
|
|
1321
1445
|
discarded = true;
|
|
1322
1446
|
// Cancellation is synchronous: a concurrent stop must never apply discarded edits.
|
|
1323
1447
|
controllers[index]!.abort(STOP_REASON);
|
|
1324
|
-
void ready[index]!.then(() => handles[index]!.shutdown()).finally(() => cleanupPrivate(run.index, true))
|
|
1448
|
+
void ready[index]!.then(() => handles[index]!.shutdown()).finally(() => cleanupPrivate(run.index, true))
|
|
1449
|
+
.catch(error => console.error("pi-jar: subagent discard failed", error));
|
|
1325
1450
|
}
|
|
1326
1451
|
}));
|
|
1452
|
+
// Initial turns are event-driven too: the retained launch returns immediately so the
|
|
1453
|
+
// moderator can act on user messages instead of remaining trapped in this tool call.
|
|
1454
|
+
// The Pi RPC stream continues independently and the registry wakes the moderator on settle.
|
|
1455
|
+
if (eventBusAvailable) for (const record of records) watched.set(record.key, record);
|
|
1327
1456
|
// Transfer the reservation into queued records before notifying reentrant listeners.
|
|
1328
1457
|
releaseReservation();
|
|
1329
1458
|
registry.add(...records);
|
|
1330
|
-
startRuns();
|
|
1459
|
+
const startup = startRuns();
|
|
1460
|
+
if (eventBusAvailable) {
|
|
1461
|
+
void startup.then(async () => {
|
|
1462
|
+
// Legacy direct writers also run off the main tool path. Remove their shared baseline
|
|
1463
|
+
// directory only after all direct streams drained and their edit events were adopted.
|
|
1464
|
+
await Promise.all(handles.map((handle, index) => runs[index]!.mode === "direct" ? handle.closed : Promise.resolve()));
|
|
1465
|
+
if (baselines) await rm(baselines, { recursive: true, force: true });
|
|
1466
|
+
}).catch((error) => console.error("pi-jar: subagent startup/cleanup failed", error));
|
|
1467
|
+
signal?.removeEventListener("abort", abortAll);
|
|
1468
|
+
publish();
|
|
1469
|
+
toolReturned = true;
|
|
1470
|
+
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() };
|
|
1471
|
+
}
|
|
1472
|
+
await startup;
|
|
1331
1473
|
publish();
|
|
1332
1474
|
|
|
1333
1475
|
try {
|
|
1334
1476
|
await Promise.all(handles.map((handle, index) => runs[index]!.mode === "direct" ? handle.closed : handle.firstSettled));
|
|
1335
1477
|
} finally {
|
|
1336
1478
|
signal?.removeEventListener("abort", abortAll);
|
|
1337
|
-
if (baselines)
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
else if (run.state === "failed") cleanupPrivate(run.index, false);
|
|
1341
|
-
}
|
|
1479
|
+
if (baselines) await rm(baselines, { recursive: true, force: true });
|
|
1480
|
+
await Promise.all(runs.map(run => run.mode === "direct" || run.state === "failed"
|
|
1481
|
+
? cleanupPrivate(run.index, run.mode === "direct") : Promise.resolve()));
|
|
1342
1482
|
registry.notify();
|
|
1343
1483
|
flushUpdate();
|
|
1344
1484
|
toolReturned = true;
|
|
@@ -1386,7 +1526,25 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1386
1526
|
if (!launched) throw new Error(result.content.map((part) => "text" in part ? part.text : "").join("\n"));
|
|
1387
1527
|
const record = registry.get(`delegate-${launched}-1`);
|
|
1388
1528
|
if (!record) throw new Error("Scout session was discarded");
|
|
1389
|
-
|
|
1529
|
+
// This launch is an internal democracy vote; its caller is already awaiting the result,
|
|
1530
|
+
// so the moderator does not need a duplicate unsolicited completion notification.
|
|
1531
|
+
watched.delete(record.key);
|
|
1532
|
+
deleteSettled(record.key);
|
|
1533
|
+
await new Promise<void>((resolve, reject) => {
|
|
1534
|
+
let unsubscribe = () => {};
|
|
1535
|
+
const cleanup = () => { unsubscribe(); signal?.removeEventListener("abort", abort); };
|
|
1536
|
+
const check = () => {
|
|
1537
|
+
if (registry.get(record.key) !== record) { cleanup(); reject(new Error("Scout session was discarded")); return; }
|
|
1538
|
+
if (isRunning(record.run.state)) return;
|
|
1539
|
+
cleanup();
|
|
1540
|
+
if (record.run.error || record.run.state !== "idle") reject(new Error(record.run.error ?? "Scout failed to settle"));
|
|
1541
|
+
else resolve();
|
|
1542
|
+
};
|
|
1543
|
+
const abort = () => { cleanup(); void registry.stop(record.key); reject(new Error("Scout launch aborted")); };
|
|
1544
|
+
unsubscribe = registry.subscribe(check);
|
|
1545
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
1546
|
+
if (signal?.aborted) abort(); else check();
|
|
1547
|
+
});
|
|
1390
1548
|
return snapshot(record);
|
|
1391
1549
|
},
|
|
1392
1550
|
async resumeScout(id, prompt, signal) {
|
|
@@ -1414,14 +1572,28 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1414
1572
|
}
|
|
1415
1573
|
});
|
|
1416
1574
|
|
|
1575
|
+
let operationSequence = 0;
|
|
1576
|
+
const pendingControls = new Map<string, { id: string; action: string; message?: string }>();
|
|
1577
|
+
const deliverControl = (record: SubagentRecord, operation: { id: string; action: string }, result: { content: Array<{ text: string }>; details?: unknown; isError?: boolean }) => {
|
|
1578
|
+
// A session switch discards the old fleet. Never wake a new session with stale handoffs.
|
|
1579
|
+
if (registry.get(record.key) !== record) return;
|
|
1580
|
+
try {
|
|
1581
|
+
pi.sendMessage({
|
|
1582
|
+
customType: SUBAGENT_MESSAGE, display: true,
|
|
1583
|
+
details: { operationId: operation.id, action: operation.action, key: record.key, status: result.isError ? "failed" : "completed", report: result.details },
|
|
1584
|
+
content: `[PI-JAR SUBAGENT] ${operation.action} ${operation.id} completed:\n${result.content.map(part => part.text).join("\n")}`
|
|
1585
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1586
|
+
} catch (error) { console.error("pi-jar: could not deliver a control event", error); }
|
|
1587
|
+
};
|
|
1588
|
+
|
|
1417
1589
|
pi.registerTool({
|
|
1418
1590
|
name: "jar_subagent",
|
|
1419
1591
|
label: "subagent",
|
|
1420
|
-
description: "
|
|
1592
|
+
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.",
|
|
1421
1593
|
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
1594
|
promptGuidelines: [
|
|
1423
|
-
"peek returns only task progress
|
|
1424
|
-
"Use steer
|
|
1595
|
+
"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.",
|
|
1596
|
+
"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.",
|
|
1425
1597
|
"Pause when an agent should stop spending tokens but keep its context/workspace. Resume the same agent later instead of spawning a replacement.",
|
|
1426
1598
|
"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
1599
|
],
|
|
@@ -1434,6 +1606,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1434
1606
|
message: Type.Optional(Type.String({ description: "Direction, BTW question, or resume instruction." }))
|
|
1435
1607
|
}),
|
|
1436
1608
|
async execute(_id, params, signal) {
|
|
1609
|
+
if (signal?.aborted) return { content: [{ type: "text" as const, text: "Cancelled before accepting the operation." }], details: undefined, isError: true };
|
|
1437
1610
|
const work = (async () => {
|
|
1438
1611
|
if (params.action === "peek") {
|
|
1439
1612
|
const targets = params.agent ? [registry.resolve(params.agent)].filter((item): item is SubagentRecord => !!item)
|
|
@@ -1444,6 +1617,15 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1444
1617
|
const agent = params.agent ?? "";
|
|
1445
1618
|
const record = registry.resolve(agent);
|
|
1446
1619
|
if (!record) return { content: [{ type: "text", text: "Subagent not found or name is ambiguous: " + agent }], isError: true };
|
|
1620
|
+
const stopping = pendingControls.get(record.key + ":stop");
|
|
1621
|
+
const controlling = pendingControls.get(record.key + ":control");
|
|
1622
|
+
if (stopping || (controlling && params.action !== "stop")) {
|
|
1623
|
+
const pending = stopping ?? controlling!;
|
|
1624
|
+
if (pending.action === params.action && pending.message === params.message) {
|
|
1625
|
+
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 } };
|
|
1626
|
+
}
|
|
1627
|
+
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 };
|
|
1628
|
+
}
|
|
1447
1629
|
if (params.action === "steer") {
|
|
1448
1630
|
const message = cleanBlock(params.message ?? "", MAX_STEER_CHARS);
|
|
1449
1631
|
if (!message) return { content: [{ type: "text", text: "steer requires message." }], isError: true };
|
|
@@ -1451,18 +1633,20 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1451
1633
|
}
|
|
1452
1634
|
// The moderator is handling this agent directly now; its settle no longer needs a wake-up.
|
|
1453
1635
|
if (params.action !== "resume") watched.delete(record.key);
|
|
1636
|
+
const controlWork = async () => {
|
|
1454
1637
|
if (params.action === "ask") {
|
|
1455
1638
|
const message = cleanBlock(params.message ?? "", MAX_STEER_CHARS);
|
|
1456
1639
|
if (!message) return { content: [{ type: "text", text: "ask requires message." }], isError: true };
|
|
1457
1640
|
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." }] };
|
|
1641
|
+
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 };
|
|
1459
1642
|
}
|
|
1460
1643
|
if (params.action === "pause") {
|
|
1461
1644
|
const paused = await registry.pause(record.key);
|
|
1645
|
+
if (!paused && isRunning(record.run.state) && !pendingControls.has(record.key + ":stop")) watched.set(record.key, record);
|
|
1462
1646
|
const text = paused ? "Paused " + record.run.name + "; context and workspace are retained."
|
|
1463
1647
|
: record.run.state === "working" ? record.run.name + " has not settled yet; it pauses when its current turn ends."
|
|
1464
1648
|
: "Could not pause " + record.run.name + ".";
|
|
1465
|
-
return { content: [{ type: "text", text }] };
|
|
1649
|
+
return { content: [{ type: "text", text }], isError: !paused };
|
|
1466
1650
|
}
|
|
1467
1651
|
if (params.action === "resume") {
|
|
1468
1652
|
const resumed = registry.resume(record.key, params.message);
|
|
@@ -1471,7 +1655,26 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1471
1655
|
}
|
|
1472
1656
|
const report = await registry.stop(record.key);
|
|
1473
1657
|
if (!report) return { content: [{ type: "text", text: "Subagent is already retired: " + record.run.name }] };
|
|
1474
|
-
return { content: [{ type: "text", text: controlReport(report) }], details: report };
|
|
1658
|
+
return { content: [{ type: "text", text: controlReport(report) }], details: report, isError: !!report.error };
|
|
1659
|
+
};
|
|
1660
|
+
// Internal registry APIs remain awaited for shutdown/reconciliation correctness. Only the
|
|
1661
|
+
// external tool boundary is detached, so parent steering never waits on a child turn or Git.
|
|
1662
|
+
if (eventBusAvailable && params.action !== "resume") {
|
|
1663
|
+
if (params.action === "ask" && !params.message?.trim()) return { content: [{ type: "text", text: "ask requires message." }], isError: true };
|
|
1664
|
+
const operation = { id: `subagent-op-${++operationSequence}`, action: params.action, message: params.message };
|
|
1665
|
+
const pendingKey = record.key + (params.action === "stop" ? ":stop" : ":control");
|
|
1666
|
+
pendingControls.set(pendingKey, operation);
|
|
1667
|
+
deleteSettled(record.key);
|
|
1668
|
+
void Promise.resolve().then(controlWork).then(result => {
|
|
1669
|
+
if (pendingControls.get(pendingKey) === operation) pendingControls.delete(pendingKey);
|
|
1670
|
+
deliverControl(record, operation, result);
|
|
1671
|
+
}, error => {
|
|
1672
|
+
if (pendingControls.get(pendingKey) === operation) pendingControls.delete(pendingKey);
|
|
1673
|
+
deliverControl(record, operation, { content: [{ text: error instanceof Error ? error.message : String(error) }], isError: true });
|
|
1674
|
+
});
|
|
1675
|
+
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 } };
|
|
1676
|
+
}
|
|
1677
|
+
return controlWork();
|
|
1475
1678
|
})();
|
|
1476
1679
|
// Pi waits on a tool until it resolves, so Esc must never be stuck behind a child that ignores abort.
|
|
1477
1680
|
const result = await untilAborted(work, signal)
|