pi-jar 0.1.8 → 0.2.0
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 +26 -13
- package/docs/DISCUSSION_BROKER_PLAN.md +333 -0
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/LONG_SESSION_PERFORMANCE_PLAN.md +731 -0
- package/docs/SUBAGENT-TOOLS.md +2 -0
- package/extensions/index.ts +304 -76
- package/package.json +2 -2
- package/src/activity-view.ts +10 -6
- package/src/advisor.ts +226 -73
- package/src/composer.ts +21 -4
- package/src/context-diet.ts +94 -0
- package/src/delegate-worktree.ts +33 -13
- package/src/delegate.ts +736 -429
- package/src/democracy.ts +6 -4
- package/src/discussion-hub.ts +418 -0
- package/src/discussion.ts +419 -187
- package/src/footer-settings.ts +3 -29
- package/src/model-roles.ts +29 -9
- package/src/perf.ts +419 -0
- package/src/profile-wizard.ts +61 -0
- package/src/profiles.ts +148 -0
- package/src/quota.ts +65 -25
- package/src/settings-ui.ts +46 -14
- package/src/settings.ts +6 -13
- package/src/shell-notifications.ts +33 -6
- package/src/shells.ts +138 -29
- package/src/side-model.ts +3 -1
- package/src/welcome.ts +6 -1
package/src/delegate.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import { signalProcessTree } from "./async-process.ts";
|
|
3
3
|
import { CHILD_TOOLS_ENV, RECURSIVE_TOOLS, WEB_TOOLS } from "./subagent-tools.ts";
|
|
4
|
-
import { existsSync, mkdtempSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdtempSync, realpathSync } from "node:fs";
|
|
5
5
|
import { rm } from "node:fs/promises";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
|
-
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { Text } from "@earendil-works/pi-tui";
|
|
@@ -14,7 +14,7 @@ import { CHILD_BASELINE_ENV, readChildBaseline } from "./child-baselines.ts";
|
|
|
14
14
|
import type { ModelRoleManager } from "./model-roles.ts";
|
|
15
15
|
import { cleanText, ROLE_PREFIX } from "./status.ts";
|
|
16
16
|
import type { Todo } from "./tasks.ts";
|
|
17
|
-
import {
|
|
17
|
+
import { DISCUSSION_ENV_KEYS, SUBAGENT_KEY_ENV, SUBAGENT_NAME_ENV } from "./discussion.ts";
|
|
18
18
|
import {
|
|
19
19
|
applyDelegateWorktree,
|
|
20
20
|
CHILD_WORKTREE_ENV,
|
|
@@ -47,6 +47,10 @@ const IDLE_TIMEOUT_MS = 10 * 60_000;
|
|
|
47
47
|
const CLOSE_GRACE_MS = 2000;
|
|
48
48
|
/** How long pause waits for the child to settle after abort before reporting that it has not paused yet. */
|
|
49
49
|
const PAUSE_GRACE_MS = 15_000;
|
|
50
|
+
/** A settled retained child closes its Pi process after this much quiet; resume/ask relaunch it on the same session. */
|
|
51
|
+
export const HIBERNATE_AFTER_MS = 30_000;
|
|
52
|
+
/** How long hibernation waits for the child to report its session file before it stays awake instead. */
|
|
53
|
+
const STATE_QUERY_MS = 5000;
|
|
50
54
|
const STATUS_REFRESH_MS = 20_000;
|
|
51
55
|
const LIVE_UPDATE_MS = 1000;
|
|
52
56
|
/** Visible message that wakes the moderator when a resumed subagent's turn ends. */
|
|
@@ -69,6 +73,10 @@ const MAX_STEER_CHARS = 4000;
|
|
|
69
73
|
export const MAX_RESUME_CHARS = 40_000;
|
|
70
74
|
/** Finished subagents the registry keeps for the activity view after their tool call returns. */
|
|
71
75
|
const MAX_FINISHED = 8;
|
|
76
|
+
/** Unresolved worktree recovery workspaces, counting active worktree agents that may still become one. */
|
|
77
|
+
export const RECOVERY_LIMIT = 4;
|
|
78
|
+
/** A recovery record keeps this much of its last report; its transcript is released. */
|
|
79
|
+
const MAX_RECOVERY_OUTPUT = 1200;
|
|
72
80
|
/** Abort reason for stopping one subagent from the activity view (vs. aborting the whole tool call). */
|
|
73
81
|
const STOP_REASON = "stopped";
|
|
74
82
|
/** Tool arguments that best describe a call in the transcript, most telling first. */
|
|
@@ -77,7 +85,7 @@ const HINT_KEYS = ["command", "path", "file_path", "pattern", "query", "url"] as
|
|
|
77
85
|
const DIALOGS = new Set(["select", "confirm", "input", "editor"]);
|
|
78
86
|
const ANSI = /\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g;
|
|
79
87
|
|
|
80
|
-
export type DelegateState = "queued" | "working" | "idle" | "paused" | "stopped" | "done" | "failed";
|
|
88
|
+
export type DelegateState = "queued" | "working" | "idle" | "paused" | "hibernated" | "stopped" | "done" | "failed";
|
|
81
89
|
export type ToolStatus = "running" | "done" | "error";
|
|
82
90
|
/** One transcript row; `rev` changes whenever the entry does, so views can cache their rendering. */
|
|
83
91
|
export type TranscriptEntry =
|
|
@@ -145,26 +153,36 @@ export function extensionFlags(argv = process.argv, cwd = process.cwd()): string
|
|
|
145
153
|
return flags;
|
|
146
154
|
}
|
|
147
155
|
|
|
148
|
-
export interface
|
|
149
|
-
|
|
156
|
+
export interface DelegateSessionOptions {
|
|
157
|
+
/** This child's own private session directory; removed when its record is permanently discarded. */
|
|
150
158
|
sessionDir: string;
|
|
159
|
+
/** fork/worktree: the parent session this child forks, so it inherits the conversation. */
|
|
160
|
+
source?: string;
|
|
161
|
+
/** This child's own session file, continued after hibernation instead of creating or forking one. */
|
|
162
|
+
resume?: string;
|
|
151
163
|
tools?: readonly string[];
|
|
152
164
|
}
|
|
153
165
|
|
|
154
|
-
/** CLI arguments for one subagent:
|
|
166
|
+
/** CLI arguments for one subagent: an ephemeral one-shot, a private resumable session, or a real Pi session fork. */
|
|
155
167
|
export function delegateArgs(model: string | undefined, thinking: string | undefined, write: boolean, argv = process.argv,
|
|
156
|
-
|
|
157
|
-
const
|
|
168
|
+
session?: DelegateSessionOptions, toolOverride?: readonly string[]): string[] {
|
|
169
|
+
const sessionArgs = !session ? ["--no-session"]
|
|
170
|
+
: session.resume ? ["--session", session.resume, "--session-dir", session.sessionDir]
|
|
171
|
+
: session.source ? ["--fork", session.source, "--session-dir", session.sessionDir]
|
|
172
|
+
: ["--session-dir", session.sessionDir];
|
|
158
173
|
const extensions = extensionFlags(argv);
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
174
|
+
const tools = session?.tools ?? toolOverride ?? (!write ? READ_ONLY_TOOLS : undefined);
|
|
175
|
+
const ownExtension = fileURLToPath(new URL("../extensions/index.ts", import.meta.url));
|
|
176
|
+
// Default read-only children need only core file tools and pi-jar. Avoid importing every
|
|
177
|
+
// personal extension (and opening unused MCP/provider connections) for each scout.
|
|
178
|
+
// Explicit extension flags and optional inherited tools retain the parent's full setup.
|
|
179
|
+
const lean = !write && extensions.length === 0 && tools?.every(tool => (READ_ONLY_TOOLS as readonly string[]).includes(tool));
|
|
180
|
+
if (lean) extensions.push("--no-extensions", "--extension", ownExtension);
|
|
181
|
+
// Worktree cwd/session forks (resumed ones too) do not inherit project extension discovery from the parent.
|
|
182
|
+
else if (session?.source && !extensions.includes(ownExtension)) extensions.push("--extension", ownExtension);
|
|
183
|
+
const args = ["--mode", "rpc", ...sessionArgs, ...extensions];
|
|
165
184
|
if (model) args.push("--model", model);
|
|
166
185
|
if (thinking) args.push("--thinking", thinking);
|
|
167
|
-
const tools = fork?.tools ?? toolOverride ?? (!write ? READ_ONLY_TOOLS : undefined);
|
|
168
186
|
if (tools) args.push("--tools", tools.join(","));
|
|
169
187
|
return args;
|
|
170
188
|
}
|
|
@@ -172,7 +190,7 @@ export function delegateArgs(model: string | undefined, thinking: string | undef
|
|
|
172
190
|
/** The subagent's prompt: its rules, how to report back, then the task. */
|
|
173
191
|
export function delegatePrompt(task: string, write: boolean, mode: DelegateExecutionMode = write ? "direct" : "scout"): string {
|
|
174
192
|
const rules = mode === "worktree"
|
|
175
|
-
? "You inherit the parent conversation but work inside an isolated disposable Git worktree. You may edit only with the provided file tools; shell tools are intentionally unavailable. Stay strictly within the task. Your changes remain private across idle
|
|
193
|
+
? "You inherit the parent conversation but work inside an isolated disposable Git worktree. You may edit only with the provided file tools; shell tools are intentionally unavailable. Stay strictly within the task. Your changes remain private across idle, pause, hibernation and resume, and are reconciled into the parent for /diff review only when the moderator stops you."
|
|
176
194
|
: mode === "fork"
|
|
177
195
|
? "You are a read-only fork of the parent conversation: use the inherited context, investigate, and report without modifying files."
|
|
178
196
|
: write
|
|
@@ -181,7 +199,7 @@ export function delegatePrompt(task: string, write: boolean, mode: DelegateExecu
|
|
|
181
199
|
return [
|
|
182
200
|
`You are a focused subagent working for another agent. ${rules}`,
|
|
183
201
|
"Track multi-step work with jar_todo (when available) so your progress is visible. The moderator may steer, pause or resume you; preserve scope across those controls.",
|
|
184
|
-
"Use jar_discuss when available for short cross-agent questions and answers. Keep discussion
|
|
202
|
+
"Use jar_discuss when available for short cross-agent questions and answers. Keep discussion messages narrow; it is for coordination, not a transcript.",
|
|
185
203
|
"Finish with a self-contained report in these sections:",
|
|
186
204
|
"## Summary — the outcome in one to three sentences.",
|
|
187
205
|
"## Details — findings or changes, with file paths (and line numbers where useful).",
|
|
@@ -293,7 +311,7 @@ interface ChildEvent {
|
|
|
293
311
|
id?: unknown;
|
|
294
312
|
command?: unknown;
|
|
295
313
|
success?: unknown;
|
|
296
|
-
data?: { disposition?: unknown };
|
|
314
|
+
data?: { disposition?: unknown; sessionFile?: unknown };
|
|
297
315
|
error?: unknown;
|
|
298
316
|
method?: unknown;
|
|
299
317
|
toolCallId?: unknown;
|
|
@@ -307,314 +325,370 @@ interface ChildEvent {
|
|
|
307
325
|
}
|
|
308
326
|
|
|
309
327
|
export interface DelegateHooks {
|
|
310
|
-
/** Extra environment for
|
|
311
|
-
env?: Record<string, string>;
|
|
328
|
+
/** Extra environment for each child process: read again when a hibernated child relaunches. */
|
|
329
|
+
env?: () => Record<string, string>;
|
|
312
330
|
/** A successful edit/write by the child, with the path it gave. */
|
|
313
331
|
edited?(path: string): void;
|
|
332
|
+
/** CLI arguments continuing this child's own session file, or undefined to refuse it. Without it the child never hibernates. */
|
|
333
|
+
resumeArgs?(sessionFile: string): string[] | undefined;
|
|
334
|
+
/** Settled quiet time before a resumable child's process closes; absent or non-finite never hibernates. */
|
|
335
|
+
hibernateAfterMs?: number;
|
|
314
336
|
}
|
|
315
337
|
export interface DelegateHandle {
|
|
316
338
|
/** First point where the child is idle after its initial task. */
|
|
317
339
|
firstSettled: Promise<void>;
|
|
318
|
-
/**
|
|
340
|
+
/** The agent's permanent end. Hibernation closes only the process; resume and ask relaunch it. */
|
|
319
341
|
closed: Promise<void>;
|
|
320
342
|
steer(text: string): boolean;
|
|
321
343
|
pause(): Promise<boolean>;
|
|
322
344
|
resume(text?: string): boolean;
|
|
323
345
|
ask(text: string): Promise<string | undefined>;
|
|
324
346
|
shutdown(): Promise<void>;
|
|
347
|
+
/** Whether a child process is running right now; false while hibernated. */
|
|
325
348
|
alive(): boolean;
|
|
349
|
+
pid(): number | undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** One Pi process of a subagent; a hibernating agent runs a series of them on one session file. */
|
|
353
|
+
interface RpcProcess {
|
|
354
|
+
child: ChildProcess;
|
|
355
|
+
/** Until exit: control commands can still reach the child. */
|
|
356
|
+
alive: boolean;
|
|
357
|
+
/** Closing on purpose to hibernate, so its exit is not the agent's end. */
|
|
358
|
+
hibernating: boolean;
|
|
359
|
+
stderr: string;
|
|
360
|
+
killTimer?: NodeJS.Timeout;
|
|
361
|
+
/** Final teardown once stdio closed or stopped draining; idempotent. */
|
|
362
|
+
exited(code: number | null): void;
|
|
363
|
+
/** Resolves after `exited`. */
|
|
364
|
+
ended: Promise<void>;
|
|
326
365
|
}
|
|
327
366
|
|
|
328
367
|
/**
|
|
329
|
-
* Run one
|
|
330
|
-
*
|
|
368
|
+
* Run one subagent over Pi RPC. Persistent children stay retained after agent_settled so the moderator
|
|
369
|
+
* can peek, steer, pause, resume and ask them without rebuilding context. A resumable child that stays
|
|
370
|
+
* settled hibernates: its process closes cleanly, and resume/ask relaunch Pi on the same session file.
|
|
331
371
|
*/
|
|
332
372
|
export function startDelegate(run: DelegateRun, args: string[], prompt: string, cwd: string, signal: AbortSignal | undefined,
|
|
333
373
|
update: () => void, spawnProcess: Spawn = spawn as Spawn, hooks: DelegateHooks = {}, persistent = true): DelegateHandle {
|
|
334
|
-
let steer: (text: string) => boolean = () => false;
|
|
335
|
-
let pause: () => Promise<boolean> = async () => false;
|
|
336
|
-
let resume: (text?: string) => boolean = () => false;
|
|
337
|
-
let ask: (text: string) => Promise<string | undefined> = async () => undefined;
|
|
338
|
-
let shutdown: () => Promise<void> = async () => {};
|
|
339
|
-
let alive = false;
|
|
340
374
|
let resolveFirst!: () => void;
|
|
341
375
|
let firstResolved = false;
|
|
342
376
|
const firstSettled = new Promise<void>((resolve) => { resolveFirst = resolve; });
|
|
377
|
+
let resolveClosed!: () => void;
|
|
378
|
+
const closed = new Promise<void>((resolve) => { resolveClosed = resolve; });
|
|
343
379
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
380
|
+
/** The running process: none before the first spawn, after the end, or while hibernated. */
|
|
381
|
+
let current: RpcProcess | undefined;
|
|
382
|
+
let finished = false;
|
|
383
|
+
let settled = false;
|
|
384
|
+
let pauseRequested = false;
|
|
385
|
+
let closing = false;
|
|
386
|
+
let shutdownPromise: Promise<void> | undefined;
|
|
387
|
+
let askBusy = false;
|
|
388
|
+
let liveAt = 0;
|
|
389
|
+
let liveTimer: NodeJS.Timeout | undefined;
|
|
390
|
+
let turnTimer: NodeJS.Timeout | undefined;
|
|
391
|
+
let hibernateTimer: NodeJS.Timeout | undefined;
|
|
392
|
+
/** Last RPC event while working; the watchdog only fires after this much silence. */
|
|
393
|
+
let activityAt = Date.now();
|
|
394
|
+
/** How to continue this child's session after hibernation, learned from Pi before its process closed. */
|
|
395
|
+
let relaunchArgs: string[] | undefined;
|
|
396
|
+
/** A hibernated agent's settled state: a BTW answer returns a paused agent to paused. */
|
|
397
|
+
let rest: "idle" | "paused" = "idle";
|
|
398
|
+
/** A prompt accepted while the hibernating process was still exiting; its relaunch sends it. */
|
|
399
|
+
let wake: { message: string; id: string } | undefined;
|
|
400
|
+
let querySequence = 0;
|
|
401
|
+
const queries = new Map<string, (file: string | undefined) => void>();
|
|
402
|
+
const settleWaiters: Array<() => void> = [];
|
|
403
|
+
const running = new Map<string, { entry: ToolEntry; path?: string }>();
|
|
355
404
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
405
|
+
const send = (command: object): boolean => {
|
|
406
|
+
const stdin = current?.alive ? current.child.stdin : undefined;
|
|
407
|
+
if (!stdin || stdin.destroyed || stdin.writableEnded) return false;
|
|
408
|
+
// Node queues writes until drain. Bound that queue if a hung RPC child stops reading;
|
|
409
|
+
// accepted commands remain complete JSONL records, never partial/manual framing.
|
|
410
|
+
if (stdin.writableLength > 256 * 1024) return false;
|
|
411
|
+
try { stdin.write(JSON.stringify(command) + "\n"); return true; } catch { return false; }
|
|
412
|
+
};
|
|
413
|
+
const clearTurnTimer = () => { clearTimeout(turnTimer); turnTimer = undefined; };
|
|
414
|
+
const hardStop = () => {
|
|
415
|
+
const proc = current;
|
|
416
|
+
if (!proc?.alive) return;
|
|
417
|
+
signalProcessTree(proc.child, "SIGTERM");
|
|
418
|
+
proc.killTimer ??= setTimeout(() => {
|
|
419
|
+
// Kill the group even if its leader exited: descendants can ignore SIGTERM and keep pipes.
|
|
420
|
+
signalProcessTree(proc.child, "SIGKILL");
|
|
421
|
+
proc.child.stdout?.destroy?.();
|
|
422
|
+
proc.child.stderr?.destroy?.();
|
|
423
|
+
proc.exited(proc.child.exitCode);
|
|
424
|
+
}, 3000);
|
|
425
|
+
proc.killTimer.unref?.();
|
|
426
|
+
};
|
|
427
|
+
const armTurnTimer = (delay = IDLE_TIMEOUT_MS) => {
|
|
428
|
+
clearTurnTimer();
|
|
429
|
+
turnTimer = setTimeout(() => {
|
|
430
|
+
turnTimer = undefined;
|
|
431
|
+
const quiet = Date.now() - activityAt;
|
|
432
|
+
if (quiet < IDLE_TIMEOUT_MS) { armTurnTimer(IDLE_TIMEOUT_MS - quiet); return; }
|
|
433
|
+
hardStop();
|
|
434
|
+
finish("failed", `timed out: no activity for ${duration(IDLE_TIMEOUT_MS)}`);
|
|
435
|
+
}, delay);
|
|
436
|
+
turnTimer.unref?.();
|
|
437
|
+
};
|
|
438
|
+
/** High-frequency stream events repaint at most every LIVE_REPAINT_MS. */
|
|
439
|
+
const repaint = () => {
|
|
440
|
+
const now = Date.now();
|
|
441
|
+
if (now - liveAt >= LIVE_REPAINT_MS) {
|
|
442
|
+
liveAt = now;
|
|
443
|
+
if (liveTimer) { clearTimeout(liveTimer); liveTimer = undefined; }
|
|
444
|
+
update();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (liveTimer) return;
|
|
448
|
+
liveTimer = setTimeout(() => { liveTimer = undefined; liveAt = Date.now(); if (!finished) update(); }, LIVE_REPAINT_MS - (now - liveAt));
|
|
449
|
+
liveTimer.unref?.();
|
|
450
|
+
};
|
|
451
|
+
const resolveSettled = () => {
|
|
452
|
+
for (const waiter of settleWaiters.splice(0)) waiter();
|
|
453
|
+
};
|
|
454
|
+
const waitSettled = () => settled || finished ? Promise.resolve() : new Promise<void>((resolve) => settleWaiters.push(resolve));
|
|
455
|
+
const finish = (state: DelegateState, error?: string) => {
|
|
456
|
+
if (finished) return;
|
|
457
|
+
finished = true;
|
|
458
|
+
clearTurnTimer();
|
|
459
|
+
clearTimeout(liveTimer);
|
|
460
|
+
clearTimeout(hibernateTimer);
|
|
461
|
+
wake = undefined;
|
|
462
|
+
for (const answer of [...queries.values()]) answer(undefined);
|
|
463
|
+
signal?.removeEventListener("abort", abort);
|
|
464
|
+
const stdin = current?.child.stdin;
|
|
465
|
+
if (stdin && !stdin.writableEnded) stdin.end();
|
|
466
|
+
run.state = state;
|
|
467
|
+
if (error) run.error = error;
|
|
468
|
+
run.activity = undefined;
|
|
469
|
+
run.live = "";
|
|
470
|
+
for (const entry of run.transcript) if (entry.kind === "tool" && entry.status === "running") { entry.status = "error"; entry.endedAt = Date.now(); entry.rev++; }
|
|
471
|
+
running.clear();
|
|
472
|
+
run.endedAt = Date.now();
|
|
473
|
+
resolveSettled();
|
|
474
|
+
if (!firstResolved) { firstResolved = true; resolveFirst(); }
|
|
361
475
|
update();
|
|
476
|
+
// With no process left (spawn failure, or ended while hibernated) nothing else reports the end.
|
|
477
|
+
if (!current) resolveClosed();
|
|
478
|
+
};
|
|
479
|
+
const abort = () => { hardStop(); finish("failed", signal?.reason === STOP_REASON ? "stopped" : "aborted"); };
|
|
362
480
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
};
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
481
|
+
/** Settled, persistent and running: the process could close without losing the agent. */
|
|
482
|
+
const sleepable = () => persistent && !finished && !closing && settled && !!current?.alive && !current.hibernating
|
|
483
|
+
&& (run.state === "idle" || run.state === "paused");
|
|
484
|
+
const armHibernate = () => {
|
|
485
|
+
clearTimeout(hibernateTimer);
|
|
486
|
+
hibernateTimer = undefined;
|
|
487
|
+
const delay = hooks.hibernateAfterMs;
|
|
488
|
+
if (!hooks.resumeArgs || delay === undefined || !Number.isFinite(delay) || !sleepable()) return;
|
|
489
|
+
hibernateTimer = setTimeout(() => { hibernateTimer = undefined; void hibernate(); }, Math.max(0, delay));
|
|
490
|
+
hibernateTimer.unref?.();
|
|
491
|
+
};
|
|
492
|
+
/** The session file Pi reports for this child; undefined when it cannot say in time. */
|
|
493
|
+
const querySessionFile = () => new Promise<string | undefined>((resolve) => {
|
|
494
|
+
const id = `state-${++querySequence}`;
|
|
495
|
+
let timer: NodeJS.Timeout | undefined;
|
|
496
|
+
const answer = (file: string | undefined) => { clearTimeout(timer); queries.delete(id); resolve(file); };
|
|
497
|
+
queries.set(id, answer);
|
|
498
|
+
timer = setTimeout(() => answer(undefined), STATE_QUERY_MS);
|
|
499
|
+
timer.unref?.();
|
|
500
|
+
if (!send({ id, type: "get_state" })) answer(undefined);
|
|
501
|
+
});
|
|
502
|
+
/** Close a settled child's process but keep the agent: its session file is all a relaunch needs. */
|
|
503
|
+
const hibernate = async () => {
|
|
504
|
+
if (!sleepable() || askBusy) return;
|
|
505
|
+
const proc = current!;
|
|
506
|
+
const file = await querySessionFile();
|
|
507
|
+
// A session Pi has not written yet (no assistant message) has nothing to continue, so the child
|
|
508
|
+
// stays up. Anything that touched the agent meanwhile (resume, ask, stop) also wins over sleeping.
|
|
509
|
+
const continued = file && existsSync(file) ? hooks.resumeArgs?.(file) : undefined;
|
|
510
|
+
if (!continued || !sleepable() || askBusy || current !== proc) return;
|
|
511
|
+
relaunchArgs = continued;
|
|
512
|
+
proc.hibernating = true;
|
|
513
|
+
run.activity = "hibernating";
|
|
514
|
+
update();
|
|
515
|
+
// EOF is Pi's orderly shutdown: it disposes the runtime, and session entries are already on disk.
|
|
516
|
+
const stdin = proc.child.stdin;
|
|
517
|
+
if (stdin && !stdin.writableEnded) stdin.end();
|
|
518
|
+
// A child that ignores EOF must not linger: the same escalation as shutdown.
|
|
519
|
+
const killer = setTimeout(() => { if (current === proc) hardStop(); }, 3000);
|
|
520
|
+
killer.unref?.();
|
|
521
|
+
await proc.ended;
|
|
522
|
+
clearTimeout(killer);
|
|
523
|
+
};
|
|
524
|
+
const settleTurn = () => {
|
|
525
|
+
if (settled || finished) return;
|
|
526
|
+
settled = true;
|
|
527
|
+
clearTurnTimer();
|
|
528
|
+
for (const call of running.values()) {
|
|
529
|
+
call.entry.status = "error";
|
|
530
|
+
call.entry.endedAt = Date.now();
|
|
531
|
+
call.entry.rev++;
|
|
532
|
+
}
|
|
533
|
+
running.clear();
|
|
534
|
+
run.live = "";
|
|
535
|
+
run.activity = undefined;
|
|
536
|
+
run.endedAt = Date.now();
|
|
537
|
+
run.state = run.error ? "failed" : pauseRequested ? "paused" : "idle";
|
|
538
|
+
pauseRequested = false;
|
|
539
|
+
if (!firstResolved && !run.error) { firstResolved = true; resolveFirst(); }
|
|
540
|
+
resolveSettled();
|
|
541
|
+
update();
|
|
542
|
+
if (run.error) { hardStop(); finish("failed", run.error); return; }
|
|
543
|
+
const stdin = current?.child.stdin;
|
|
544
|
+
if (!persistent) { if (stdin && !stdin.writableEnded) stdin.end(); }
|
|
545
|
+
else armHibernate();
|
|
546
|
+
};
|
|
547
|
+
const line = (raw: string) => {
|
|
548
|
+
if (finished || !raw.trim()) return;
|
|
549
|
+
let parsed: unknown;
|
|
550
|
+
try { parsed = JSON.parse(raw); } catch { return; }
|
|
551
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
552
|
+
const event = parsed as ChildEvent;
|
|
553
|
+
activityAt = Date.now();
|
|
554
|
+
switch (event.type) {
|
|
555
|
+
case "response": {
|
|
556
|
+
const query = typeof event.id === "string" ? queries.get(event.id) : undefined;
|
|
557
|
+
if (query) { query(event.success !== false && typeof event.data?.sessionFile === "string" ? event.data.sessionFile : undefined); return; }
|
|
558
|
+
// A handled prompt starts no run, so Pi deliberately sends no agent_settled event.
|
|
559
|
+
if (event.success !== false) {
|
|
560
|
+
if (event.command === "prompt" && event.data?.disposition === "handled") settleTurn();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (event.command === "prompt") { hardStop(); finish("failed", cleanText(String(event.error ?? "prompt rejected"), 300)); return; }
|
|
564
|
+
pushEntry(run, { kind: "note", rev: 0, text: `${cleanText(String(event.command ?? "command"), 24)} rejected: ${cleanText(String(event.error ?? ""), 200)}` });
|
|
419
565
|
update();
|
|
420
566
|
return;
|
|
421
567
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const finish = (state: DelegateState, error?: string) => {
|
|
431
|
-
if (finished) return;
|
|
432
|
-
finished = true;
|
|
433
|
-
clearTurnTimer();
|
|
434
|
-
clearTimeout(liveTimer);
|
|
435
|
-
signal?.removeEventListener("abort", abort);
|
|
436
|
-
if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
|
|
437
|
-
run.state = state;
|
|
438
|
-
if (error) run.error = error;
|
|
439
|
-
run.activity = undefined;
|
|
440
|
-
run.live = "";
|
|
441
|
-
for (const entry of run.transcript) if (entry.kind === "tool" && entry.status === "running") { entry.status = "error"; entry.endedAt = Date.now(); entry.rev++; }
|
|
442
|
-
run.endedAt = Date.now();
|
|
443
|
-
resolveSettled();
|
|
444
|
-
if (!firstResolved) { firstResolved = true; resolveFirst(); }
|
|
445
|
-
update();
|
|
446
|
-
};
|
|
447
|
-
const abort = () => { hardStop(); finish("failed", signal?.reason === STOP_REASON ? "stopped" : "aborted"); };
|
|
448
|
-
|
|
449
|
-
child.stdin?.on("error", (error) => { if (!finished) stderr = (stderr + "\n" + error.message).slice(-4000); });
|
|
450
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
451
|
-
armTurnTimer();
|
|
452
|
-
|
|
453
|
-
const beginPrompt = (message: string, pauseAfter = false): boolean => {
|
|
454
|
-
const clean = cleanBlock(message, MAX_RESUME_CHARS);
|
|
455
|
-
if (finished || closing || !alive || !settled || !clean) return false;
|
|
456
|
-
if (!send({ id: "prompt-" + (run.resumes + 1), type: "prompt", message: clean })) return false;
|
|
457
|
-
settled = false;
|
|
458
|
-
run.error = undefined;
|
|
459
|
-
run.output = "";
|
|
460
|
-
pauseRequested = pauseAfter;
|
|
461
|
-
run.state = "working";
|
|
462
|
-
run.resumes++;
|
|
463
|
-
run.endedAt = undefined;
|
|
464
|
-
run.activity = "resuming";
|
|
465
|
-
activityAt = Date.now();
|
|
466
|
-
armTurnTimer();
|
|
467
|
-
update();
|
|
468
|
-
return true;
|
|
469
|
-
};
|
|
470
|
-
|
|
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
|
-
};
|
|
493
|
-
const line = (raw: string) => {
|
|
494
|
-
if (finished || !raw.trim()) return;
|
|
495
|
-
let parsed: unknown;
|
|
496
|
-
try { parsed = JSON.parse(raw); } catch { return; }
|
|
497
|
-
if (!parsed || typeof parsed !== "object") return;
|
|
498
|
-
const event = parsed as ChildEvent;
|
|
499
|
-
activityAt = Date.now();
|
|
500
|
-
switch (event.type) {
|
|
501
|
-
case "response":
|
|
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
|
-
}
|
|
507
|
-
if (event.command === "prompt") { hardStop(); finish("failed", cleanText(String(event.error ?? "prompt rejected"), 300)); return; }
|
|
508
|
-
pushEntry(run, { kind: "note", rev: 0, text: `${cleanText(String(event.command ?? "command"), 24)} rejected: ${cleanText(String(event.error ?? ""), 200)}` });
|
|
509
|
-
update();
|
|
510
|
-
return;
|
|
511
|
-
case "extension_ui_request":
|
|
512
|
-
if (DIALOGS.has(String(event.method))) send({ type: "extension_ui_response", id: event.id, cancelled: true });
|
|
513
|
-
return;
|
|
514
|
-
case "message_update": {
|
|
515
|
-
const delta = event.assistantMessageEvent;
|
|
516
|
-
if (delta?.type === "text_delta" && typeof delta.delta === "string") {
|
|
517
|
-
run.live = (run.live + delta.delta).slice(-MAX_LIVE_CHARS);
|
|
518
|
-
run.activity = "writing";
|
|
519
|
-
repaint();
|
|
520
|
-
} else if ((delta?.type === "thinking_start" || delta?.type === "thinking_delta") && run.activity !== "thinking") {
|
|
521
|
-
run.activity = "thinking";
|
|
522
|
-
update();
|
|
523
|
-
}
|
|
524
|
-
return;
|
|
525
|
-
}
|
|
526
|
-
case "tool_execution_start": {
|
|
527
|
-
const name = cleanText(String(event.toolName ?? "tool"), 24) || "tool";
|
|
528
|
-
const hint = cleanText(toolHint(event.args), 200);
|
|
529
|
-
const id = typeof event.toolCallId === "string" && event.toolCallId ? event.toolCallId : `call-${run.tools}`;
|
|
530
|
-
const entry: ToolEntry = { kind: "tool", rev: 0, id, name, hint, args: argsText(event.args), status: "running", output: "", startedAt: Date.now() };
|
|
531
|
-
const path = toolPath(event.args);
|
|
532
|
-
run.tools++;
|
|
533
|
-
run.toolCounts[name] = (run.toolCounts[name] ?? 0) + 1;
|
|
534
|
-
if (name === "read" && path) addFile(run.filesRead, path);
|
|
535
|
-
running.set(id, { entry, ...(path ? { path } : {}) });
|
|
536
|
-
pushEntry(run, entry);
|
|
537
|
-
run.activity = cleanText(hint ? `${name} ${hint}` : name, 60);
|
|
538
|
-
update();
|
|
539
|
-
return;
|
|
540
|
-
}
|
|
541
|
-
case "tool_execution_update": {
|
|
542
|
-
const call = typeof event.toolCallId === "string" ? running.get(event.toolCallId) : undefined;
|
|
543
|
-
if (!call || !event.partialResult) return;
|
|
544
|
-
call.entry.output = cleanBlock(textOf(event.partialResult, MAX_ENTRY_TEXT * 2), MAX_ENTRY_TEXT);
|
|
545
|
-
call.entry.rev++;
|
|
568
|
+
case "extension_ui_request":
|
|
569
|
+
if (DIALOGS.has(String(event.method))) send({ type: "extension_ui_response", id: event.id, cancelled: true });
|
|
570
|
+
return;
|
|
571
|
+
case "message_update": {
|
|
572
|
+
const delta = event.assistantMessageEvent;
|
|
573
|
+
if (delta?.type === "text_delta" && typeof delta.delta === "string") {
|
|
574
|
+
run.live = (run.live + delta.delta).slice(-MAX_LIVE_CHARS);
|
|
575
|
+
run.activity = "writing";
|
|
546
576
|
repaint();
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
case "tool_execution_end": {
|
|
550
|
-
const call = typeof event.toolCallId === "string" ? running.get(event.toolCallId) : undefined;
|
|
551
|
-
if (call) running.delete(event.toolCallId as string);
|
|
552
|
-
const failed = event.isError === true;
|
|
553
|
-
const name = call?.entry.name ?? cleanText(String(event.toolName ?? ""), 24);
|
|
554
|
-
if (call) {
|
|
555
|
-
call.entry.status = failed ? "error" : "done";
|
|
556
|
-
call.entry.output = event.result ? cleanBlock(textOf(event.result, MAX_ENTRY_TEXT * 2), MAX_ENTRY_TEXT) : call.entry.output;
|
|
557
|
-
call.entry.endedAt = Date.now();
|
|
558
|
-
call.entry.rev++;
|
|
559
|
-
}
|
|
560
|
-
if (name === "jar_todo" && !failed) { const todos = childTodos(event.result?.details); if (todos) run.todos = todos; }
|
|
561
|
-
if ((name === "edit" || name === "write") && !failed && call?.path) {
|
|
562
|
-
addFile(run.filesEdited, call.path);
|
|
563
|
-
try { hooks.edited?.(call.path); } catch (error) { console.error("pi-jar: could not track a subagent edit", error); }
|
|
564
|
-
}
|
|
577
|
+
} else if ((delta?.type === "thinking_start" || delta?.type === "thinking_delta") && run.activity !== "thinking") {
|
|
565
578
|
run.activity = "thinking";
|
|
566
579
|
update();
|
|
567
|
-
return;
|
|
568
580
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
case "tool_execution_start": {
|
|
584
|
+
const name = cleanText(String(event.toolName ?? "tool"), 24) || "tool";
|
|
585
|
+
const hint = cleanText(toolHint(event.args), 200);
|
|
586
|
+
const id = typeof event.toolCallId === "string" && event.toolCallId ? event.toolCallId : `call-${run.tools}`;
|
|
587
|
+
const entry: ToolEntry = { kind: "tool", rev: 0, id, name, hint, args: argsText(event.args), status: "running", output: "", startedAt: Date.now() };
|
|
588
|
+
const path = toolPath(event.args);
|
|
589
|
+
run.tools++;
|
|
590
|
+
run.toolCounts[name] = (run.toolCounts[name] ?? 0) + 1;
|
|
591
|
+
if (name === "read" && path) addFile(run.filesRead, path);
|
|
592
|
+
running.set(id, { entry, ...(path ? { path } : {}) });
|
|
593
|
+
pushEntry(run, entry);
|
|
594
|
+
run.activity = cleanText(hint ? `${name} ${hint}` : name, 60);
|
|
595
|
+
update();
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
case "tool_execution_update": {
|
|
599
|
+
const call = typeof event.toolCallId === "string" ? running.get(event.toolCallId) : undefined;
|
|
600
|
+
if (!call || !event.partialResult) return;
|
|
601
|
+
call.entry.output = cleanBlock(textOf(event.partialResult, MAX_ENTRY_TEXT * 2), MAX_ENTRY_TEXT);
|
|
602
|
+
call.entry.rev++;
|
|
603
|
+
repaint();
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
case "tool_execution_end": {
|
|
607
|
+
const call = typeof event.toolCallId === "string" ? running.get(event.toolCallId) : undefined;
|
|
608
|
+
if (call) running.delete(event.toolCallId as string);
|
|
609
|
+
const failed = event.isError === true;
|
|
610
|
+
const name = call?.entry.name ?? cleanText(String(event.toolName ?? ""), 24);
|
|
611
|
+
if (call) {
|
|
612
|
+
call.entry.status = failed ? "error" : "done";
|
|
613
|
+
call.entry.output = event.result ? cleanBlock(textOf(event.result, MAX_ENTRY_TEXT * 2), MAX_ENTRY_TEXT) : call.entry.output;
|
|
614
|
+
call.entry.endedAt = Date.now();
|
|
615
|
+
call.entry.rev++;
|
|
581
616
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
617
|
+
if (name === "jar_todo" && !failed) { const todos = childTodos(event.result?.details); if (todos) run.todos = todos; }
|
|
618
|
+
if ((name === "edit" || name === "write") && !failed && call?.path) {
|
|
619
|
+
addFile(run.filesEdited, call.path);
|
|
620
|
+
try { hooks.edited?.(call.path); } catch (error) { console.error("pi-jar: could not track a subagent edit", error); }
|
|
621
|
+
}
|
|
622
|
+
run.activity = "thinking";
|
|
623
|
+
update();
|
|
624
|
+
return;
|
|
585
625
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
626
|
+
case "message_end": {
|
|
627
|
+
if (event.message?.role !== "assistant") return;
|
|
628
|
+
run.turns++;
|
|
629
|
+
run.cost += Number(event.message.usage?.cost?.total) || 0;
|
|
630
|
+
const text = textOf(event.message).trim();
|
|
631
|
+
if (text) { run.output = text.slice(0, MAX_OUTPUT_CHARS); pushEntry(run, { kind: "text", rev: 0, text: cleanBlock(text, MAX_ENTRY_TEXT) }); }
|
|
632
|
+
if (event.message.stopReason === "error") run.error = cleanText(String(event.message.errorMessage ?? "assistant turn failed"), 200);
|
|
633
|
+
if (event.message.stopReason === "aborted" && !pauseRequested && !closing) run.error = "assistant turn aborted";
|
|
634
|
+
run.live = "";
|
|
635
|
+
run.activity = "thinking";
|
|
636
|
+
update();
|
|
637
|
+
return;
|
|
598
638
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
}
|
|
603
|
-
|
|
639
|
+
case "agent_settled":
|
|
640
|
+
settleTurn();
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
/** The hibernating process is gone: the agent rests, or relaunches for a prompt accepted meanwhile. */
|
|
645
|
+
const slept = () => {
|
|
646
|
+
const pending = wake;
|
|
647
|
+
wake = undefined;
|
|
648
|
+
if (pending) { relaunch(pending.message, pending.id); return; }
|
|
649
|
+
rest = run.state === "paused" ? "paused" : "idle";
|
|
650
|
+
run.state = "hibernated";
|
|
651
|
+
run.activity = undefined;
|
|
652
|
+
update();
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
/** Spawn one Pi RPC process for this agent and wire its stream; false when spawning failed and the agent ended. */
|
|
656
|
+
const launch = (launchArgs: string[]): boolean => {
|
|
657
|
+
const invocation = piInvocation(launchArgs);
|
|
658
|
+
let child: ChildProcess;
|
|
659
|
+
try {
|
|
660
|
+
const env = { ...process.env };
|
|
661
|
+
for (const key of [CHILD_BASELINE_ENV, CHILD_WORKTREE_ENV, CHILD_TOOLS_ENV, ...DISCUSSION_ENV_KEYS]) delete env[key];
|
|
662
|
+
child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], env: { ...env, ...hooks.env?.(), [CHILD_ENV]: "1" } });
|
|
663
|
+
} catch (error) {
|
|
664
|
+
finish("failed", error instanceof Error ? error.message : String(error));
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
let buffer = "";
|
|
668
|
+
let overflow = false;
|
|
604
669
|
let closedOnce = false;
|
|
670
|
+
let closeTimer: NodeJS.Timeout | undefined;
|
|
671
|
+
let ended!: () => void;
|
|
605
672
|
const exited = (code: number | null) => {
|
|
606
673
|
if (closedOnce) return;
|
|
607
674
|
closedOnce = true;
|
|
608
|
-
alive = false;
|
|
609
|
-
if (killTimer) signalProcessTree(child, "SIGKILL");
|
|
610
|
-
clearTimeout(killTimer);
|
|
675
|
+
proc.alive = false;
|
|
676
|
+
if (proc.killTimer) signalProcessTree(child, "SIGKILL");
|
|
677
|
+
clearTimeout(proc.killTimer);
|
|
611
678
|
clearTimeout(closeTimer);
|
|
612
679
|
if (buffer && !overflow) { line(buffer); buffer = ""; }
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
if (
|
|
616
|
-
|
|
680
|
+
for (const answer of [...queries.values()]) answer(undefined);
|
|
681
|
+
if (current === proc) current = undefined;
|
|
682
|
+
if (proc.hibernating && !finished && !closing) slept();
|
|
683
|
+
else {
|
|
684
|
+
if ((closing || (code === 0 && settled)) && !run.error) finish("done");
|
|
685
|
+
else finish("failed", run.error ?? (cleanText(proc.stderr, 300) || (settled ? `exited ${code}` : `exited ${code} before finishing`)));
|
|
686
|
+
resolveClosed();
|
|
687
|
+
}
|
|
688
|
+
ended();
|
|
617
689
|
};
|
|
690
|
+
const proc: RpcProcess = { child, alive: true, hibernating: false, stderr: "", exited, ended: new Promise<void>((resolve) => { ended = resolve; }) };
|
|
691
|
+
current = proc;
|
|
618
692
|
// A grandchild that inherited stdout/stderr keeps them open after the child is gone, and 'close'
|
|
619
693
|
// then never fires; stop waiting after a short drain so stop/shutdown can never hang on it.
|
|
620
694
|
const drainThenClose = (code: number | null) => {
|
|
@@ -627,115 +701,191 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
627
701
|
}, CLOSE_GRACE_MS);
|
|
628
702
|
closeTimer.unref?.();
|
|
629
703
|
};
|
|
704
|
+
child.stdin?.on("error", (error) => { if (!finished) proc.stderr = (proc.stderr + "\n" + error.message).slice(-4000); });
|
|
705
|
+
child.stdout?.setEncoding?.("utf8");
|
|
706
|
+
child.stdout?.on("data", (chunk) => {
|
|
707
|
+
const text = String(chunk);
|
|
708
|
+
let start = 0;
|
|
709
|
+
for (let end = text.indexOf("\n", start); end >= 0; end = text.indexOf("\n", start)) {
|
|
710
|
+
const item = buffer + text.slice(start, end);
|
|
711
|
+
if (!overflow && item.length <= MAX_LINE_CHARS) line(item.endsWith("\r") ? item.slice(0, -1) : item);
|
|
712
|
+
buffer = "";
|
|
713
|
+
overflow = false;
|
|
714
|
+
start = end + 1;
|
|
715
|
+
}
|
|
716
|
+
if (overflow) return;
|
|
717
|
+
buffer += text.slice(start);
|
|
718
|
+
if (buffer.length > MAX_LINE_CHARS) { buffer = ""; overflow = true; }
|
|
719
|
+
});
|
|
720
|
+
child.stderr?.on("data", (chunk) => { proc.stderr = (proc.stderr + String(chunk)).slice(-4000); });
|
|
630
721
|
child.on("error", (error) => {
|
|
631
722
|
hardStop();
|
|
632
723
|
finish("failed", error.message);
|
|
633
724
|
// A process that never spawned has no exit event to wait for.
|
|
634
|
-
if (child.pid === undefined) { alive = false; drainThenClose(null); }
|
|
725
|
+
if (child.pid === undefined) { proc.alive = false; drainThenClose(null); }
|
|
635
726
|
});
|
|
636
727
|
// Exit retires control immediately, but finalization waits for close so stdio drains first.
|
|
637
728
|
child.once("exit", (code) => {
|
|
638
|
-
alive = false;
|
|
639
|
-
|
|
640
|
-
if (!finished) {
|
|
641
|
-
|
|
642
|
-
if (
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
729
|
+
proc.alive = false;
|
|
730
|
+
// A process exiting to hibernate leaves the agent's settled state alone.
|
|
731
|
+
if (!proc.hibernating || finished || closing) {
|
|
732
|
+
clearTurnTimer();
|
|
733
|
+
if (!finished) {
|
|
734
|
+
run.state = (closing || (code === 0 && settled)) && !run.error ? "done" : "failed";
|
|
735
|
+
if (run.state === "failed") run.error ??= `exited ${code}`;
|
|
736
|
+
run.activity = undefined;
|
|
737
|
+
resolveSettled();
|
|
738
|
+
update();
|
|
739
|
+
}
|
|
646
740
|
}
|
|
647
741
|
drainThenClose(code);
|
|
648
742
|
});
|
|
649
743
|
child.once("close", exited);
|
|
744
|
+
return true;
|
|
745
|
+
};
|
|
746
|
+
/** Continue the hibernated session in a fresh process with the prompt beginPrompt accepted. */
|
|
747
|
+
const relaunch = (message: string, id: string): boolean => {
|
|
748
|
+
if (finished || closing) return false;
|
|
749
|
+
if (!relaunchArgs) { finish("failed", "the hibernated session cannot be resumed"); return false; }
|
|
750
|
+
if (!launch(relaunchArgs)) return false;
|
|
751
|
+
if (signal?.aborted) { abort(); return false; }
|
|
752
|
+
if (send({ id, type: "prompt", message })) return true;
|
|
753
|
+
hardStop();
|
|
754
|
+
finish("failed", "could not send the prompt to the resumed subagent");
|
|
755
|
+
return false;
|
|
756
|
+
};
|
|
650
757
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
758
|
+
const beginPrompt = (message: string, pauseAfter = false): boolean => {
|
|
759
|
+
const clean = cleanBlock(message, MAX_RESUME_CHARS);
|
|
760
|
+
if (finished || closing || !settled || !clean) return false;
|
|
761
|
+
const id = "prompt-" + (run.resumes + 1);
|
|
762
|
+
const proc = current;
|
|
763
|
+
// Hibernated, or still exiting to hibernate: the prompt relaunches Pi on the same session file.
|
|
764
|
+
const asleep = !proc || proc.hibernating;
|
|
765
|
+
if (asleep ? !relaunchArgs : !send({ id, type: "prompt", message: clean })) return false;
|
|
766
|
+
settled = false;
|
|
767
|
+
run.error = undefined;
|
|
768
|
+
run.output = "";
|
|
769
|
+
pauseRequested = pauseAfter;
|
|
770
|
+
run.state = "working";
|
|
771
|
+
run.resumes++;
|
|
772
|
+
run.endedAt = undefined;
|
|
773
|
+
run.activity = "resuming";
|
|
774
|
+
activityAt = Date.now();
|
|
775
|
+
clearTimeout(hibernateTimer);
|
|
776
|
+
armTurnTimer();
|
|
777
|
+
update();
|
|
778
|
+
if (!asleep) return true;
|
|
779
|
+
if (proc) { wake = { message: clean, id }; return true; }
|
|
780
|
+
return relaunch(clean, id);
|
|
781
|
+
};
|
|
782
|
+
const steer = (text: string): boolean => {
|
|
783
|
+
const message = cleanBlock(text, MAX_STEER_CHARS);
|
|
784
|
+
if (finished || closing || !message || run.state !== "working" || !send({ type: "steer", message })) return false;
|
|
785
|
+
pushEntry(run, { kind: "steer", rev: 0, text: message });
|
|
786
|
+
run.steered++;
|
|
787
|
+
update();
|
|
788
|
+
return true;
|
|
789
|
+
};
|
|
790
|
+
const pause = async (): Promise<boolean> => {
|
|
791
|
+
if (finished || closing) return false;
|
|
792
|
+
// A hibernated agent spends nothing already; remember the pause for its next BTW answer.
|
|
793
|
+
if (run.state === "hibernated") {
|
|
794
|
+
if (rest !== "paused") { rest = "paused"; run.pauses++; update(); }
|
|
657
795
|
return true;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
run.activity = "thinking";
|
|
673
|
-
update();
|
|
674
|
-
return false;
|
|
675
|
-
}
|
|
796
|
+
}
|
|
797
|
+
if (!current?.alive) return false;
|
|
798
|
+
if (run.state === "paused") return true;
|
|
799
|
+
if (run.state === "idle") { run.state = "paused"; run.pauses++; update(); return true; }
|
|
800
|
+
if (run.state !== "working") return false;
|
|
801
|
+
pauseRequested = true;
|
|
802
|
+
run.pauses++;
|
|
803
|
+
run.activity = "pausing";
|
|
804
|
+
const waiting = waitSettled();
|
|
805
|
+
send({ type: "clear_queue" });
|
|
806
|
+
if (!send({ type: "abort" })) {
|
|
807
|
+
pauseRequested = false;
|
|
808
|
+
run.pauses = Math.max(0, run.pauses - 1);
|
|
809
|
+
run.activity = "thinking";
|
|
676
810
|
update();
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
update();
|
|
814
|
+
// A child that ignores abort must not hang the moderator; it still pauses when it settles.
|
|
815
|
+
if (!await settlesWithin(waiting, PAUSE_GRACE_MS)) return false;
|
|
816
|
+
return (run.state as DelegateState) === "paused";
|
|
817
|
+
};
|
|
818
|
+
const resume = (text?: string): boolean => {
|
|
819
|
+
if (run.state !== "paused" && run.state !== "idle" && run.state !== "hibernated") return false;
|
|
820
|
+
const message = text?.trim() || "Continue the assigned task from where you stopped. Review your current checklist and remaining work first.";
|
|
821
|
+
return beginPrompt(message);
|
|
822
|
+
};
|
|
823
|
+
const ask = async (text: string): Promise<string | undefined> => {
|
|
824
|
+
const question = cleanBlock(text, MAX_STEER_CHARS);
|
|
825
|
+
if (!question || finished || closing || askBusy) return undefined;
|
|
826
|
+
askBusy = true;
|
|
827
|
+
const wasPaused = run.state === "paused" || (run.state === "hibernated" && rest === "paused");
|
|
828
|
+
try {
|
|
829
|
+
// Cross a settled boundary so the active task's report cannot answer the question.
|
|
830
|
+
if (run.state === "working" && !await pause()) return undefined;
|
|
831
|
+
const instruction = "Moderator BTW: answer only this question in one compact paragraph. Preserve your assigned task scope for the next resume. Question: " + question;
|
|
832
|
+
if (!beginPrompt(instruction, wasPaused)) return undefined;
|
|
833
|
+
const answered = await settlesWithin(waitSettled(), 2 * 60_000);
|
|
834
|
+
if (!answered) { void pause(); return undefined; }
|
|
835
|
+
return !finished && !run.error ? run.output || undefined : undefined;
|
|
836
|
+
} finally {
|
|
837
|
+
askBusy = false;
|
|
838
|
+
// The BTW turn settled while askBusy kept the child awake: its quiet period starts now.
|
|
839
|
+
armHibernate();
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
const shutdown = (): Promise<void> => {
|
|
843
|
+
if (shutdownPromise) return shutdownPromise;
|
|
844
|
+
closing = true;
|
|
845
|
+
clearTimeout(hibernateTimer);
|
|
846
|
+
shutdownPromise = Promise.resolve().then(async () => {
|
|
705
847
|
if (finished) { await closed; return; }
|
|
706
|
-
|
|
707
|
-
|
|
848
|
+
const proc = current;
|
|
849
|
+
// Hibernated: there is no process to stop, so the agent simply ends here.
|
|
850
|
+
if (!proc) { finish("done"); return; }
|
|
851
|
+
if (run.state === "working" && !proc.hibernating) {
|
|
708
852
|
send({ type: "clear_queue" });
|
|
709
|
-
if (send({ type: "abort" }))
|
|
710
|
-
if (!await settlesWithin(waiting, 3000) && !finished) hardStop();
|
|
711
|
-
} else hardStop();
|
|
853
|
+
if (!send({ type: "abort" })) hardStop();
|
|
712
854
|
}
|
|
713
|
-
|
|
714
|
-
|
|
855
|
+
// EOF follows the queued abort in the same pipe. Pi can now dispose immediately;
|
|
856
|
+
// do not spend an abort grace and then another full process-exit grace serially.
|
|
857
|
+
const stdin = proc.child.stdin;
|
|
858
|
+
if (stdin && !stdin.writableEnded) stdin.end();
|
|
859
|
+
const killer = setTimeout(() => { if (proc.child.exitCode === null) hardStop(); }, 3000);
|
|
715
860
|
killer.unref?.();
|
|
716
861
|
await closed;
|
|
717
862
|
clearTimeout(killer);
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
863
|
+
});
|
|
864
|
+
return shutdownPromise;
|
|
865
|
+
};
|
|
721
866
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
867
|
+
if (launch(args)) {
|
|
868
|
+
run.state = "working";
|
|
869
|
+
run.startedAt = Date.now();
|
|
870
|
+
run.endedAt = undefined;
|
|
871
|
+
run.activity = "starting";
|
|
872
|
+
update();
|
|
873
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
874
|
+
armTurnTimer();
|
|
875
|
+
if (signal?.aborted) abort();
|
|
876
|
+
else if (!send({ id: "prompt", type: "prompt", message: prompt })) { hardStop(); finish("failed", "could not send the task to the subagent"); }
|
|
877
|
+
}
|
|
725
878
|
return {
|
|
726
|
-
firstSettled,
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
pause: () => pause(),
|
|
730
|
-
resume: (text) => resume(text),
|
|
731
|
-
ask: (text) => ask(text),
|
|
732
|
-
shutdown: () => shutdown(),
|
|
733
|
-
alive: () => alive
|
|
879
|
+
firstSettled, closed, steer, pause, resume, ask, shutdown,
|
|
880
|
+
alive: () => !!current?.alive,
|
|
881
|
+
pid: () => current?.alive ? current.child.pid : undefined
|
|
734
882
|
};
|
|
735
883
|
}
|
|
736
884
|
|
|
737
885
|
const isRunning = (state: DelegateState) => state === "queued" || state === "working";
|
|
738
|
-
const isRetained = (state: DelegateState) => isRunning(state) || state === "idle" || state === "paused";
|
|
886
|
+
const isRetained = (state: DelegateState) => isRunning(state) || state === "idle" || state === "paused" || state === "hibernated";
|
|
887
|
+
/** A finished worktree whose private changes were not reconciled: kept until stop retries or discard removes it. */
|
|
888
|
+
const isRecovery = (record: SubagentRecord) => !!record.run.workspace && !isRetained(record.run.state) && !record.alive?.();
|
|
739
889
|
|
|
740
890
|
export interface SubagentStopReport {
|
|
741
891
|
key: string;
|
|
@@ -759,10 +909,36 @@ export interface SubagentRecord {
|
|
|
759
909
|
steer(text: string): boolean;
|
|
760
910
|
ask(text: string): Promise<string | undefined>;
|
|
761
911
|
changed(): string[];
|
|
762
|
-
|
|
912
|
+
/** Retire for good without applying edits; `keepWorkspace` leaves an unresolved recovery worktree on disk. */
|
|
913
|
+
discard(keepWorkspace?: boolean): Promise<void>;
|
|
763
914
|
alive?(): boolean;
|
|
915
|
+
pid?(): number | undefined;
|
|
764
916
|
closed?(): Promise<void>;
|
|
765
917
|
}
|
|
918
|
+
/** What an unresolved worktree recovery keeps: enough to retry reconciliation or discard it, nothing bulky. */
|
|
919
|
+
export interface RecoveryRecord {
|
|
920
|
+
key: string;
|
|
921
|
+
name: string;
|
|
922
|
+
workspace: string;
|
|
923
|
+
changed: string[];
|
|
924
|
+
error?: string;
|
|
925
|
+
startedAt?: number;
|
|
926
|
+
failedAt?: number;
|
|
927
|
+
}
|
|
928
|
+
/** On-demand lifecycle diagnostics; `live` and `pids` describe child Pi processes running right now. */
|
|
929
|
+
export interface SubagentStats { retained: number; live: number; hibernated: number; recovery: number; recoveryLimit: number; pids: number[] }
|
|
930
|
+
|
|
931
|
+
/** Recovery keeps the facts needed to retry or discard; the transcript, stream and read list are released. */
|
|
932
|
+
const releaseForRecovery = (run: DelegateRun) => {
|
|
933
|
+
run.transcript = [{ kind: "note", rev: 0, text: `Recovery workspace kept at ${run.workspace}: stop retries reconciliation; discard removes it without applying.` }];
|
|
934
|
+
run.live = "";
|
|
935
|
+
run.filesRead = [];
|
|
936
|
+
run.output = cleanBlock(run.output, MAX_RECOVERY_OUTPUT);
|
|
937
|
+
};
|
|
938
|
+
/** Symlinked temp roots (macOS /var → /private/var) must not make a child's own directory look foreign. */
|
|
939
|
+
const sameDirectory = (a: string, b: string) => {
|
|
940
|
+
try { return realpathSync(a) === realpathSync(b); } catch { return false; } // a missing directory is nobody's session
|
|
941
|
+
};
|
|
766
942
|
|
|
767
943
|
/**
|
|
768
944
|
* Live subagents for the activity view. Tool details are saved in the session, so they stay small;
|
|
@@ -846,18 +1022,69 @@ export class DelegateRegistry {
|
|
|
846
1022
|
for (const { record } of this.entries.values()) if (isRetained(record.run.state) || record.alive?.()) count++;
|
|
847
1023
|
return count;
|
|
848
1024
|
}
|
|
849
|
-
/**
|
|
850
|
-
|
|
1025
|
+
/** Unresolved worktree recovery workspaces, oldest first. */
|
|
1026
|
+
recoveries(): RecoveryRecord[] {
|
|
1027
|
+
const recoveries: RecoveryRecord[] = [];
|
|
1028
|
+
for (const { record } of this.entries.values()) {
|
|
1029
|
+
if (!isRecovery(record)) continue;
|
|
1030
|
+
const { key, run } = record;
|
|
1031
|
+
recoveries.push({ key, name: run.name, workspace: run.workspace!, changed: [...run.filesEdited],
|
|
1032
|
+
...(run.error ? { error: run.error } : {}),
|
|
1033
|
+
...(run.startedAt !== undefined ? { startedAt: run.startedAt } : {}),
|
|
1034
|
+
...(run.endedAt !== undefined ? { failedAt: run.endedAt } : {}) });
|
|
1035
|
+
}
|
|
1036
|
+
return recoveries.sort((a, b) => (a.failedAt ?? 0) - (b.failedAt ?? 0));
|
|
1037
|
+
}
|
|
1038
|
+
/** Remove an unresolved recovery workspace without applying it, freeing its recovery slot. */
|
|
1039
|
+
async discard(key: string): Promise<RecoveryRecord | undefined> {
|
|
1040
|
+
const record = this.resolve(key);
|
|
1041
|
+
const recovery = record && this.recoveries().find((item) => item.key === record.key);
|
|
1042
|
+
if (!record || !recovery) return undefined;
|
|
1043
|
+
await record.discard();
|
|
1044
|
+
if (this.get(record.key) === record) this.entries.delete(record.key);
|
|
1045
|
+
this.notify();
|
|
1046
|
+
return recovery;
|
|
1047
|
+
}
|
|
1048
|
+
/** Lifecycle counts computed on demand from memory: no polling, no filesystem access. */
|
|
1049
|
+
stats(): SubagentStats {
|
|
1050
|
+
let live = 0;
|
|
1051
|
+
let hibernated = 0;
|
|
1052
|
+
let recovery = 0;
|
|
1053
|
+
const pids: number[] = [];
|
|
1054
|
+
const visit = (record: SubagentRecord) => {
|
|
1055
|
+
if (!record.alive?.()) return;
|
|
1056
|
+
live++;
|
|
1057
|
+
const pid = record.pid?.();
|
|
1058
|
+
if (pid !== undefined) pids.push(pid);
|
|
1059
|
+
};
|
|
1060
|
+
for (const { record } of this.entries.values()) {
|
|
1061
|
+
visit(record);
|
|
1062
|
+
if (record.run.state === "hibernated") hibernated++;
|
|
1063
|
+
else if (isRecovery(record)) recovery++;
|
|
1064
|
+
}
|
|
1065
|
+
for (const record of this.draining) visit(record);
|
|
1066
|
+
return { retained: this.retained(), live, hibernated, recovery, recoveryLimit: RECOVERY_LIMIT, pids };
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* Session changes discard retained workers without applying edits. Any worktree that might hold
|
|
1070
|
+
* changes stays on disk and its path is returned, including a worker still running at shutdown.
|
|
1071
|
+
*/
|
|
1072
|
+
clear(): string[] {
|
|
851
1073
|
const records = [...this.entries.values()].map(({ record }) => record);
|
|
852
1074
|
this.entries.clear();
|
|
1075
|
+
const preserved: string[] = [];
|
|
853
1076
|
for (const record of records) {
|
|
1077
|
+
const keepWorkspace = !!record.run.workspace;
|
|
1078
|
+
if (keepWorkspace) preserved.push(record.run.workspace!);
|
|
854
1079
|
if (record.closed && (isRetained(record.run.state) || record.alive?.())) {
|
|
855
1080
|
this.draining.add(record);
|
|
856
1081
|
void record.closed().finally(() => { this.draining.delete(record); this.notify(); });
|
|
857
1082
|
}
|
|
858
|
-
try { record.discard()
|
|
1083
|
+
try { void Promise.resolve(record.discard(keepWorkspace)).catch((error) => console.error("pi-jar: subagent discard failed", error)); }
|
|
1084
|
+
catch (error) { console.error("pi-jar: subagent discard failed", error); }
|
|
859
1085
|
}
|
|
860
1086
|
this.notify();
|
|
1087
|
+
return preserved;
|
|
861
1088
|
}
|
|
862
1089
|
add(...records: SubagentRecord[]): void {
|
|
863
1090
|
for (const record of records) this.entries.set(record.key, { record });
|
|
@@ -874,13 +1101,19 @@ export class DelegateRegistry {
|
|
|
874
1101
|
for (const entry of this.entries.values()) {
|
|
875
1102
|
if (isRetained(entry.record.run.state) || entry.record.alive?.()) { entry.finished = undefined; continue; }
|
|
876
1103
|
entry.finished ??= ++this.sequence;
|
|
877
|
-
|
|
1104
|
+
// Recovery workspaces are bounded by worktree admission, never by this history cap.
|
|
1105
|
+
if (!entry.record.run.workspace) finished++;
|
|
878
1106
|
}
|
|
879
1107
|
if (finished <= MAX_FINISHED) return;
|
|
880
1108
|
// Conflicted/failed workspaces are recovery state, not disposable transcript history.
|
|
881
1109
|
const oldest = [...this.entries].filter(([, entry]) => entry.finished !== undefined && !entry.record.run.workspace)
|
|
882
1110
|
.sort((a, b) => a[1].finished! - b[1].finished!);
|
|
883
|
-
for (const [key] of oldest.slice(0, finished - MAX_FINISHED))
|
|
1111
|
+
for (const [key, { record }] of oldest.slice(0, finished - MAX_FINISHED)) {
|
|
1112
|
+
this.entries.delete(key);
|
|
1113
|
+
// A sibling still registered keeps the whole batch closure alive; free what only views read.
|
|
1114
|
+
record.run.transcript = [];
|
|
1115
|
+
record.run.live = "";
|
|
1116
|
+
}
|
|
884
1117
|
}
|
|
885
1118
|
}
|
|
886
1119
|
|
|
@@ -964,8 +1197,14 @@ export interface DelegateOptions {
|
|
|
964
1197
|
changes?: () => ChangeTracker | undefined;
|
|
965
1198
|
/** Called after a subagent edit was added to the tracker. */
|
|
966
1199
|
changed?: () => void;
|
|
967
|
-
/**
|
|
968
|
-
|
|
1200
|
+
/** Discussion broker environment for one child spawn; idempotent per key and read again on every relaunch. */
|
|
1201
|
+
discussionEnv?: (key: string, name: string) => Record<string, string> | undefined;
|
|
1202
|
+
/** The agent was permanently stopped or discarded: release its discussion identity. */
|
|
1203
|
+
discussionRetire?: (key: string) => void;
|
|
1204
|
+
/** Unread discussion messages waiting for an agent, shown in the moderator's fleet line. */
|
|
1205
|
+
discussionUnread?: (key: string) => number;
|
|
1206
|
+
/** Settled quiet time before a retained child's process hibernates; defaults to HIBERNATE_AFTER_MS. */
|
|
1207
|
+
hibernateAfterMs?: number;
|
|
969
1208
|
}
|
|
970
1209
|
|
|
971
1210
|
const roleForMode = (mode: DelegateExecutionMode): string =>
|
|
@@ -1001,17 +1240,41 @@ const peekRecord = (record: SubagentRecord): string => {
|
|
|
1001
1240
|
const current = open.find((todo) => todo.status === "in_progress");
|
|
1002
1241
|
const next = open.filter((todo) => todo !== current).slice(0, 3).map((todo) => cleanText(todo.title, 80));
|
|
1003
1242
|
const more = open.length - (current ? 1 : 0) - next.length;
|
|
1243
|
+
const recovery = isRecovery(record);
|
|
1004
1244
|
return [
|
|
1005
|
-
`${record.key} · ${run.name} · ${run.state}`,
|
|
1245
|
+
`${record.key} · ${run.name} · ${run.state}${recovery ? " · unresolved recovery" : ""}`,
|
|
1006
1246
|
`progress: ${leaves.length ? `${leaves.length - open.length}/${leaves.length} tasks` : "no checklist"}`
|
|
1007
1247
|
+ (current ? ` · now: ${cleanText(current.title, 80)}` : "") + (run.activity ? ` · ${cleanText(run.activity, 60)}` : ""),
|
|
1008
|
-
next.length ? `next: ${next.join("; ")}${more > 0 ? ` (+${more} more)` : ""}` : ""
|
|
1248
|
+
next.length ? `next: ${next.join("; ")}${more > 0 ? ` (+${more} more)` : ""}` : "",
|
|
1249
|
+
...(recovery ? [
|
|
1250
|
+
`workspace: ${run.workspace}`,
|
|
1251
|
+
`changed: ${run.filesEdited.length ? fileList(run.filesEdited, 20) : "none recorded"}`,
|
|
1252
|
+
run.error ? `error: ${cleanText(run.error, 300)}` : "",
|
|
1253
|
+
"resolve: stop retries reconciliation into /diff; discard removes the workspace without applying it"
|
|
1254
|
+
] : [])
|
|
1009
1255
|
].filter(Boolean).join("\n");
|
|
1010
1256
|
};
|
|
1011
1257
|
/** The wake-up after a resumed turn: its progress plus the bounded report the turn produced. */
|
|
1012
1258
|
const settledRecord = (record: SubagentRecord): string =>
|
|
1013
1259
|
peekRecord(record) + (record.run.output ? `\nlast: ${cleanBlock(record.run.output, 1200)}` : "");
|
|
1014
1260
|
|
|
1261
|
+
/**
|
|
1262
|
+
* Every active worktree agent may still end up needing recovery, so admission reserves a recovery slot for
|
|
1263
|
+
* it: unresolved recovery workspaces never exceed RECOVERY_LIMIT, and none is ever deleted to make room.
|
|
1264
|
+
*/
|
|
1265
|
+
function worktreeAdmission(registry: DelegateRegistry, requested: number): string | undefined {
|
|
1266
|
+
const recoveries = registry.recoveries();
|
|
1267
|
+
const active = registry.records().filter((record) => record.run.mode === "worktree" && (isRetained(record.run.state) || record.alive?.())).length;
|
|
1268
|
+
if (recoveries.length + active + requested <= RECOVERY_LIMIT) return undefined;
|
|
1269
|
+
return [
|
|
1270
|
+
`Worktree recovery limit: ${plural(recoveries.length, "unresolved recovery workspace")} and ${plural(active, "active worktree agent")} leave no room for ${requested} more (limit ${RECOVERY_LIMIT}).`,
|
|
1271
|
+
recoveries.length ? "Resolve recovery records with jar_subagent stop (retry reconciliation) or discard (remove without applying):"
|
|
1272
|
+
: "Stop finished worktree agents first so their changes reconcile.",
|
|
1273
|
+
...recoveries.map((item) => `- ${item.key} · ${item.name} · ${item.workspace}`
|
|
1274
|
+
+ (item.changed.length ? ` · changed: ${fileList(item.changed, 5)}` : "") + (item.error ? ` · ${cleanText(item.error, 160)}` : ""))
|
|
1275
|
+
].join("\n");
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1015
1278
|
/** jar_delegate plus jar_subagent: the parent remains a moderator while retained workers do the work. */
|
|
1016
1279
|
export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, registry: DelegateRegistry, options: DelegateOptions = {}): void {
|
|
1017
1280
|
if (process.env[CHILD_ENV] || typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool !== "function") return;
|
|
@@ -1028,18 +1291,32 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1028
1291
|
const resolved = roles.resolve(role);
|
|
1029
1292
|
return resolved ? [resolved] : [];
|
|
1030
1293
|
};
|
|
1294
|
+
const discussionEnv = (key: string, name: string): Record<string, string> => {
|
|
1295
|
+
try { return options.discussionEnv?.(key, name) ?? {}; }
|
|
1296
|
+
catch (error) { console.error("pi-jar: discussion broker unavailable for a subagent", error); return {}; }
|
|
1297
|
+
};
|
|
1298
|
+
const discussionUnread = (key: string): number => {
|
|
1299
|
+
try { return Math.max(0, Math.floor(options.discussionUnread?.(key) ?? 0)) || 0; }
|
|
1300
|
+
catch { return 0; } // the count is decoration; the fleet line must still render
|
|
1301
|
+
};
|
|
1302
|
+
const retireDiscussion = (key: string) => {
|
|
1303
|
+
try { options.discussionRetire?.(key); } catch (error) { console.error("pi-jar: could not retire a subagent's discussion identity", error); }
|
|
1304
|
+
};
|
|
1031
1305
|
|
|
1032
1306
|
pi.on?.("before_agent_start", (_event, ctx) => {
|
|
1033
1307
|
controllerContext = ctx;
|
|
1034
1308
|
const retained = registry.records().filter((record) => isRetained(record.run.state));
|
|
1035
1309
|
if (!retained.length) return;
|
|
1036
|
-
const fleet = retained.map((record) =>
|
|
1037
|
-
|
|
1310
|
+
const fleet = retained.map((record) => {
|
|
1311
|
+
const unread = discussionUnread(record.key);
|
|
1312
|
+
return `- ${record.key} · ${record.run.name} · ${record.run.state}${unread ? ` · ${plural(unread, "unread discussion message")}` : ""} · ${record.run.role}: ${cleanText(record.run.task, 140)}`;
|
|
1313
|
+
}).join("\n");
|
|
1038
1314
|
return { message: { customType: "pi-jar.moderator-context", display: false, content: [
|
|
1039
1315
|
"[PI-JAR MODERATOR MODE]",
|
|
1040
1316
|
"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.",
|
|
1041
1317
|
"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.",
|
|
1042
1318
|
"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.",
|
|
1319
|
+
"Hibernated agents are idle with their process closed to save memory; resume and ask relaunch them with context and workspace intact.",
|
|
1043
1320
|
"Retained fleet:",
|
|
1044
1321
|
fleet
|
|
1045
1322
|
].join("\n") } };
|
|
@@ -1103,14 +1380,14 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1103
1380
|
const delegateTool: ToolDefinition<typeof Parameters> = {
|
|
1104
1381
|
name: DELEGATE_TOOL,
|
|
1105
1382
|
label: "delegate",
|
|
1106
|
-
description: `Start retained session-scoped subagents within the configured
|
|
1383
|
+
description: `Start retained session-scoped subagents within the configured retained 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. Settled agents hibernate (process closed, context kept) until resumed. Worktree changes apply only when the moderator stops that agent; at most ${RECOVERY_LIMIT} worktree workspaces may be active or awaiting recovery at once.`,
|
|
1107
1384
|
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.",
|
|
1108
1385
|
promptGuidelines: [
|
|
1109
1386
|
"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.",
|
|
1110
1387
|
"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.",
|
|
1112
|
-
"Use jar_subagent ask for a brief BTW question to one worker. For agent-to-agent questions, have them use
|
|
1113
|
-
"Respect the configured pool limit, including idle and
|
|
1388
|
+
"Retained agents become idle after each turn and hibernate after a quiet period; resume and ask relaunch them with their context. 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.",
|
|
1389
|
+
"Use jar_subagent ask for a brief BTW question to one worker. For agent-to-agent questions, have them use jar_discuss so answers stay structured and cheap.",
|
|
1390
|
+
"Respect the configured pool limit, including idle, paused and hibernated agents. Stop agents you no longer need; resolve failed worktree recovery records (stop to retry, discard to drop) before starting more worktree agents."
|
|
1114
1391
|
],
|
|
1115
1392
|
parameters: Parameters,
|
|
1116
1393
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
@@ -1154,6 +1431,9 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1154
1431
|
}
|
|
1155
1432
|
}
|
|
1156
1433
|
}
|
|
1434
|
+
const worktreeTasks = requested.filter((item) => (item.mode ?? params.mode ?? (params.write === true ? "direct" : "scout")) === "worktree").length;
|
|
1435
|
+
const recoveryBlock = worktreeTasks ? worktreeAdmission(registry, worktreeTasks) : undefined;
|
|
1436
|
+
if (recoveryBlock) return { content: [{ type: "text", text: recoveryBlock }], details: { runs: [], write: false }, isError: true };
|
|
1157
1437
|
const maximum = maxSubagents();
|
|
1158
1438
|
const releaseReservation = registry.reserve(requested.length, maximum);
|
|
1159
1439
|
if (!releaseReservation) {
|
|
@@ -1211,7 +1491,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1211
1491
|
if (!ctx.hasUI) return;
|
|
1212
1492
|
for (const { key, run } of records) {
|
|
1213
1493
|
const state = run.state === "done" || run.state === "failed" || run.state === "stopped" ? ""
|
|
1214
|
-
: run.state === "queued" || run.state === "paused" ? "waiting" : run.state === "idle" ? "idle" : "working";
|
|
1494
|
+
: run.state === "queued" || run.state === "paused" ? "waiting" : run.state === "idle" || run.state === "hibernated" ? "idle" : "working";
|
|
1215
1495
|
if (published.get(key) === state) continue;
|
|
1216
1496
|
published.set(key, state);
|
|
1217
1497
|
try {
|
|
@@ -1249,7 +1529,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1249
1529
|
};
|
|
1250
1530
|
const failedHandle = (): DelegateHandle => ({
|
|
1251
1531
|
firstSettled: Promise.resolve(), closed: Promise.resolve(), steer: () => false, pause: async () => false,
|
|
1252
|
-
resume: () => false, ask: async () => undefined, shutdown: async () => {}, alive: () => false
|
|
1532
|
+
resume: () => false, ask: async () => undefined, shutdown: async () => {}, alive: () => false, pid: () => undefined
|
|
1253
1533
|
});
|
|
1254
1534
|
const cleanupPrivate = async (index: number, discardWorktree: boolean) => {
|
|
1255
1535
|
const dir = sessionDirs.get(index);
|
|
@@ -1269,7 +1549,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1269
1549
|
update();
|
|
1270
1550
|
};
|
|
1271
1551
|
|
|
1272
|
-
|
|
1552
|
+
/** Runs discarded by a session change or the discard action: their edits are never applied. */
|
|
1553
|
+
const discarded = new Set<number>();
|
|
1554
|
+
/** Discarded recovery runs whose unresolved workspace stays on disk for the user. */
|
|
1555
|
+
const preserved = new Set<number>();
|
|
1556
|
+
const dropsWorktree = (index: number) => discarded.has(index) && !preserved.has(index);
|
|
1273
1557
|
const markStartStopped = (run: DelegateRun) => {
|
|
1274
1558
|
run.state = "stopped";
|
|
1275
1559
|
run.error = undefined;
|
|
@@ -1281,33 +1565,30 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1281
1565
|
const readyResolvers: Array<() => void> = [];
|
|
1282
1566
|
const ready = runs.map(() => new Promise<void>((resolve) => readyResolvers.push(resolve)));
|
|
1283
1567
|
const startRuns = async () => Promise.all(runs.map(async (run, index) => {
|
|
1568
|
+
const key = `delegate-${id}-${run.index}`;
|
|
1284
1569
|
try {
|
|
1285
|
-
if (discarded) { run.state = "stopped"; readyResolvers[index]!(); return; }
|
|
1570
|
+
if (discarded.has(index)) { run.state = "stopped"; readyResolvers[index]!(); return; }
|
|
1286
1571
|
const start = async (): Promise<DelegateHandle> => {
|
|
1287
1572
|
if ((run.mode === "fork" || run.mode === "worktree") && !parentSession) {
|
|
1288
1573
|
failBeforeStart(run, "fork/worktree mode requires a persisted parent Pi session");
|
|
1289
1574
|
return failedHandle();
|
|
1290
1575
|
}
|
|
1291
1576
|
let cwd = ctx.cwd;
|
|
1292
|
-
let
|
|
1293
|
-
const env: Record<string, string> = {
|
|
1294
|
-
[SUBAGENT_KEY_ENV]: `delegate-${id}-${run.index}`,
|
|
1295
|
-
[SUBAGENT_NAME_ENV]: run.name
|
|
1296
|
-
};
|
|
1577
|
+
let session: DelegateSessionOptions | undefined;
|
|
1578
|
+
const env: Record<string, string> = { [SUBAGENT_KEY_ENV]: key, [SUBAGENT_NAME_ENV]: run.name };
|
|
1297
1579
|
const effectiveTools = toolsByRun.get(run.index) ?? (run.mode === "worktree" ? WORKTREE_TOOLS : run.mode === "direct" ? undefined : READ_ONLY_TOOLS);
|
|
1298
1580
|
if (effectiveTools) env[CHILD_TOOLS_ENV] = JSON.stringify(effectiveTools);
|
|
1299
|
-
const discussion = options.discussionFile?.();
|
|
1300
|
-
if (discussion) env[DISCUSSION_FILE_ENV] = discussion;
|
|
1301
1581
|
const hooks: DelegateHooks = {};
|
|
1302
1582
|
const write = run.mode === "direct" || run.mode === "worktree";
|
|
1303
1583
|
|
|
1304
|
-
|
|
1584
|
+
// Retained modes keep a private session so a settled child can hibernate and continue it later;
|
|
1585
|
+
// legacy direct writers are one-shot and stay ephemeral.
|
|
1586
|
+
if (run.mode !== "direct") {
|
|
1305
1587
|
try {
|
|
1306
|
-
const sessionDir = mkdtempSync(join(tmpdir(), "pi-jar-fork-"));
|
|
1588
|
+
const sessionDir = mkdtempSync(join(tmpdir(), run.mode === "scout" ? "pi-jar-scout-" : "pi-jar-fork-"));
|
|
1307
1589
|
sessionDirs.set(run.index, sessionDir);
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
...((run.mode === "worktree" || run.mode === "fork") ? { tools: requestedTools ?? (run.mode === "worktree" ? WORKTREE_TOOLS : READ_ONLY_TOOLS) } : {}) };
|
|
1590
|
+
session = run.mode === "scout" ? { sessionDir } : { source: parentSession!, sessionDir,
|
|
1591
|
+
tools: toolsByRun.get(run.index) ?? (run.mode === "worktree" ? WORKTREE_TOOLS : READ_ONLY_TOOLS) };
|
|
1311
1592
|
} catch (error) {
|
|
1312
1593
|
failBeforeStart(run, error);
|
|
1313
1594
|
return failedHandle();
|
|
@@ -1322,7 +1603,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1322
1603
|
env[CHILD_WORKTREE_ENV] = worktree.root;
|
|
1323
1604
|
} catch (error) {
|
|
1324
1605
|
await cleanupPrivate(run.index, true);
|
|
1325
|
-
if (discarded || stopRequested.has(index)) markStartStopped(run);
|
|
1606
|
+
if (discarded.has(index) || stopRequested.has(index)) markStartStopped(run);
|
|
1326
1607
|
else failBeforeStart(run, error);
|
|
1327
1608
|
return failedHandle();
|
|
1328
1609
|
}
|
|
@@ -1330,15 +1611,23 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1330
1611
|
env[CHILD_BASELINE_ENV] = baselines;
|
|
1331
1612
|
hooks.edited = edited;
|
|
1332
1613
|
}
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1614
|
+
// Read at every spawn, so a relaunch after hibernation reaches the current discussion broker.
|
|
1615
|
+
hooks.env = () => ({ ...env, ...discussionEnv(key, run.name) });
|
|
1616
|
+
const argsFor = (resume?: string) => delegateArgs(run.model, thinkingByRun.get(run.index), write, process.argv,
|
|
1617
|
+
session && resume ? { ...session, resume } : session, run.mode === "scout" ? toolsByRun.get(run.index) : undefined);
|
|
1618
|
+
if (session) {
|
|
1619
|
+
const own = session.sessionDir;
|
|
1620
|
+
// Only ever continue this child's own private session, never a file it reports elsewhere.
|
|
1621
|
+
hooks.resumeArgs = (file) => sameDirectory(dirname(file), own) ? argsFor(file) : undefined;
|
|
1622
|
+
hooks.hibernateAfterMs = options.hibernateAfterMs ?? HIBERNATE_AFTER_MS;
|
|
1623
|
+
}
|
|
1624
|
+
return startDelegate(run, argsFor(), delegatePrompt(run.task, write, run.mode), cwd, controllers[index]!.signal,
|
|
1625
|
+
update, options.spawnProcess, hooks, run.mode !== "direct");
|
|
1337
1626
|
};
|
|
1338
1627
|
let handle: DelegateHandle;
|
|
1339
1628
|
try { handle = await start(); }
|
|
1340
1629
|
catch (error) {
|
|
1341
|
-
if (discarded || stopRequested.has(index)) markStartStopped(run);
|
|
1630
|
+
if (discarded.has(index) || stopRequested.has(index)) markStartStopped(run);
|
|
1342
1631
|
else failBeforeStart(run, error);
|
|
1343
1632
|
handle = failedHandle();
|
|
1344
1633
|
}
|
|
@@ -1346,13 +1635,17 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1346
1635
|
started.add(index);
|
|
1347
1636
|
readyResolvers[index]!();
|
|
1348
1637
|
void handle.closed.then(async () => {
|
|
1349
|
-
|
|
1638
|
+
// The agent ended for good (hibernation never resolves closed): drop its private session and
|
|
1639
|
+
// discussion identity. An unreconciled worktree outlives it as lightweight recovery state.
|
|
1640
|
+
retireDiscussion(key);
|
|
1641
|
+
await cleanupPrivate(run.index, dropsWorktree(index) || run.mode !== "worktree");
|
|
1642
|
+
if (worktrees.has(run.index) && !stopRequested.has(index)) releaseForRecovery(run);
|
|
1350
1643
|
update();
|
|
1351
1644
|
}).catch((error) => {
|
|
1352
1645
|
run.error ??= "subagent cleanup failed: " + cleanText(error instanceof Error ? error.message : String(error), 240);
|
|
1353
1646
|
update();
|
|
1354
1647
|
});
|
|
1355
|
-
if (discarded) void handle.shutdown().finally(() => cleanupPrivate(run.index,
|
|
1648
|
+
if (discarded.has(index)) void handle.shutdown().finally(() => cleanupPrivate(run.index, dropsWorktree(index)))
|
|
1356
1649
|
.catch(error => console.error("pi-jar: subagent cleanup failed", error));
|
|
1357
1650
|
} catch (error) {
|
|
1358
1651
|
failBeforeStart(run, error);
|
|
@@ -1381,14 +1674,15 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1381
1674
|
const run = runs[index]!;
|
|
1382
1675
|
const key = `delegate-${id}-${run.index}`;
|
|
1383
1676
|
const workspace = run.workspace;
|
|
1384
|
-
|
|
1677
|
+
// Reconciliation failures are retryable once the parent drift or Git problem is resolved.
|
|
1678
|
+
if (run.error?.startsWith("changes not applied:") || run.error?.startsWith("could not inspect isolated changes:")) run.error = undefined;
|
|
1385
1679
|
await ready[index];
|
|
1386
1680
|
const handle = handles[index]!;
|
|
1387
1681
|
await handle.shutdown();
|
|
1388
1682
|
|
|
1389
1683
|
let changed: string[] = [];
|
|
1390
1684
|
const worktree = worktrees.get(run.index);
|
|
1391
|
-
if (worktree && !discarded) {
|
|
1685
|
+
if (worktree && !discarded.has(index)) {
|
|
1392
1686
|
try { changed = await worktreeChangedFiles(worktree, controllers[index]!.signal); run.filesEdited = changed; }
|
|
1393
1687
|
catch (error) { run.error = "could not inspect isolated changes: " + cleanText(String(error), 240); }
|
|
1394
1688
|
if (changed.length && !run.error) {
|
|
@@ -1404,16 +1698,17 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1404
1698
|
if (error instanceof WorktreeApplyError) run.appliedFiles = error.appliedFiles;
|
|
1405
1699
|
run.error = "changes not applied: " + cleanText(error instanceof Error ? error.message : String(error), 240);
|
|
1406
1700
|
}
|
|
1407
|
-
} else if (!changed.length) {
|
|
1701
|
+
} else if (!changed.length && !run.error?.startsWith("could not inspect isolated changes:")) {
|
|
1408
1702
|
try { await disposeDelegateWorktree(worktree); } catch { /* best effort */ }
|
|
1409
1703
|
worktrees.delete(run.index);
|
|
1410
1704
|
run.workspace = undefined;
|
|
1411
1705
|
}
|
|
1412
1706
|
}
|
|
1413
|
-
await cleanupPrivate(run.index,
|
|
1414
|
-
if (
|
|
1707
|
+
await cleanupPrivate(run.index, dropsWorktree(index));
|
|
1708
|
+
if (dropsWorktree(index)) { run.error = undefined; run.workspace = undefined; }
|
|
1415
1709
|
run.state = run.error ? "failed" : "stopped";
|
|
1416
1710
|
run.endedAt ??= Date.now();
|
|
1711
|
+
if (worktrees.has(run.index)) releaseForRecovery(run);
|
|
1417
1712
|
update();
|
|
1418
1713
|
return {
|
|
1419
1714
|
key, name: run.name, state: run.state, task: run.task, ...(workspace ? { workspace } : {}),
|
|
@@ -1426,26 +1721,28 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1426
1721
|
stop: () => stopRun(index),
|
|
1427
1722
|
pause: async () => {
|
|
1428
1723
|
await ready[index];
|
|
1429
|
-
return discarded || stopRequested.has(index) ? false : handles[index]!.pause();
|
|
1724
|
+
return discarded.has(index) || stopRequested.has(index) ? false : handles[index]!.pause();
|
|
1430
1725
|
},
|
|
1431
|
-
resume: (text) => !discarded && !stopRequested.has(index) && handles[index]!.resume(text),
|
|
1432
|
-
steer: (text) => !discarded && !stopRequested.has(index) && handles[index]!.steer(text),
|
|
1726
|
+
resume: (text) => !discarded.has(index) && !stopRequested.has(index) && handles[index]!.resume(text),
|
|
1727
|
+
steer: (text) => !discarded.has(index) && !stopRequested.has(index) && handles[index]!.steer(text),
|
|
1433
1728
|
ask: async (text) => {
|
|
1434
1729
|
await ready[index];
|
|
1435
|
-
return discarded || stopRequested.has(index) ? undefined : handles[index]!.ask(text);
|
|
1730
|
+
return discarded.has(index) || stopRequested.has(index) ? undefined : handles[index]!.ask(text);
|
|
1436
1731
|
},
|
|
1437
1732
|
alive: () => handles[index]!.alive(),
|
|
1733
|
+
pid: () => handles[index]!.pid(),
|
|
1438
1734
|
closed: () => ready[index]!.then(() => handles[index]!.closed),
|
|
1439
1735
|
changed: () => {
|
|
1440
1736
|
const worktree = worktrees.get(run.index);
|
|
1441
1737
|
if (!worktree) return [...run.filesEdited];
|
|
1442
1738
|
return [...run.filesEdited]; // synchronous activity snapshots never launch subprocesses
|
|
1443
1739
|
},
|
|
1444
|
-
discard: () => {
|
|
1445
|
-
discarded
|
|
1740
|
+
discard: (keepWorkspace = false) => {
|
|
1741
|
+
discarded.add(index);
|
|
1742
|
+
if (keepWorkspace) preserved.add(index);
|
|
1446
1743
|
// Cancellation is synchronous: a concurrent stop must never apply discarded edits.
|
|
1447
1744
|
controllers[index]!.abort(STOP_REASON);
|
|
1448
|
-
|
|
1745
|
+
return ready[index]!.then(() => handles[index]!.shutdown()).finally(() => cleanupPrivate(run.index, dropsWorktree(index)))
|
|
1449
1746
|
.catch(error => console.error("pi-jar: subagent discard failed", error));
|
|
1450
1747
|
}
|
|
1451
1748
|
}));
|
|
@@ -1537,7 +1834,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1537
1834
|
if (registry.get(record.key) !== record) { cleanup(); reject(new Error("Scout session was discarded")); return; }
|
|
1538
1835
|
if (isRunning(record.run.state)) return;
|
|
1539
1836
|
cleanup();
|
|
1540
|
-
if (record.run.error || record.run.state !== "idle") reject(new Error(record.run.error ?? "Scout failed to settle"));
|
|
1837
|
+
if (record.run.error || (record.run.state !== "idle" && record.run.state !== "hibernated")) reject(new Error(record.run.error ?? "Scout failed to settle"));
|
|
1541
1838
|
else resolve();
|
|
1542
1839
|
};
|
|
1543
1840
|
const abort = () => { cleanup(); void registry.stop(record.key); reject(new Error("Scout launch aborted")); };
|
|
@@ -1565,7 +1862,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1565
1862
|
const abort = () => { cleanup(); void registry.stop(id); reject(new Error("Scout resume aborted")); };
|
|
1566
1863
|
unsubscribe = registry.subscribe(check);
|
|
1567
1864
|
signal?.addEventListener("abort", abort, { once: true });
|
|
1568
|
-
if (!registry.resume(id, prompt)) { cleanup(); reject(new Error("Scout is not idle or
|
|
1865
|
+
if (!registry.resume(id, prompt)) { cleanup(); reject(new Error(record.run.error ?? "Scout is not idle, paused or hibernated")); }
|
|
1569
1866
|
else check();
|
|
1570
1867
|
});
|
|
1571
1868
|
return result;
|
|
@@ -1589,18 +1886,18 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1589
1886
|
pi.registerTool({
|
|
1590
1887
|
name: "jar_subagent",
|
|
1591
1888
|
label: "subagent",
|
|
1592
|
-
description: "Non-blocking moderator control for retained subagents. peek, steer and
|
|
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.",
|
|
1889
|
+
description: "Non-blocking moderator control for retained subagents. peek, steer, resume and discard return immediately. ask, pause and stop return an accepted operation receipt; the final answer or handoff arrives as a pi-jar.subagent event. Settled agents hibernate (process closed, context and workspace kept); resume and ask relaunch them transparently. Stop completion safely reconciles worktree changes; acceptance does not mean changes are applied yet. discard removes an unresolved worktree recovery workspace without applying it.",
|
|
1890
|
+
promptSnippet: "Use jar_subagent as the moderator control plane. Prefer peek over rereading transcripts; reuse paused/idle/hibernated agents with resume; stop finished workers to reconcile their work.",
|
|
1594
1891
|
promptGuidelines: [
|
|
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.",
|
|
1892
|
+
"peek returns only task progress, plus workspace and changed files for unresolved worktree recovery records. 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
1893
|
"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.",
|
|
1597
|
-
"Pause when an agent should stop spending tokens but keep its context/workspace. Resume the same agent later instead of spawning a replacement.",
|
|
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."
|
|
1894
|
+
"Pause when an agent should stop spending tokens but keep its context/workspace. Resume the same agent later instead of spawning a replacement; a hibernated agent resumes exactly like an idle one.",
|
|
1895
|
+
"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. On a failed/conflicted worktree recovery record, stop retries reconciliation; discard it only when its changes are not wanted."
|
|
1599
1896
|
],
|
|
1600
1897
|
parameters: Type.Object({
|
|
1601
1898
|
action: Type.Union([
|
|
1602
1899
|
Type.Literal("peek"), Type.Literal("steer"), Type.Literal("ask"),
|
|
1603
|
-
Type.Literal("pause"), Type.Literal("resume"), Type.Literal("stop")
|
|
1900
|
+
Type.Literal("pause"), Type.Literal("resume"), Type.Literal("stop"), Type.Literal("discard")
|
|
1604
1901
|
]),
|
|
1605
1902
|
agent: Type.Optional(Type.String({ description: "Subagent key or unique name. Omit only for peek to show all retained agents." })),
|
|
1606
1903
|
message: Type.Optional(Type.String({ description: "Direction, BTW question, or resume instruction." }))
|
|
@@ -1610,7 +1907,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1610
1907
|
const work = (async () => {
|
|
1611
1908
|
if (params.action === "peek") {
|
|
1612
1909
|
const targets = params.agent ? [registry.resolve(params.agent)].filter((item): item is SubagentRecord => !!item)
|
|
1613
|
-
: registry.records().filter((record) => isRetained(record.run.state));
|
|
1910
|
+
: registry.records().filter((record) => isRetained(record.run.state) || isRecovery(record));
|
|
1614
1911
|
if (!targets.length) return { content: [{ type: "text", text: params.agent ? "Subagent not found: " + params.agent : "No retained subagents." }] };
|
|
1615
1912
|
return { content: [{ type: "text", text: targets.map(peekRecord).join("\n\n") }] };
|
|
1616
1913
|
}
|
|
@@ -1631,6 +1928,15 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1631
1928
|
if (!message) return { content: [{ type: "text", text: "steer requires message." }], isError: true };
|
|
1632
1929
|
return { content: [{ type: "text", text: registry.steer(record.key, message) ? "Steered " + record.run.name + "." : "Subagent is not currently working; use resume instead." }] };
|
|
1633
1930
|
}
|
|
1931
|
+
if (params.action === "discard") {
|
|
1932
|
+
const removed = await registry.discard(record.key);
|
|
1933
|
+
if (!removed) return { content: [{ type: "text", text: `${record.run.name} is not an unresolved worktree recovery record; discard only removes recovery workspaces (use stop for agents).` }], isError: true };
|
|
1934
|
+
const left = existsSync(removed.workspace);
|
|
1935
|
+
return { content: [{ type: "text", text: left
|
|
1936
|
+
? `Discarded recovery record ${removed.key}, but could not delete ${removed.workspace}; remove it manually.`
|
|
1937
|
+
: `Discarded ${removed.name}: removed ${removed.workspace} without applying${removed.changed.length ? ` its changes (${fileList(removed.changed, 10)})` : " it"}.` }],
|
|
1938
|
+
...(left ? { isError: true } : {}) };
|
|
1939
|
+
}
|
|
1634
1940
|
// The moderator is handling this agent directly now; its settle no longer needs a wake-up.
|
|
1635
1941
|
if (params.action !== "resume") watched.delete(record.key);
|
|
1636
1942
|
const controlWork = async () => {
|
|
@@ -1651,7 +1957,8 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1651
1957
|
if (params.action === "resume") {
|
|
1652
1958
|
const resumed = registry.resume(record.key, params.message);
|
|
1653
1959
|
if (resumed) watched.set(record.key, record);
|
|
1654
|
-
return { content: [{ type: "text", text: resumed ? "Resumed " + record.run.name + ". You will be woken when its turn ends; do not poll peek."
|
|
1960
|
+
return { content: [{ type: "text", text: resumed ? "Resumed " + record.run.name + ". You will be woken when its turn ends; do not poll peek."
|
|
1961
|
+
: record.run.state === "failed" && record.run.error ? `Could not resume ${record.run.name}: ${record.run.error}` : record.run.name + " is not idle, paused or hibernated." }] };
|
|
1655
1962
|
}
|
|
1656
1963
|
const report = await registry.stop(record.key);
|
|
1657
1964
|
if (!report) return { content: [{ type: "text", text: "Subagent is already retired: " + record.run.name }] };
|