@ferris1225/pi-subagents 4.3.9 → 4.3.10
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/CHANGELOG.md +24 -0
- package/README.md +100 -117
- package/package.json +12 -10
- package/src/configuration/setup.ts +16 -14
- package/src/delegation/agents.ts +1 -1
- package/src/delegation/dispatch.ts +15 -20
- package/src/delegation/phase-scope.ts +7 -37
- package/src/delegation/prompt.ts +16 -27
- package/src/execution/rpc-control.ts +2 -29
- package/src/execution/rpc-run.ts +29 -30
- package/src/execution/spawn.ts +21 -20
- package/src/isolation/temp-hygiene.ts +7 -9
- package/src/isolation/worktree.ts +13 -88
- package/src/lifecycle/durable.ts +6 -8
- package/src/lifecycle/runtime.ts +23 -70
- package/src/lifecycle/thread-lifecycle.ts +56 -459
- package/src/lifecycle/thread-restore.ts +13 -35
- package/src/lifecycle/thread-shared.ts +5 -65
- package/src/lifecycle/tools.ts +86 -251
- package/src/presentation/announcements.ts +1 -1
- package/src/presentation/format.ts +6 -16
- package/src/presentation/monitor.ts +0 -91
- package/src/presentation/widget.ts +7 -17
- package/src/execution/session-fork.ts +0 -86
|
@@ -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
|
-
/**
|
|
157
|
-
|
|
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
|
|
165
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
/**
|
|
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
|
|
121
|
-
|
|
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(
|
|
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
|
-
}
|