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