@ferris1225/pi-subagents 0.31.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -74
- package/package.json +2 -2
- package/src/agents.ts +8 -13
- package/src/announcements.ts +59 -0
- package/src/background.ts +59 -6
- package/src/config.ts +14 -2
- package/src/dispatch.ts +1833 -845
- package/src/fixloop.ts +1 -1
- package/src/format.ts +30 -4
- package/src/index.ts +9 -10
- package/src/models.ts +184 -54
- package/src/monitor.ts +141 -93
- package/src/prompt.ts +3 -2
- package/src/recovery.ts +145 -0
- package/src/rpc-run.ts +991 -0
- package/src/runtime.ts +284 -145
- package/src/session-fork.ts +84 -0
- package/src/setup.ts +271 -184
- package/src/spawn.ts +557 -977
- package/src/tools.ts +730 -409
- package/src/trajectory.ts +312 -0
- package/src/ui.ts +32 -16
- package/src/worktree.ts +687 -0
- package/src/widget.ts +0 -182
package/src/monitor.ts
CHANGED
|
@@ -2,38 +2,28 @@
|
|
|
2
2
|
* Sub-agent monitor: a module-level singleton store that tracks subagent runs
|
|
3
3
|
* for the current turn.
|
|
4
4
|
*
|
|
5
|
-
* The store notifies
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* as soon as they finish: the tool result is the durable record in the main
|
|
10
|
-
* conversation, so a stale "done" row must not linger in the widget.
|
|
5
|
+
* The store notifies wait/status consumers on every mutation. Each run carries
|
|
6
|
+
* timing information plus a concise activity string ("thinking",
|
|
7
|
+
* "read src/index.ts", ...). Runs are removed after publication; tool results
|
|
8
|
+
* and the finished-run registry are the durable user-facing records.
|
|
11
9
|
*/
|
|
12
10
|
|
|
13
11
|
import { stripVTControlCharacters } from "node:util";
|
|
14
12
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import {
|
|
13
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
16
14
|
import type { UsageStats } from "./spawn.ts";
|
|
15
|
+
import { redactSensitiveText } from "./trajectory.ts";
|
|
16
|
+
import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
|
|
17
17
|
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
// Types
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
|
|
22
|
-
export type RunStatus = "queued" | "running" | "done" | "failed";
|
|
22
|
+
export type RunStatus = "queued" | "running" | "steering" | "interrupting" | "parked" | "done" | "failed";
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
* user can tell a healthy busy run from one that needs a nudge. */
|
|
28
|
-
export type ActivityState = "needs_attention" | "active_long_running";
|
|
29
|
-
|
|
30
|
-
/** A run with no tool running and no activity for this long is "needs attention"
|
|
31
|
-
* (the model may be stuck between turns). Below the idle-kill threshold so the
|
|
32
|
-
* soft signal always fires before the hard kill. */
|
|
33
|
-
export const NEEDS_ATTENTION_AFTER_MS = 60_000;
|
|
34
|
-
/** A run whose total elapsed time exceeds this is "long-running": still active
|
|
35
|
-
* but worth flagging so the user can decide whether to wait or steer. */
|
|
36
|
-
export const ACTIVE_LONG_RUNNING_AFTER_MS = 240_000;
|
|
24
|
+
export function isRunActiveStatus(status: RunStatus): boolean {
|
|
25
|
+
return status === "queued" || status === "running" || status === "steering" || status === "interrupting";
|
|
26
|
+
}
|
|
37
27
|
|
|
38
28
|
export interface RunView {
|
|
39
29
|
id: number;
|
|
@@ -44,8 +34,14 @@ export interface RunView {
|
|
|
44
34
|
* are doing, not just their run id. */
|
|
45
35
|
label?: string;
|
|
46
36
|
model?: string;
|
|
37
|
+
/** Primary model ref when the run advanced to another candidate in its pool. */
|
|
38
|
+
modelFallbackFrom?: string;
|
|
47
39
|
/** Effective thinking strength this run was launched with (frontmatter/config/global). */
|
|
48
40
|
thinking?: string;
|
|
41
|
+
isolation?: IsolationMode;
|
|
42
|
+
integrationStatus?: "pending" | WorktreeFinalizationStatus;
|
|
43
|
+
forkedFromRunId?: number;
|
|
44
|
+
forkChildRunIds?: number[];
|
|
49
45
|
status: RunStatus;
|
|
50
46
|
usage: UsageStats;
|
|
51
47
|
/** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
|
|
@@ -66,15 +62,14 @@ export interface RunView {
|
|
|
66
62
|
groupId?: string;
|
|
67
63
|
/** Human-readable role within a chain, e.g. "fix round 1" or "re-review round 1". */
|
|
68
64
|
relationLabel?: string;
|
|
69
|
-
/** Free-form note
|
|
65
|
+
/** Free-form orchestration note (e.g. "auto-fix chain running"). */
|
|
70
66
|
annotation?: string;
|
|
71
|
-
/** One-line outcome summary of a finished chain run
|
|
72
|
-
* each auto-fix round reads as what it did: a reviewer reports its verdict
|
|
67
|
+
/** One-line outcome summary of a finished chain run: a reviewer reports its verdict
|
|
73
68
|
* plus key fragments of what it found ("fail · src/index.ts · render()"), a
|
|
74
69
|
* worker the fragments of what it changed. Unset for non-chain runs. */
|
|
75
70
|
summary?: string;
|
|
76
|
-
/** True when a finished run
|
|
77
|
-
*
|
|
71
|
+
/** True when a finished run remains in monitor state (e.g. an auto-fix
|
|
72
|
+
* parent whose chain is still running). beginTurn preserves
|
|
78
73
|
* retained runs so they are not swept between turns. */
|
|
79
74
|
retained?: boolean;
|
|
80
75
|
}
|
|
@@ -83,6 +78,8 @@ export interface RunView {
|
|
|
83
78
|
export interface RunChainMeta {
|
|
84
79
|
groupId?: string;
|
|
85
80
|
relationLabel?: string;
|
|
81
|
+
isolation?: IsolationMode;
|
|
82
|
+
forkedFromRunId?: number;
|
|
86
83
|
}
|
|
87
84
|
|
|
88
85
|
// ---------------------------------------------------------------------------
|
|
@@ -94,7 +91,7 @@ const TASK_SUMMARY_ELLIPSIS = "…";
|
|
|
94
91
|
/** Columns reserved at the END of a truncated summary so the distinguishing
|
|
95
92
|
* keywords (paths, symbols, ...) survive; the head gets the rest. */
|
|
96
93
|
const TASK_SUMMARY_TAIL_MAX = 28;
|
|
97
|
-
/** Tail share of a non-default maxWidth (narrow
|
|
94
|
+
/** Tail share of a non-default maxWidth (narrow summaries keep a usable tail). */
|
|
98
95
|
const TASK_SUMMARY_TAIL_SHARE = 0.35;
|
|
99
96
|
const TASK_SUMMARY_TAIL_MIN = 8;
|
|
100
97
|
const TASK_SUMMARY_KEY_SEP = " · ";
|
|
@@ -292,63 +289,18 @@ export function formatElapsed(run: RunView, now: number = Date.now()): string {
|
|
|
292
289
|
return formatDuration(end - run.startedAt);
|
|
293
290
|
}
|
|
294
291
|
|
|
295
|
-
/** Strip the provider prefix from a "provider/model-id" reference for compact
|
|
296
|
-
* widget display ("anthropic/claude-sonnet-4" → "claude-sonnet-4"). A bare id is
|
|
297
|
-
* left unchanged. */
|
|
298
|
-
export function compactModelRef(model: string | undefined): string {
|
|
299
|
-
if (!model) return "";
|
|
300
|
-
const slash = model.lastIndexOf("/");
|
|
301
|
-
return slash >= 0 ? model.slice(slash + 1) : model;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/** Human-readable label for a soft activity-state annotation. */
|
|
305
|
-
export function activityStateLabel(state: ActivityState): string {
|
|
306
|
-
return state === "needs_attention" ? "idle" : "long-running";
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/** Derive the soft activity state of a run at render time: needs_attention
|
|
310
|
-
* (no tool running, idle past the threshold) takes priority over
|
|
311
|
-
* active_long_running (total elapsed past its threshold). Both are suppressed
|
|
312
|
-
* for non-running runs. */
|
|
313
|
-
export function deriveActivityState(run: RunView, now: number = Date.now()): ActivityState | undefined {
|
|
314
|
-
if (run.status !== "running") return undefined;
|
|
315
|
-
if (!run.currentTool) {
|
|
316
|
-
const since = run.lastActivityAt ?? run.startedAt ?? now;
|
|
317
|
-
if (now - since >= NEEDS_ATTENTION_AFTER_MS) return "needs_attention";
|
|
318
|
-
}
|
|
319
|
-
if (run.startedAt !== undefined && now - run.startedAt >= ACTIVE_LONG_RUNNING_AFTER_MS) {
|
|
320
|
-
return "active_long_running";
|
|
321
|
-
}
|
|
322
|
-
return undefined;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/** Left/right split a widget line so the right side (status, elapsed) is always
|
|
326
|
-
* visible and the left side (title) clips on overflow instead of pushing it off.
|
|
327
|
-
* `left`/`right` may carry ANSI styling; widths are measured display-column-wise. */
|
|
328
|
-
export function rightAlign(left: string, right: string, width: number): string {
|
|
329
|
-
const rightWidth = visibleWidth(right);
|
|
330
|
-
const leftMax = Math.max(0, width - rightWidth - 1);
|
|
331
|
-
const leftClipped = truncateToWidth(left, leftMax);
|
|
332
|
-
const gap = Math.max(1, width - visibleWidth(leftClipped) - rightWidth);
|
|
333
|
-
return truncateToWidth(`${leftClipped}${" ".repeat(gap)}${right}`, width);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/** Concatenate `left` and `right` (no center padding) and clip the combined line
|
|
337
|
-
* to `width`. Unlike {@link rightAlign}, the right side trails the left side
|
|
338
|
-
* instead of being pinned to the far edge, so a short header leaves no gap in
|
|
339
|
-
* the middle. `right` should carry its own leading separator (e.g. ` \u00b7 `);
|
|
340
|
-
* pass an empty string when there is nothing to append. Styled strings are
|
|
341
|
-
* measured by display width. */
|
|
342
|
-
export function compactLine(left: string, right: string, width: number): string {
|
|
343
|
-
return truncateToWidth(`${left}${right}`, width);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
292
|
/** Max length of the argument target inside a formatted activity line. */
|
|
347
293
|
export const ACTIVITY_TARGET_MAX = 60;
|
|
348
294
|
|
|
295
|
+
/** Monitor activity is returned to the parent model and rendered in the terminal,
|
|
296
|
+
* so treat every live string as untrusted before it reaches store state. */
|
|
297
|
+
function sanitizeActivityText(value: string): string {
|
|
298
|
+
return redactSensitiveText(value).replace(/\s+/g, " ").trim();
|
|
299
|
+
}
|
|
300
|
+
|
|
349
301
|
function shortTarget(value: unknown): string {
|
|
350
302
|
if (typeof value !== "string") return "";
|
|
351
|
-
const oneLine = value
|
|
303
|
+
const oneLine = sanitizeActivityText(value);
|
|
352
304
|
// Slice by code point so emoji / CJK-ext never leave a lone surrogate.
|
|
353
305
|
const chars = [...oneLine];
|
|
354
306
|
return chars.length > ACTIVITY_TARGET_MAX ? `${chars.slice(0, ACTIVITY_TARGET_MAX - 1).join("")}…` : oneLine;
|
|
@@ -397,7 +349,8 @@ export function formatToolActivity(toolName: string, args: unknown): string {
|
|
|
397
349
|
default:
|
|
398
350
|
target = pick("path", "command", "query", "pattern", "url", "file", "task");
|
|
399
351
|
}
|
|
400
|
-
|
|
352
|
+
const safeToolName = sanitizeActivityText(toolName) || "tool";
|
|
353
|
+
return target ? `${safeToolName} ${target}` : safeToolName;
|
|
401
354
|
}
|
|
402
355
|
|
|
403
356
|
// ---------------------------------------------------------------------------
|
|
@@ -416,7 +369,7 @@ export class MonitorStore {
|
|
|
416
369
|
// running) are also preserved — their status is "done" but they must
|
|
417
370
|
// stay visible until the chain resolves.
|
|
418
371
|
this.runs = this.runs.filter(
|
|
419
|
-
(r) => r.status
|
|
372
|
+
(r) => isRunActiveStatus(r.status) || r.status === "parked" || r.retained,
|
|
420
373
|
);
|
|
421
374
|
this.notify();
|
|
422
375
|
}
|
|
@@ -434,6 +387,8 @@ export class MonitorStore {
|
|
|
434
387
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
435
388
|
...(meta?.groupId ? { groupId: meta.groupId } : {}),
|
|
436
389
|
...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
|
|
390
|
+
...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined } : {}),
|
|
391
|
+
...(meta?.forkedFromRunId !== undefined ? { forkedFromRunId: meta.forkedFromRunId } : {}),
|
|
437
392
|
});
|
|
438
393
|
this.notify();
|
|
439
394
|
return id;
|
|
@@ -443,13 +398,13 @@ export class MonitorStore {
|
|
|
443
398
|
const run = this.find(id);
|
|
444
399
|
if (!run) return;
|
|
445
400
|
run.status = status;
|
|
446
|
-
if (status === "running") {
|
|
401
|
+
if (status === "running" || status === "steering" || status === "interrupting") {
|
|
447
402
|
if (run.startedAt === undefined) run.startedAt = Date.now();
|
|
448
|
-
// A model-fallback retry
|
|
403
|
+
// A model-fallback retry or resumed generation restarts the clock; a
|
|
449
404
|
// stale endedAt would freeze the elapsed display at the first attempt.
|
|
450
405
|
if (run.endedAt !== undefined) run.endedAt = undefined;
|
|
451
406
|
run.lastActivityAt = Date.now();
|
|
452
|
-
} else if ((status === "done" || status === "failed") && run.endedAt === undefined) {
|
|
407
|
+
} else if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
|
|
453
408
|
run.endedAt = Date.now();
|
|
454
409
|
}
|
|
455
410
|
this.notify();
|
|
@@ -463,11 +418,20 @@ export class MonitorStore {
|
|
|
463
418
|
this.notify();
|
|
464
419
|
}
|
|
465
420
|
|
|
421
|
+
/** Record the final actual model and primary-to-backup transition. */
|
|
422
|
+
setModel(id: number, model?: string, fallbackFrom?: string): void {
|
|
423
|
+
const run = this.find(id);
|
|
424
|
+
if (!run) return;
|
|
425
|
+
if (model) run.model = model;
|
|
426
|
+
run.modelFallbackFrom = fallbackFrom;
|
|
427
|
+
this.notify();
|
|
428
|
+
}
|
|
429
|
+
|
|
466
430
|
/** Update the run's current one-line activity (what it is doing now). */
|
|
467
431
|
setActivity(id: number, text: string): void {
|
|
468
432
|
const run = this.find(id);
|
|
469
433
|
if (!run) return;
|
|
470
|
-
run.activity = text;
|
|
434
|
+
run.activity = sanitizeActivityText(text) || undefined;
|
|
471
435
|
run.lastActivityAt = Date.now();
|
|
472
436
|
this.notify();
|
|
473
437
|
}
|
|
@@ -478,9 +442,10 @@ export class MonitorStore {
|
|
|
478
442
|
recordToolStart(id: number, toolName: string, activity: string): void {
|
|
479
443
|
const run = this.find(id);
|
|
480
444
|
if (!run) return;
|
|
445
|
+
const safeToolName = sanitizeActivityText(toolName) || "tool";
|
|
481
446
|
run.toolCount = (run.toolCount ?? 0) + 1;
|
|
482
|
-
run.currentTool =
|
|
483
|
-
run.activity = activity;
|
|
447
|
+
run.currentTool = safeToolName;
|
|
448
|
+
run.activity = sanitizeActivityText(activity) || safeToolName;
|
|
484
449
|
run.lastActivityAt = Date.now();
|
|
485
450
|
this.notify();
|
|
486
451
|
}
|
|
@@ -492,11 +457,11 @@ export class MonitorStore {
|
|
|
492
457
|
if (!run) return;
|
|
493
458
|
run.currentTool = undefined;
|
|
494
459
|
run.lastActivityAt = Date.now();
|
|
495
|
-
if (isError) run.activity = `✗ ${toolName} failed`;
|
|
460
|
+
if (isError) run.activity = `✗ ${sanitizeActivityText(toolName) || "tool"} failed`;
|
|
496
461
|
this.notify();
|
|
497
462
|
}
|
|
498
463
|
|
|
499
|
-
/** Set
|
|
464
|
+
/** Set an orchestration note on the run (e.g. auto-fix chain running). */
|
|
500
465
|
setAnnotation(id: number, text: string): void {
|
|
501
466
|
const run = this.find(id);
|
|
502
467
|
if (!run) return;
|
|
@@ -512,7 +477,7 @@ export class MonitorStore {
|
|
|
512
477
|
this.notify();
|
|
513
478
|
}
|
|
514
479
|
|
|
515
|
-
/**
|
|
480
|
+
/** Keep a finished chain step in status state until its group settles. */
|
|
516
481
|
setRetained(id: number, retained: boolean): void {
|
|
517
482
|
const run = this.find(id);
|
|
518
483
|
if (!run) return;
|
|
@@ -520,6 +485,73 @@ export class MonitorStore {
|
|
|
520
485
|
this.notify();
|
|
521
486
|
}
|
|
522
487
|
|
|
488
|
+
setIsolation(id: number, isolation: IsolationMode, integrationStatus?: "pending" | WorktreeFinalizationStatus): void {
|
|
489
|
+
const run = this.find(id);
|
|
490
|
+
if (!run) return;
|
|
491
|
+
run.isolation = isolation;
|
|
492
|
+
run.integrationStatus = integrationStatus;
|
|
493
|
+
this.notify();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
setForkRelation(sourceRunId: number, childRunId: number): void {
|
|
497
|
+
const source = this.find(sourceRunId);
|
|
498
|
+
if (source) {
|
|
499
|
+
source.forkChildRunIds ??= [];
|
|
500
|
+
if (!source.forkChildRunIds.includes(childRunId)) source.forkChildRunIds.push(childRunId);
|
|
501
|
+
}
|
|
502
|
+
const child = this.find(childRunId);
|
|
503
|
+
if (child) child.forkedFromRunId = sourceRunId;
|
|
504
|
+
this.notify();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Update the objective shown for a queued retarget or resumed generation. */
|
|
508
|
+
setTask(id: number, task: string): void {
|
|
509
|
+
const run = this.find(id);
|
|
510
|
+
if (!run) return;
|
|
511
|
+
run.task = task;
|
|
512
|
+
run.label = runLabel(task);
|
|
513
|
+
this.notify();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Reuse a stable logical run id for a resumed generation. */
|
|
517
|
+
restartRun(id: number, agent: string, task: string, model?: string, thinking?: string, isolation?: IsolationMode): void {
|
|
518
|
+
const run = this.find(id);
|
|
519
|
+
if (!run) {
|
|
520
|
+
this.runs.push({
|
|
521
|
+
id,
|
|
522
|
+
agent,
|
|
523
|
+
task,
|
|
524
|
+
label: runLabel(task),
|
|
525
|
+
model,
|
|
526
|
+
thinking,
|
|
527
|
+
...(isolation ? { isolation, integrationStatus: isolation === "worktree" ? "pending" as const : undefined } : {}),
|
|
528
|
+
status: "queued",
|
|
529
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
530
|
+
});
|
|
531
|
+
this.notify();
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
run.agent = agent;
|
|
535
|
+
run.task = task;
|
|
536
|
+
run.label = runLabel(task);
|
|
537
|
+
run.model = model;
|
|
538
|
+
run.thinking = thinking;
|
|
539
|
+
if (isolation) run.isolation = isolation;
|
|
540
|
+
run.integrationStatus = isolation === "worktree" ? "pending" : undefined;
|
|
541
|
+
run.status = "queued";
|
|
542
|
+
run.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
|
|
543
|
+
run.activity = undefined;
|
|
544
|
+
run.toolCount = undefined;
|
|
545
|
+
run.currentTool = undefined;
|
|
546
|
+
run.lastActivityAt = undefined;
|
|
547
|
+
run.startedAt = undefined;
|
|
548
|
+
run.endedAt = undefined;
|
|
549
|
+
run.annotation = undefined;
|
|
550
|
+
run.summary = undefined;
|
|
551
|
+
run.retained = undefined;
|
|
552
|
+
this.notify();
|
|
553
|
+
}
|
|
554
|
+
|
|
523
555
|
/** Look up a run by id without removing it. */
|
|
524
556
|
findRun(id: number): RunView | undefined {
|
|
525
557
|
return this.find(id);
|
|
@@ -533,7 +565,7 @@ export class MonitorStore {
|
|
|
533
565
|
this.notify();
|
|
534
566
|
}
|
|
535
567
|
|
|
536
|
-
/** Remove a run
|
|
568
|
+
/** Remove a run after publication. Returns the removed run. */
|
|
537
569
|
removeRun(id: number): RunView | undefined {
|
|
538
570
|
const index = this.runs.findIndex((r) => r.id === id);
|
|
539
571
|
if (index === -1) return undefined;
|
|
@@ -560,6 +592,7 @@ export class MonitorStore {
|
|
|
560
592
|
if (run.summary) parts.push(run.summary);
|
|
561
593
|
if (run.model) parts.push(run.model);
|
|
562
594
|
if (run.thinking) parts.push(`thinking ${run.thinking}`);
|
|
595
|
+
if (run.isolation === "worktree") parts.push(`worktree ${run.integrationStatus ?? "active"}`);
|
|
563
596
|
if (usage) parts.push(usage);
|
|
564
597
|
const elapsed = formatElapsed(run);
|
|
565
598
|
if (elapsed) parts.push(elapsed);
|
|
@@ -591,6 +624,12 @@ export function statusIcon(status: RunStatus, theme: Theme): string {
|
|
|
591
624
|
switch (status) {
|
|
592
625
|
case "running":
|
|
593
626
|
return theme.fg("accent", "●");
|
|
627
|
+
case "steering":
|
|
628
|
+
return theme.fg("accent", "◆");
|
|
629
|
+
case "interrupting":
|
|
630
|
+
return theme.fg("warning", "◐");
|
|
631
|
+
case "parked":
|
|
632
|
+
return theme.fg("dim", "■");
|
|
594
633
|
case "done":
|
|
595
634
|
return theme.fg("success", "✓");
|
|
596
635
|
case "failed":
|
|
@@ -600,13 +639,19 @@ export function statusIcon(status: RunStatus, theme: Theme): string {
|
|
|
600
639
|
}
|
|
601
640
|
}
|
|
602
641
|
|
|
603
|
-
/** User-facing status label
|
|
642
|
+
/** User-facing status label used by tool/status rendering. */
|
|
604
643
|
export function statusLabel(status: RunStatus): string {
|
|
605
644
|
switch (status) {
|
|
606
645
|
case "queued":
|
|
607
646
|
return "ready";
|
|
608
647
|
case "running":
|
|
609
648
|
return "running";
|
|
649
|
+
case "steering":
|
|
650
|
+
return "steering";
|
|
651
|
+
case "interrupting":
|
|
652
|
+
return "interrupting";
|
|
653
|
+
case "parked":
|
|
654
|
+
return "parked";
|
|
610
655
|
case "done":
|
|
611
656
|
return "done";
|
|
612
657
|
case "failed":
|
|
@@ -615,10 +660,13 @@ export function statusLabel(status: RunStatus): string {
|
|
|
615
660
|
}
|
|
616
661
|
|
|
617
662
|
/** Theme color matching the status label. */
|
|
618
|
-
export function statusColor(status: RunStatus): "accent" | "success" | "error" | "dim" {
|
|
663
|
+
export function statusColor(status: RunStatus): "accent" | "success" | "error" | "warning" | "dim" {
|
|
619
664
|
switch (status) {
|
|
620
665
|
case "running":
|
|
666
|
+
case "steering":
|
|
621
667
|
return "accent";
|
|
668
|
+
case "interrupting":
|
|
669
|
+
return "warning";
|
|
622
670
|
case "done":
|
|
623
671
|
return "success";
|
|
624
672
|
case "failed":
|
package/src/prompt.ts
CHANGED
|
@@ -58,8 +58,9 @@ ${routing ? `Routing:\n${routing}\n` : ""}Dispatch discipline:
|
|
|
58
58
|
- Delegate only when isolation genuinely pays: a self-contained implementation/fix with its own validation (worker), or a fresh-context review gate (reviewer).
|
|
59
59
|
- When in doubt, start with a direct tool call in the main context; escalate to \`explore\` as soon as the search turns broad or crosses multiple files.
|
|
60
60
|
- For an already-known or trivial target, use a direct search/read tool (e.g. grep/find/read) — do not over-delegate a one-line lookup.
|
|
61
|
-
${hasMultiple ? "- Run INDEPENDENT tasks in parallel: one subagent call with a `tasks` array, and track them with your todo list. Let the automatically resumed main agent launch dependent work only after its prerequisite result arrives (e.g. explore, then worker, then reviewer).\n" : ""}-
|
|
62
|
-
-
|
|
61
|
+
${hasMultiple ? "- Run INDEPENDENT tasks in parallel: one subagent call with a `tasks` array, and track them with your todo list. Parallel worker items default to detached Git worktree isolation; pass `isolation: \"shared\"` only when a worker intentionally needs the caller's live uncommitted tree. Let the automatically resumed main agent launch dependent work only after its prerequisite result arrives (e.g. explore, then worker, then reviewer).\n" : ""}- Single dispatch stays in the shared working tree by default. Use \`isolation: "worktree"\` only for worker/write-capable agents in a Git repository; never request it for explore/reviewer, and never silently retry shared after setup fails.
|
|
62
|
+
- Brief each sub-agent as self-contained: goal, exact paths, constraints, expected output. It has NO memory of this conversation.
|
|
63
|
+
- Treat delegated agents as leaf workers: do not ask a sub-agent to dispatch another sub-agent; child processes do not have this tool. Use \`subagent_control fork\` on a parked/settled retained thread when you need an independent continuation with preserved context and a new run id.
|
|
63
64
|
- Trust but verify: a sub-agent's summary describes intent, not outcome. Check the actual changes/results before reporting work done.
|
|
64
65
|
|
|
65
66
|
Vision tasks:
|
package/src/recovery.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** Durable handoff for worktree integration/cleanup failures across sessions. */
|
|
2
|
+
|
|
3
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { stripVTControlCharacters } from "node:util";
|
|
8
|
+
import type { WorktreeFinalization } from "./worktree.ts";
|
|
9
|
+
|
|
10
|
+
export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
|
|
11
|
+
const RECOVERY_MANIFEST_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
export interface RecoveryRecord {
|
|
14
|
+
runId: number;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
integrated: boolean;
|
|
17
|
+
worktreePath?: string;
|
|
18
|
+
patchPath?: string;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RecoveryManifest {
|
|
23
|
+
version: number;
|
|
24
|
+
records: RecoveryRecord[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getRecoveryManifestPath(configPath: string): string {
|
|
28
|
+
return join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeRecord(value: unknown): RecoveryRecord | undefined {
|
|
32
|
+
if (!value || typeof value !== "object") return undefined;
|
|
33
|
+
const raw = value as Record<string, unknown>;
|
|
34
|
+
if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
|
|
35
|
+
if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
|
|
36
|
+
return {
|
|
37
|
+
runId: raw.runId,
|
|
38
|
+
createdAt: raw.createdAt,
|
|
39
|
+
integrated: raw.integrated === true,
|
|
40
|
+
...(typeof raw.worktreePath === "string" && raw.worktreePath ? { worktreePath: raw.worktreePath } : {}),
|
|
41
|
+
...(typeof raw.patchPath === "string" && raw.patchPath ? { patchPath: raw.patchPath } : {}),
|
|
42
|
+
...(typeof raw.error === "string" && raw.error ? { error: raw.error } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(await readFile(getRecoveryManifestPath(configPath), "utf8")) as {
|
|
49
|
+
records?: unknown;
|
|
50
|
+
};
|
|
51
|
+
if (!Array.isArray(parsed.records)) return [];
|
|
52
|
+
return parsed.records.flatMap((record) => {
|
|
53
|
+
const normalized = normalizeRecord(record);
|
|
54
|
+
return normalized ? [normalized] : [];
|
|
55
|
+
});
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function recoveryKey(record: RecoveryRecord): string {
|
|
62
|
+
return `${record.runId}\0${record.worktreePath ?? ""}\0${record.patchPath ?? ""}\0${record.error ?? ""}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function writeManifest(path: string, records: readonly RecoveryRecord[]): Promise<void> {
|
|
66
|
+
if (records.length === 0) {
|
|
67
|
+
await rm(path, { force: true });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await mkdir(dirname(path), { recursive: true });
|
|
71
|
+
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
72
|
+
try {
|
|
73
|
+
const manifest: RecoveryManifest = {
|
|
74
|
+
version: RECOVERY_MANIFEST_VERSION,
|
|
75
|
+
records: [...records],
|
|
76
|
+
};
|
|
77
|
+
await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
78
|
+
await rename(temporaryPath, path);
|
|
79
|
+
} finally {
|
|
80
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Merge retained artifacts into the durable manifest. */
|
|
85
|
+
export async function persistRecoveryRecords(
|
|
86
|
+
configPath: string,
|
|
87
|
+
records: readonly RecoveryRecord[],
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
if (records.length === 0) return;
|
|
90
|
+
const path = getRecoveryManifestPath(configPath);
|
|
91
|
+
await withFileMutationQueue(path, async () => {
|
|
92
|
+
const merged = new Map<string, RecoveryRecord>();
|
|
93
|
+
for (const record of await readRecoveryRecords(configPath)) merged.set(recoveryKey(record), record);
|
|
94
|
+
for (const record of records) merged.set(recoveryKey(record), record);
|
|
95
|
+
await writeManifest(path, [...merged.values()]);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function recoveryRecordFromFinalization(
|
|
100
|
+
runId: number,
|
|
101
|
+
finalization: WorktreeFinalization,
|
|
102
|
+
now = Date.now(),
|
|
103
|
+
): RecoveryRecord {
|
|
104
|
+
return {
|
|
105
|
+
runId,
|
|
106
|
+
createdAt: now,
|
|
107
|
+
integrated: finalization.integrated,
|
|
108
|
+
...(finalization.worktreePath ? { worktreePath: finalization.worktreePath } : {}),
|
|
109
|
+
...(finalization.patchPath ? { patchPath: finalization.patchPath } : {}),
|
|
110
|
+
...(finalization.error ? { error: finalization.error } : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Show retained recovery paths on every later session start until the user
|
|
115
|
+
* removes the artifacts. Stale records are pruned automatically. */
|
|
116
|
+
export async function announceRecoveryRecords(
|
|
117
|
+
configPath: string,
|
|
118
|
+
ctx: {
|
|
119
|
+
hasUI?: boolean;
|
|
120
|
+
ui: { notify(message: string, kind: "info" | "warning" | "error"): void };
|
|
121
|
+
},
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
if (ctx.hasUI === false) return;
|
|
124
|
+
const records = await readRecoveryRecords(configPath);
|
|
125
|
+
if (records.length === 0) return;
|
|
126
|
+
const live = records.filter((record) =>
|
|
127
|
+
(record.worktreePath ? existsSync(record.worktreePath) : false) ||
|
|
128
|
+
(record.patchPath ? existsSync(record.patchPath) : false),
|
|
129
|
+
);
|
|
130
|
+
if (live.length !== records.length) {
|
|
131
|
+
const path = getRecoveryManifestPath(configPath);
|
|
132
|
+
await withFileMutationQueue(path, () => writeManifest(path, live)).catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
for (const record of live) {
|
|
135
|
+
const paths = [
|
|
136
|
+
record.worktreePath ? `worktree ${stripVTControlCharacters(record.worktreePath)}` : undefined,
|
|
137
|
+
record.patchPath ? `patch ${stripVTControlCharacters(record.patchPath)}` : undefined,
|
|
138
|
+
].filter(Boolean).join(" · ");
|
|
139
|
+
const reason = record.error ? ` · ${stripVTControlCharacters(record.error)}` : "";
|
|
140
|
+
ctx.ui.notify(
|
|
141
|
+
`pi-subagents recovery for run #${record.runId}: ${record.integrated ? "changes were applied but cleanup failed" : "integration failed"}${paths ? ` · retained ${paths}` : ""}${reason}`,
|
|
142
|
+
"error",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|