@ferris1225/pi-subagents 0.29.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),
@@ -39,9 +44,19 @@ export interface RunView {
39
44
  id: number;
40
45
  agent: string;
41
46
  task: string;
47
+ /** Short content label derived from the task (paths/symbols), shown next to
48
+ * the agent name so concurrent same-agent runs are told apart by what they
49
+ * are doing, not just their run id. */
50
+ label?: string;
42
51
  model?: string;
52
+ /** Primary model ref when the run advanced to another candidate in its pool. */
53
+ modelFallbackFrom?: string;
43
54
  /** Effective thinking strength this run was launched with (frontmatter/config/global). */
44
55
  thinking?: string;
56
+ isolation?: IsolationMode;
57
+ integrationStatus?: "pending" | WorktreeFinalizationStatus;
58
+ forkedFromRunId?: number;
59
+ forkChildRunIds?: number[];
45
60
  status: RunStatus;
46
61
  usage: UsageStats;
47
62
  /** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
@@ -79,6 +94,8 @@ export interface RunView {
79
94
  export interface RunChainMeta {
80
95
  groupId?: string;
81
96
  relationLabel?: string;
97
+ isolation?: IsolationMode;
98
+ forkedFromRunId?: number;
82
99
  }
83
100
 
84
101
  // ---------------------------------------------------------------------------
@@ -228,6 +245,28 @@ export function formatTaskSummary(task: string, maxWidth: number = TASK_SUMMARY_
228
245
  return `${takeGraphemes(segments, headMax)}${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(segments, tailMax)}`;
229
246
  }
230
247
 
248
+ /** Max display width of a run's content label. */
249
+ export const RUN_LABEL_MAX = 32;
250
+
251
+ /**
252
+ * Short content label for a run, derived from its task: the single most
253
+ * distinguishing fragment (path, quoted phrase, symbol) so concurrent same-agent
254
+ * runs are told apart by WHAT they do, not just their run id. A long path keeps
255
+ * its tail (the filename is the recognisable part); a task with no recognizable
256
+ * fragment falls back to a head slice of its prose. Grapheme-safe.
257
+ */
258
+ export function runLabel(task: string): string {
259
+ const fragment = extractKeyFragments(task)[0];
260
+ const src = fragment ?? stripVTControlCharacters(task).replace(/\s+/g, " ").trim();
261
+ if (visibleWidth(src) <= RUN_LABEL_MAX) return src;
262
+ const chars = [...graphemeSegmenter.segment(src)].map((s) => s.segment);
263
+ // A path/symbol fragment keeps its tail (filename/symbol is recognisable);
264
+ // a prose fallback keeps its head.
265
+ return fragment
266
+ ? `${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(chars, RUN_LABEL_MAX - 1)}`
267
+ : `${takeGraphemes(chars, RUN_LABEL_MAX - 1)}${TASK_SUMMARY_ELLIPSIS}`;
268
+ }
269
+
231
270
  function formatTokens(count: number): string {
232
271
  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
233
272
  if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
@@ -285,7 +324,7 @@ export function activityStateLabel(state: ActivityState): string {
285
324
  * active_long_running (total elapsed past its threshold). Both are suppressed
286
325
  * for non-running runs. */
287
326
  export function deriveActivityState(run: RunView, now: number = Date.now()): ActivityState | undefined {
288
- if (run.status !== "running") return undefined;
327
+ if (run.status !== "running" && run.status !== "steering") return undefined;
289
328
  if (!run.currentTool) {
290
329
  const since = run.lastActivityAt ?? run.startedAt ?? now;
291
330
  if (now - since >= NEEDS_ATTENTION_AFTER_MS) return "needs_attention";
@@ -390,7 +429,7 @@ export class MonitorStore {
390
429
  // running) are also preserved — their status is "done" but they must
391
430
  // stay visible until the chain resolves.
392
431
  this.runs = this.runs.filter(
393
- (r) => r.status === "queued" || r.status === "running" || r.retained,
432
+ (r) => isRunActiveStatus(r.status) || r.status === "parked" || r.retained,
394
433
  );
395
434
  this.notify();
396
435
  }
@@ -401,12 +440,15 @@ export class MonitorStore {
401
440
  id,
402
441
  agent,
403
442
  task,
443
+ label: runLabel(task),
404
444
  model,
405
445
  thinking,
406
446
  status: "queued",
407
447
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
408
448
  ...(meta?.groupId ? { groupId: meta.groupId } : {}),
409
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 } : {}),
410
452
  });
411
453
  this.notify();
412
454
  return id;
@@ -416,13 +458,13 @@ export class MonitorStore {
416
458
  const run = this.find(id);
417
459
  if (!run) return;
418
460
  run.status = status;
419
- if (status === "running") {
461
+ if (status === "running" || status === "steering" || status === "interrupting") {
420
462
  if (run.startedAt === undefined) run.startedAt = Date.now();
421
- // A model-fallback retry after a failed attempt restarts the clock; a
463
+ // A model-fallback retry or resumed generation restarts the clock; a
422
464
  // stale endedAt would freeze the elapsed display at the first attempt.
423
465
  if (run.endedAt !== undefined) run.endedAt = undefined;
424
466
  run.lastActivityAt = Date.now();
425
- } else if ((status === "done" || status === "failed") && run.endedAt === undefined) {
467
+ } else if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
426
468
  run.endedAt = Date.now();
427
469
  }
428
470
  this.notify();
@@ -436,6 +478,15 @@ export class MonitorStore {
436
478
  this.notify();
437
479
  }
438
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
+
439
490
  /** Update the run's current one-line activity (what it is doing now). */
440
491
  setActivity(id: number, text: string): void {
441
492
  const run = this.find(id);
@@ -493,6 +544,73 @@ export class MonitorStore {
493
544
  this.notify();
494
545
  }
495
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
+
496
614
  /** Look up a run by id without removing it. */
497
615
  findRun(id: number): RunView | undefined {
498
616
  return this.find(id);
@@ -533,6 +651,7 @@ export class MonitorStore {
533
651
  if (run.summary) parts.push(run.summary);
534
652
  if (run.model) parts.push(run.model);
535
653
  if (run.thinking) parts.push(`thinking ${run.thinking}`);
654
+ if (run.isolation === "worktree") parts.push(`worktree ${run.integrationStatus ?? "active"}`);
536
655
  if (usage) parts.push(usage);
537
656
  const elapsed = formatElapsed(run);
538
657
  if (elapsed) parts.push(elapsed);
@@ -564,6 +683,12 @@ export function statusIcon(status: RunStatus, theme: Theme): string {
564
683
  switch (status) {
565
684
  case "running":
566
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", "■");
567
692
  case "done":
568
693
  return theme.fg("success", "✓");
569
694
  case "failed":
@@ -580,6 +705,12 @@ export function statusLabel(status: RunStatus): string {
580
705
  return "ready";
581
706
  case "running":
582
707
  return "running";
708
+ case "steering":
709
+ return "steering";
710
+ case "interrupting":
711
+ return "interrupting";
712
+ case "parked":
713
+ return "parked";
583
714
  case "done":
584
715
  return "done";
585
716
  case "failed":
@@ -588,10 +719,13 @@ export function statusLabel(status: RunStatus): string {
588
719
  }
589
720
 
590
721
  /** Theme color matching the status label. */
591
- export function statusColor(status: RunStatus): "accent" | "success" | "error" | "dim" {
722
+ export function statusColor(status: RunStatus): "accent" | "success" | "error" | "warning" | "dim" {
592
723
  switch (status) {
593
724
  case "running":
725
+ case "steering":
594
726
  return "accent";
727
+ case "interrupting":
728
+ return "warning";
595
729
  case "done":
596
730
  return "success";
597
731
  case "failed":
package/src/prompt.ts CHANGED
@@ -58,14 +58,21 @@ ${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:
66
67
  - Judge whether a delegated task may require viewing images (frontend screenshots, mockups, design files, visual regression comparisons). If it might, pass \`vision: true\` in the subagent call and give the sub-agent the exact image paths — it reads them with its read tool.
67
68
  - \`vision: true\` runs the sub-agent on the vision-capable model configured in /subagents-setup; when none is configured it falls back to the main session's current model. Do not skip the flag because the agent's default model looks fast/cheap — a non-vision model cannot see the images.
68
69
 
70
+ Result handoff (do not re-state):
71
+ - A sub-agent's result arrives as a message that is already shown to the user. Do NOT restate, paraphrase, or re-summarize its findings in your reply — that just burns tokens duplicating what is already visible. The user can read the result above.
72
+ - Reply only with what you ADD: your own conclusion, the next action you are taking, or a one-line acknowledgement. When the result already answers the user, a single sentence is enough — then end your turn or proceed.
73
+ - Read the result and act on it (verify, continue, commit). Keep your own output short.
74
+ - A result arriving does NOT mean all work is finished: sub-agents run in the background and siblings may still be active (a delivery names any still-running runs). Do not report the overall task complete until no runs are active — call subagent_status to confirm before saying Done.
75
+
69
76
  Review & verification:
70
77
  - Never report an unrun check as passed; report it as unavailable or as a pre-existing failure.
71
78
  ${hasReviewer ? "- For non-trivial diffs, run one fresh read-only `reviewer` sub-agent before reporting done. Fix only concrete blockers and re-review at most once.\n- Use multi-model cross-review only when explicitly requested or for genuinely high-risk changes (security, unsafe/FFI, persistence-migration, concurrency). Reviewers are read-only; only the main agent edits.\n" : ""}- Commit or push only when explicitly requested, applicable checks pass, and no accepted blockers remain.`;
@@ -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
+ }