@ferris1225/pi-subagents 0.31.0 → 0.32.2

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/src/monitor.ts CHANGED
@@ -14,12 +14,17 @@ import { stripVTControlCharacters } from "node:util";
14
14
  import type { Theme } from "@earendil-works/pi-coding-agent";
15
15
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
16
16
  import type { UsageStats } from "./spawn.ts";
17
+ import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // Types
20
21
  // ---------------------------------------------------------------------------
21
22
 
22
- export type RunStatus = "queued" | "running" | "done" | "failed";
23
+ export type RunStatus = "queued" | "running" | "steering" | "interrupting" | "parked" | "done" | "failed";
24
+
25
+ export function isRunActiveStatus(status: RunStatus): boolean {
26
+ return status === "queued" || status === "running" || status === "steering" || status === "interrupting";
27
+ }
23
28
 
24
29
  /** Soft state-awareness signals, complementary to the hard idle-kill: a run may
25
30
  * be alive (stdout streaming) yet "stuck thinking" (no tool running for a while),
@@ -44,8 +49,14 @@ export interface RunView {
44
49
  * are doing, not just their run id. */
45
50
  label?: string;
46
51
  model?: string;
52
+ /** Primary model ref when the run advanced to another candidate in its pool. */
53
+ modelFallbackFrom?: string;
47
54
  /** Effective thinking strength this run was launched with (frontmatter/config/global). */
48
55
  thinking?: string;
56
+ isolation?: IsolationMode;
57
+ integrationStatus?: "pending" | WorktreeFinalizationStatus;
58
+ forkedFromRunId?: number;
59
+ forkChildRunIds?: number[];
49
60
  status: RunStatus;
50
61
  usage: UsageStats;
51
62
  /** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
@@ -83,6 +94,8 @@ export interface RunView {
83
94
  export interface RunChainMeta {
84
95
  groupId?: string;
85
96
  relationLabel?: string;
97
+ isolation?: IsolationMode;
98
+ forkedFromRunId?: number;
86
99
  }
87
100
 
88
101
  // ---------------------------------------------------------------------------
@@ -311,7 +324,7 @@ export function activityStateLabel(state: ActivityState): string {
311
324
  * active_long_running (total elapsed past its threshold). Both are suppressed
312
325
  * for non-running runs. */
313
326
  export function deriveActivityState(run: RunView, now: number = Date.now()): ActivityState | undefined {
314
- if (run.status !== "running") return undefined;
327
+ if (run.status !== "running" && run.status !== "steering") return undefined;
315
328
  if (!run.currentTool) {
316
329
  const since = run.lastActivityAt ?? run.startedAt ?? now;
317
330
  if (now - since >= NEEDS_ATTENTION_AFTER_MS) return "needs_attention";
@@ -416,7 +429,7 @@ export class MonitorStore {
416
429
  // running) are also preserved — their status is "done" but they must
417
430
  // stay visible until the chain resolves.
418
431
  this.runs = this.runs.filter(
419
- (r) => r.status === "queued" || r.status === "running" || r.retained,
432
+ (r) => isRunActiveStatus(r.status) || r.status === "parked" || r.retained,
420
433
  );
421
434
  this.notify();
422
435
  }
@@ -434,6 +447,8 @@ export class MonitorStore {
434
447
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
435
448
  ...(meta?.groupId ? { groupId: meta.groupId } : {}),
436
449
  ...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
450
+ ...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined } : {}),
451
+ ...(meta?.forkedFromRunId !== undefined ? { forkedFromRunId: meta.forkedFromRunId } : {}),
437
452
  });
438
453
  this.notify();
439
454
  return id;
@@ -443,13 +458,13 @@ export class MonitorStore {
443
458
  const run = this.find(id);
444
459
  if (!run) return;
445
460
  run.status = status;
446
- if (status === "running") {
461
+ if (status === "running" || status === "steering" || status === "interrupting") {
447
462
  if (run.startedAt === undefined) run.startedAt = Date.now();
448
- // A model-fallback retry after a failed attempt restarts the clock; a
463
+ // A model-fallback retry or resumed generation restarts the clock; a
449
464
  // stale endedAt would freeze the elapsed display at the first attempt.
450
465
  if (run.endedAt !== undefined) run.endedAt = undefined;
451
466
  run.lastActivityAt = Date.now();
452
- } else if ((status === "done" || status === "failed") && run.endedAt === undefined) {
467
+ } else if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
453
468
  run.endedAt = Date.now();
454
469
  }
455
470
  this.notify();
@@ -463,6 +478,15 @@ export class MonitorStore {
463
478
  this.notify();
464
479
  }
465
480
 
481
+ /** Record the final actual model and primary-to-backup transition. */
482
+ setModel(id: number, model?: string, fallbackFrom?: string): void {
483
+ const run = this.find(id);
484
+ if (!run) return;
485
+ if (model) run.model = model;
486
+ run.modelFallbackFrom = fallbackFrom;
487
+ this.notify();
488
+ }
489
+
466
490
  /** Update the run's current one-line activity (what it is doing now). */
467
491
  setActivity(id: number, text: string): void {
468
492
  const run = this.find(id);
@@ -520,6 +544,73 @@ export class MonitorStore {
520
544
  this.notify();
521
545
  }
522
546
 
547
+ setIsolation(id: number, isolation: IsolationMode, integrationStatus?: "pending" | WorktreeFinalizationStatus): void {
548
+ const run = this.find(id);
549
+ if (!run) return;
550
+ run.isolation = isolation;
551
+ run.integrationStatus = integrationStatus;
552
+ this.notify();
553
+ }
554
+
555
+ setForkRelation(sourceRunId: number, childRunId: number): void {
556
+ const source = this.find(sourceRunId);
557
+ if (source) {
558
+ source.forkChildRunIds ??= [];
559
+ if (!source.forkChildRunIds.includes(childRunId)) source.forkChildRunIds.push(childRunId);
560
+ }
561
+ const child = this.find(childRunId);
562
+ if (child) child.forkedFromRunId = sourceRunId;
563
+ this.notify();
564
+ }
565
+
566
+ /** Update the objective shown for a queued retarget or resumed generation. */
567
+ setTask(id: number, task: string): void {
568
+ const run = this.find(id);
569
+ if (!run) return;
570
+ run.task = task;
571
+ run.label = runLabel(task);
572
+ this.notify();
573
+ }
574
+
575
+ /** Reuse a stable logical run id for a resumed generation. */
576
+ restartRun(id: number, agent: string, task: string, model?: string, thinking?: string, isolation?: IsolationMode): void {
577
+ const run = this.find(id);
578
+ if (!run) {
579
+ this.runs.push({
580
+ id,
581
+ agent,
582
+ task,
583
+ label: runLabel(task),
584
+ model,
585
+ thinking,
586
+ ...(isolation ? { isolation, integrationStatus: isolation === "worktree" ? "pending" as const : undefined } : {}),
587
+ status: "queued",
588
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
589
+ });
590
+ this.notify();
591
+ return;
592
+ }
593
+ run.agent = agent;
594
+ run.task = task;
595
+ run.label = runLabel(task);
596
+ run.model = model;
597
+ run.thinking = thinking;
598
+ if (isolation) run.isolation = isolation;
599
+ run.integrationStatus = isolation === "worktree" ? "pending" : undefined;
600
+ run.status = "queued";
601
+ run.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
602
+ run.activity = undefined;
603
+ run.toolCount = undefined;
604
+ run.currentTool = undefined;
605
+ run.lastActivityAt = undefined;
606
+ run.startedAt = undefined;
607
+ run.endedAt = undefined;
608
+ run.annotation = undefined;
609
+ run.summary = undefined;
610
+ run.retained = undefined;
611
+ this.notify();
612
+ }
613
+
523
614
  /** Look up a run by id without removing it. */
524
615
  findRun(id: number): RunView | undefined {
525
616
  return this.find(id);
@@ -560,6 +651,7 @@ export class MonitorStore {
560
651
  if (run.summary) parts.push(run.summary);
561
652
  if (run.model) parts.push(run.model);
562
653
  if (run.thinking) parts.push(`thinking ${run.thinking}`);
654
+ if (run.isolation === "worktree") parts.push(`worktree ${run.integrationStatus ?? "active"}`);
563
655
  if (usage) parts.push(usage);
564
656
  const elapsed = formatElapsed(run);
565
657
  if (elapsed) parts.push(elapsed);
@@ -591,6 +683,12 @@ export function statusIcon(status: RunStatus, theme: Theme): string {
591
683
  switch (status) {
592
684
  case "running":
593
685
  return theme.fg("accent", "●");
686
+ case "steering":
687
+ return theme.fg("accent", "◆");
688
+ case "interrupting":
689
+ return theme.fg("warning", "◐");
690
+ case "parked":
691
+ return theme.fg("dim", "■");
594
692
  case "done":
595
693
  return theme.fg("success", "✓");
596
694
  case "failed":
@@ -607,6 +705,12 @@ export function statusLabel(status: RunStatus): string {
607
705
  return "ready";
608
706
  case "running":
609
707
  return "running";
708
+ case "steering":
709
+ return "steering";
710
+ case "interrupting":
711
+ return "interrupting";
712
+ case "parked":
713
+ return "parked";
610
714
  case "done":
611
715
  return "done";
612
716
  case "failed":
@@ -615,10 +719,13 @@ export function statusLabel(status: RunStatus): string {
615
719
  }
616
720
 
617
721
  /** Theme color matching the status label. */
618
- export function statusColor(status: RunStatus): "accent" | "success" | "error" | "dim" {
722
+ export function statusColor(status: RunStatus): "accent" | "success" | "error" | "warning" | "dim" {
619
723
  switch (status) {
620
724
  case "running":
725
+ case "steering":
621
726
  return "accent";
727
+ case "interrupting":
728
+ return "warning";
622
729
  case "done":
623
730
  return "success";
624
731
  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" : ""}- Brief each sub-agent as self-contained: goal, exact paths, constraints, expected output. It has NO memory of this conversation.
62
- - Treat delegated agents as leaf workers: do not ask a sub-agent to dispatch another sub-agent; child processes do not have this tool.
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:
@@ -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
+ }