@ferris1225/pi-subagents 4.3.9 → 4.3.11

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.
@@ -46,7 +46,7 @@ export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime
46
46
  runtime.restoredNotified = true;
47
47
  const ids = runtime.restoredRunIds.map((id) => `#${id}`).join(", ");
48
48
  ctx.ui.notify(
49
- `pi-subagents: restored ${runtime.restoredRunIds.length} interrupted thread${runtime.restoredRunIds.length === 1 ? "" : "s"} (${ids}) with retained context. subagent_control resume continues one.`,
49
+ `pi-subagents: restored ${runtime.restoredRunIds.length} interrupted run${runtime.restoredRunIds.length === 1 ? "" : "s"} (${ids}) for manual recovery. Inspect with subagent_status; main finishes the work.`,
50
50
  "info",
51
51
  );
52
52
  }
@@ -9,6 +9,7 @@ import { runLabel, shrinkRunLabel } from "./monitor.ts";
9
9
  import { emptyUsage } from "../execution/rpc-control.ts";
10
10
  import {
11
11
  RESULT_LINE_MAX,
12
+ getResultError,
12
13
  getResultOutput,
13
14
  isFailedResult,
14
15
  truncateResultOutput,
@@ -143,6 +144,7 @@ export function formatCompletionBlock(
143
144
  const artifact = options.resultRoot
144
145
  ? writeResultArtifact(output, result.agent, options.resultRoot)
145
146
  : "(result root unavailable)";
147
+ if (options.resultRoot) result.resultFile = artifact;
146
148
  // State the real loss and condition the read: handing the parent both a
147
149
  // summary and a full-text entrance invites the same content twice.
148
150
  const lineLoss = shownLines < totalLines ? `${shownLines} of ${totalLines} lines shown` : `${shownLines} line${shownLines === 1 ? "" : "s"} shown`;
@@ -153,23 +155,11 @@ export function formatCompletionBlock(
153
155
  return lines.join("\n");
154
156
  }
155
157
 
156
- /** Instruction appended to a model-level failure: the sub-agent's provider never
157
- * produced usable output (or the run stalled), so the task is handed back to the
158
- * main window instead of being left as a dead failure. When the run preserved a
159
- * session with earlier work (and the run id is known), steer the main agent to
160
- * RESUME it in-context once a model is available, instead of re-dispatching
161
- * fresh (which would re-scan everything). */
162
- export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
158
+ /** A terminal model failure hands the unfinished task to main, not another run. */
159
+ export function modelLevelTakeoverNote(result: SingleResult): string {
163
160
  const retry = result.modelFallbackFrom ? ", and the current main model also failed" : "";
164
- const detail = result.errorMessage?.trim();
165
- const cause = detail
166
- ? `its model/provider call failed (${detail})`
167
- : "its model was unavailable or failed (or the run stalled)";
168
- const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
169
- const recovery = sessionPreserved
170
- ? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent_control with { action: "resume", id: ${opts!.runId} } to CONTINUE it in-context (it keeps the same run id and does not re-scan), or execute the task in the main window with your own tools.`
171
- : ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
172
- return `The sub-agent could not complete this task: ${cause}${retry}.${recovery}`;
161
+ const cause = getResultError(result) ?? "its model was unavailable or failed";
162
+ return `The sub-agent could not complete this task: ${cause}${retry}. Earlier edits and any retained result/session artifacts remain available. Main must inspect that work and finish with its own tools; do not re-dispatch this phase.`;
173
163
  }
174
164
 
175
165
  /** Resolve a run-id request to actual ids: an exact numeric match always wins
@@ -20,7 +20,6 @@ import type { IsolationMode, WorktreeFinalizationStatus } from "../isolation/wor
20
20
  // ---------------------------------------------------------------------------
21
21
 
22
22
  export type RunStatus = "queued" | "running" | "interrupting" | "parked" | "done" | "failed";
23
- export type ContinuationKind = "resume-retained" | "resume-appended";
24
23
 
25
24
  /** Why a queued run has produced no output yet. Three genuinely different
26
25
  * situations used to be reported as one "queued": waiting for a free process
@@ -56,8 +55,6 @@ export interface RunView {
56
55
  thinking?: string;
57
56
  isolation?: IsolationMode;
58
57
  integrationStatus?: RunIntegrationStatus;
59
- /** Short worktree-group identity (mkdtemp suffix) shared by every run inside
60
- * one isolated worktree; changes when a continuation worktree is created. */
61
58
  worktreeId?: string;
62
59
  status: RunStatus;
63
60
  /** What a queued run is actually waiting for; see RunWaitReason. */
@@ -73,15 +70,11 @@ export interface RunView {
73
70
  elapsedMs: number;
74
71
  /** Epoch ms when the latest active segment stopped. */
75
72
  endedAt?: number;
76
- /** Why this generation reused retained context, shown in the widget/status. */
77
- continuationKind?: ContinuationKind;
78
73
  }
79
74
 
80
- /** Extra metadata for a run whose row must carry isolation or resume context. */
81
75
  export interface RunChainMeta {
82
76
  isolation?: IsolationMode;
83
77
  worktreeId?: string;
84
- continuationKind?: ContinuationKind;
85
78
  /** Initial wait reason; defaults to "process-slot" (a fresh dispatch enters
86
79
  * the process queue). Children spawned outside the queue pass "starting"
87
80
  * because they never wait for a slot. */
@@ -326,7 +319,6 @@ export function formatDuration(ms: number): string {
326
319
  return `${hours}h${String(minutes % 60).padStart(2, "0")}m${String(seconds).padStart(2, "0")}s`;
327
320
  }
328
321
 
329
- /** Cumulative active time across generations; parked gaps never count. */
330
322
  export function elapsedMilliseconds(run: RunView, now: number = Date.now()): number {
331
323
  let elapsed = run.elapsedMs ?? 0;
332
324
  if (run.activeSince !== undefined) elapsed += Math.max(0, now - run.activeSince);
@@ -343,14 +335,6 @@ export function formatElapsed(run: RunView, now: number = Date.now()): string {
343
335
  return formatDuration(elapsedMilliseconds(run, now));
344
336
  }
345
337
 
346
- export function continuationLabel(kind: ContinuationKind | undefined): string | undefined {
347
- switch (kind) {
348
- case "resume-retained": return "resume: current objective";
349
- case "resume-appended": return "resume: appended objective";
350
- default: return undefined;
351
- }
352
- }
353
-
354
338
  /** Max length of the argument target inside a formatted activity line. */
355
339
  export const ACTIVITY_TARGET_MAX = 60;
356
340
 
@@ -505,7 +489,6 @@ export class MonitorStore {
505
489
  usage: emptyUsage(),
506
490
  elapsedMs: 0,
507
491
  ...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined, ...(meta.worktreeId ? { worktreeId: meta.worktreeId } : {}) } : {}),
508
- ...(meta?.continuationKind ? { continuationKind: meta.continuationKind } : {}),
509
492
  });
510
493
  this.notify();
511
494
  return id;
@@ -608,83 +591,11 @@ export class MonitorStore {
608
591
  }
609
592
 
610
593
 
611
- /** Update the objective shown for a resumed generation. */
612
- setTask(id: number, task: string): void {
613
- const run = this.find(id);
614
- if (!run) return;
615
- run.task = task;
616
- run.label = runLabel(task);
617
- this.notify();
618
- }
619
-
620
- setContinuationKind(id: number, kind: ContinuationKind): void {
621
- const run = this.find(id);
622
- if (!run) return;
623
- run.continuationKind = kind;
624
- this.notify();
625
- }
626
-
627
594
  getElapsedMs(id: number, now: number = Date.now()): number | undefined {
628
595
  const run = this.find(id);
629
596
  return run ? elapsedMilliseconds(run, now) : undefined;
630
597
  }
631
598
 
632
- /** Reuse a stable logical run id for a resumed generation without discarding
633
- * active time accumulated by earlier generations. */
634
- restartRun(
635
- id: number,
636
- agent: string,
637
- task: string,
638
- model?: string,
639
- thinking?: string,
640
- isolation?: IsolationMode,
641
- meta?: { elapsedMs?: number; continuationKind?: ContinuationKind; worktreeId?: string },
642
- ): void {
643
- const run = this.find(id);
644
- if (!run) {
645
- this.runs.push({
646
- id,
647
- agent,
648
- task,
649
- label: runLabel(task),
650
- model,
651
- thinking,
652
- ...(isolation
653
- ? {
654
- isolation,
655
- integrationStatus: isolation === "worktree" ? "pending" as const : undefined,
656
- ...(isolation === "worktree" && meta?.worktreeId ? { worktreeId: meta.worktreeId } : {}),
657
- }
658
- : {}),
659
- status: "queued",
660
- waitReason: "process-slot",
661
- usage: emptyUsage(),
662
- elapsedMs: meta?.elapsedMs ?? 0,
663
- continuationKind: meta?.continuationKind,
664
- });
665
- this.notify();
666
- return;
667
- }
668
- run.agent = agent;
669
- run.task = task;
670
- run.label = runLabel(task);
671
- run.model = model;
672
- run.thinking = thinking;
673
- if (isolation) run.isolation = isolation;
674
- run.integrationStatus = isolation === "worktree" ? "pending" : undefined;
675
- if (isolation === "worktree" && meta?.worktreeId) run.worktreeId = meta.worktreeId;
676
- else if (isolation !== "worktree") run.worktreeId = undefined;
677
- run.status = "queued";
678
- run.waitReason = "process-slot";
679
- run.usage = emptyUsage();
680
- run.activity = undefined;
681
- run.activeSince = undefined;
682
- run.endedAt = undefined;
683
- run.elapsedMs = Math.max(run.elapsedMs, meta?.elapsedMs ?? 0);
684
- run.continuationKind = meta?.continuationKind;
685
- this.notify();
686
- }
687
-
688
599
  /** Look up a run by id without removing it. */
689
600
  findRun(id: number): RunView | undefined {
690
601
  return this.find(id);
@@ -721,8 +632,6 @@ export class MonitorStore {
721
632
  summarize(run: RunView): string {
722
633
  const usage = formatUsageCompact(run.usage);
723
634
  const parts = [run.agent];
724
- const continuation = continuationLabel(run.continuationKind);
725
- if (continuation) parts.push(continuation);
726
635
  if (run.model) parts.push(run.model);
727
636
  if (run.thinking) parts.push(`thinking ${run.thinking}`);
728
637
  if (run.isolation === "worktree") parts.push(`worktree ${run.integrationStatus ?? "active"}`);
@@ -3,8 +3,7 @@
3
3
  *
4
4
  * Layout contract:
5
5
  * - Aligned identity columns: `icon #id agent` pad to the widest displayed
6
- * id and agent so every label starts at the same column; a resumed thread
7
- * carries a dim `↻` inside the agent column.
6
+ * id and agent so every label starts at the same column.
8
7
  * - A live run owns two lines. Line 1 is what it is: identity, task label,
9
8
  * then the telemetry flow (worktree, wait state, token flow in the pi-footer
10
9
  * vocabulary `↑in ↓out R/W cache`, cost, `provider/model`, effective
@@ -51,7 +50,7 @@ const ACTIVITY_MIN_WIDTH = 6;
51
50
  interface ColumnLayout {
52
51
  /** Display width of the widest `#id` among rendered roots. */
53
52
  idWidth: number;
54
- /** Display width of the widest agent name (plus resume marker) among them. */
53
+ /** Display width of the widest agent name among rendered runs. */
55
54
  agentWidth: number;
56
55
  }
57
56
 
@@ -80,8 +79,7 @@ export function waitWord(run: Pick<RunView, "waitReason">): string {
80
79
 
81
80
  /** Worktree-group badge shown on the row that owns the isolated worktree: the
82
81
  * short group identity plus its integration state, so a run visibly moves
83
- * through applying → applied (or retained) and a continuation worktree (new
84
- * identity) is distinguishable from the original one. */
82
+ * through applying → applied (or retained). */
85
83
  function worktreeBadge(run: RunView): string {
86
84
  const id = run.worktreeId ?? "?";
87
85
  switch (run.integrationStatus) {
@@ -105,21 +103,13 @@ function composeTail(parts: Array<string | undefined>, budget: number): string {
105
103
  return present.join(SEPARATOR);
106
104
  }
107
105
 
108
- /** Marker text that shares the agent column so alignment survives resumes. */
109
- function agentColumnText(run: RunView): string {
110
- return run.continuationKind ? `${run.agent} ↻` : run.agent;
111
- }
112
-
113
- /** `icon #id agent` in fixed columns — never truncated. The id is
114
- * right-aligned and the agent column is padded so every label starts at the
115
- * same x; a resumed thread carries a dim `↻` inside the agent column. */
106
+ /** Fixed identity columns; labels begin at the same display column. */
116
107
  function identitySegment(run: RunView, theme: Theme, layout: ColumnLayout): string {
117
108
  const icon = statusIcon(run.status, theme);
118
109
  const id = `#${run.id}`.padStart(layout.idWidth);
119
110
  const name = theme.fg("accent", theme.bold(run.agent));
120
- const resumed = run.continuationKind ? ` ${theme.fg("dim", "↻")}` : "";
121
- const pad = " ".repeat(Math.max(0, layout.agentWidth - visibleWidth(agentColumnText(run))));
122
- return `${icon} ${theme.fg("dim", id)} ${name}${resumed}${pad}`;
111
+ const pad = " ".repeat(Math.max(0, layout.agentWidth - visibleWidth(run.agent)));
112
+ return `${icon} ${theme.fg("dim", id)} ${name}${pad}`;
123
113
  }
124
114
 
125
115
  /** One footer-style usage part: token flow plus accrued cost, dropped as a
@@ -198,7 +188,7 @@ export function formatActiveRunLines(
198
188
  const active = runs.filter((run) => isRunActiveStatus(run.status));
199
189
  const layout: ColumnLayout = {
200
190
  idWidth: Math.max(...active.map((run) => visibleWidth(`#${run.id}`)), 0),
201
- agentWidth: Math.max(...active.map((run) => visibleWidth(agentColumnText(run))), 0),
191
+ agentWidth: Math.max(...active.map((run) => visibleWidth(run.agent)), 0),
202
192
  };
203
193
  const lines: string[] = [];
204
194
  let shown = 0;
@@ -1,86 +0,0 @@
1
- /** Pi SessionManager-backed cloning of a retained sub-agent session branch. */
2
-
3
- import { SessionManager } from "@earendil-works/pi-coding-agent";
4
- import { existsSync } from "node:fs";
5
- import { mkdir, mkdtemp, rm } from "node:fs/promises";
6
- import { join } from "node:path";
7
- import { writeTempOwnerMarker } from "../isolation/temp-hygiene.ts";
8
-
9
- export interface ForkedSession {
10
- sessionDir: string;
11
- sessionId: string;
12
- sessionFile: string;
13
- }
14
-
15
- /** Locate one retained session by its authoritative header id. */
16
- export async function findRetainedSessionFile(
17
- sessionDir: string,
18
- sessionId: string,
19
- ): Promise<string> {
20
- // The retained header may point at a worktree that has since been removed.
21
- // The session id is authoritative inside this explicit private directory;
22
- // listing the directory directly avoids a stale-cwd filter rejecting it.
23
- const sessions = await SessionManager.listAll(sessionDir);
24
- const matches = sessions.filter((session) => session.id === sessionId);
25
- if (matches.length === 0) {
26
- throw new Error(`Retained session ${sessionId} was not found in ${sessionDir}.`);
27
- }
28
- if (matches.length > 1) {
29
- throw new Error(`Retained session id ${sessionId} is ambiguous in ${sessionDir}.`);
30
- }
31
- return matches[0].path;
32
- }
33
-
34
- /**
35
- * Copy only the source file's active branch into a new isolated temp session
36
- * directory. SessionManager performs all JSONL/tree handling; source state is
37
- * never mutated.
38
- */
39
- export async function forkRetainedSession(options: {
40
- /** Cwd stored in the source session header (used for exact lookup). */
41
- cwd: string;
42
- /** Optional cwd for the cloned session header and future child tools. */
43
- targetCwd?: string;
44
- sessionDir: string;
45
- sessionId: string;
46
- /** Parent directory for the cloned branch: the project-scoped durable
47
- * sessions root, so forks never land in the OS temp directory. */
48
- targetRoot: string;
49
- }): Promise<ForkedSession> {
50
- const sourceSessionFile = await findRetainedSessionFile(
51
- options.sessionDir,
52
- options.sessionId,
53
- );
54
- const root = options.targetRoot;
55
- await mkdir(root, { recursive: true });
56
- const sessionDir = await mkdtemp(join(root, "pi-subagent-session-fork-"));
57
- writeTempOwnerMarker(sessionDir);
58
- try {
59
- // Supplying the new directory makes createBranchedSession write there.
60
- // cwdOverride rewrites the cloned header so a settled isolated session can
61
- // safely continue in its fresh worktree instead of a removed old path.
62
- const manager = SessionManager.open(
63
- sourceSessionFile,
64
- sessionDir,
65
- options.targetCwd ?? options.cwd,
66
- );
67
- const leafId = manager.getLeafId();
68
- if (!leafId) throw new Error(`Retained session ${options.sessionId} has no active branch to fork.`);
69
- const sessionFile = manager.createBranchedSession(leafId);
70
- if (!sessionFile) throw new Error("Pi SessionManager did not create a persistent fork.");
71
- // Pi defers branch files that contain no assistant response. Such a file
72
- // cannot be resumed by RPC without creating a blank session, so reject
73
- // rather than pretending context was preserved.
74
- if (!existsSync(sessionFile)) {
75
- throw new Error(`Forked session branch has no persisted assistant checkpoint at ${sessionFile}.`);
76
- }
77
- return {
78
- sessionDir,
79
- sessionId: manager.getSessionId(),
80
- sessionFile,
81
- };
82
- } catch (error) {
83
- await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
84
- throw error;
85
- }
86
- }