@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/README.md +122 -71
- package/package.json +1 -1
- package/src/agents.ts +8 -13
- package/src/background.ts +59 -6
- package/src/config.ts +14 -2
- package/src/dispatch.ts +1429 -396
- package/src/format.ts +29 -4
- package/src/index.ts +7 -4
- package/src/inspector-panel.ts +363 -0
- package/src/inspector.ts +369 -0
- package/src/models.ts +192 -50
- package/src/monitor.ts +114 -7
- package/src/prompt.ts +3 -2
- package/src/recovery.ts +145 -0
- package/src/rpc-run.ts +1016 -0
- package/src/runtime.ts +167 -27
- package/src/session-fork.ts +84 -0
- package/src/setup.ts +271 -184
- package/src/spawn.ts +562 -977
- package/src/tools.ts +404 -65
- package/src/trajectory.ts +503 -0
- package/src/ui.ts +32 -16
- package/src/widget.ts +17 -4
- package/src/worktree.ts +687 -0
package/src/runtime.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* to every registration site, so state stays in one place without globals.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { rmSync } from "node:fs";
|
|
13
13
|
import { BackgroundTaskQueue } from "./background.ts";
|
|
14
14
|
import {
|
|
@@ -19,9 +19,78 @@ import {
|
|
|
19
19
|
type CompletionBatcher,
|
|
20
20
|
type CompletionMessageItem,
|
|
21
21
|
} from "./completion.ts";
|
|
22
|
-
import { loadConfigSync } from "./config.ts";
|
|
23
|
-
import { monitor } from "./monitor.ts";
|
|
22
|
+
import { loadConfigSync, type ThinkingLevel } from "./config.ts";
|
|
23
|
+
import { isRunActiveStatus, monitor } from "./monitor.ts";
|
|
24
|
+
import {
|
|
25
|
+
persistRecoveryRecords,
|
|
26
|
+
recoveryRecordFromFinalization,
|
|
27
|
+
type RecoveryRecord,
|
|
28
|
+
} from "./recovery.ts";
|
|
29
|
+
import type { RpcRunControl } from "./rpc-run.ts";
|
|
30
|
+
import { inspectorStore } from "./trajectory.ts";
|
|
24
31
|
import type { SingleResult } from "./spawn.ts";
|
|
32
|
+
import type { IsolationMode, WorktreeFinalization, WorktreeIsolation } from "./worktree.ts";
|
|
33
|
+
|
|
34
|
+
export type ThreadState =
|
|
35
|
+
| "queued"
|
|
36
|
+
| "resuming"
|
|
37
|
+
| "running"
|
|
38
|
+
| "steering"
|
|
39
|
+
| "interrupting"
|
|
40
|
+
| "parked"
|
|
41
|
+
| "completed"
|
|
42
|
+
| "failed"
|
|
43
|
+
| "stopped";
|
|
44
|
+
|
|
45
|
+
export type ThreadLifecycleOperation = "park" | "resume" | "fork" | "stop" | "settle";
|
|
46
|
+
|
|
47
|
+
export interface SubagentThread {
|
|
48
|
+
id: number;
|
|
49
|
+
generation: number;
|
|
50
|
+
agentName: string;
|
|
51
|
+
task: string;
|
|
52
|
+
/** Caller-facing cwd in the original worktree. */
|
|
53
|
+
cwd: string;
|
|
54
|
+
/** Actual child cwd (the equivalent path inside an isolated worktree). */
|
|
55
|
+
executionCwd: string;
|
|
56
|
+
vision: boolean;
|
|
57
|
+
/** Exact primary→fallback refs inherited by a session fork. */
|
|
58
|
+
modelPool: string[];
|
|
59
|
+
thinkingLevel?: ThinkingLevel;
|
|
60
|
+
isolation: IsolationMode;
|
|
61
|
+
worktree?: WorktreeIsolation;
|
|
62
|
+
state: ThreadState;
|
|
63
|
+
control: RpcRunControl;
|
|
64
|
+
queueController?: AbortController;
|
|
65
|
+
/** Resolves only after the current generation's queue work has fully
|
|
66
|
+
* quiesced and released its concurrency slot. Auto-fix orchestration is part
|
|
67
|
+
* of the parent generation and replaces/extends this promise. */
|
|
68
|
+
generationCompletion: Promise<void>;
|
|
69
|
+
/** Synchronous CAS used by lifecycle controls across their async preflight. */
|
|
70
|
+
lifecycleVersion: number;
|
|
71
|
+
lifecycleOperation?: ThreadLifecycleOperation;
|
|
72
|
+
sessionId?: string;
|
|
73
|
+
sessionDir?: string;
|
|
74
|
+
/** Most recent generation result, retained for parked destructive-stop output. */
|
|
75
|
+
lastResult?: SingleResult;
|
|
76
|
+
/** A destructive stop retires context even if the active child settles later. */
|
|
77
|
+
retireOnSettle?: boolean;
|
|
78
|
+
retired?: boolean;
|
|
79
|
+
/** Abort the active generation to a stable checkpoint and wait until its
|
|
80
|
+
* queue work has published that checkpoint and released its slot. */
|
|
81
|
+
park: () => Promise<"queued" | "active">;
|
|
82
|
+
/** Installed by dispatch so the control tool can restart the same logical id. */
|
|
83
|
+
resume: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
|
|
84
|
+
/** Create a new logical thread from this thread's retained Pi session branch. */
|
|
85
|
+
fork: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
|
|
86
|
+
forkedFromRunId?: number;
|
|
87
|
+
forkChildRunIds: number[];
|
|
88
|
+
/** Dispatch-owned, generation-guarded worktree settlement hook. */
|
|
89
|
+
finalizeIsolation: (generation: number, result?: SingleResult) => Promise<WorktreeFinalization | undefined>;
|
|
90
|
+
/** Best-effort shutdown notification for retained integration artifacts. */
|
|
91
|
+
notifyIsolationFailure?: (finalization: WorktreeFinalization) => void;
|
|
92
|
+
isolationFailureNotified?: boolean;
|
|
93
|
+
}
|
|
25
94
|
|
|
26
95
|
export interface SubagentRuntime {
|
|
27
96
|
configPath: string;
|
|
@@ -39,21 +108,21 @@ export interface SubagentRuntime {
|
|
|
39
108
|
settledRuns: Map<number, SingleResult>;
|
|
40
109
|
settledListeners: Map<number, Set<(result: SingleResult) => void>>;
|
|
41
110
|
registerRunResult: (runId: number, result: SingleResult) => void;
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*
|
|
46
|
-
|
|
111
|
+
/** Logical threads outlive process attempts and completed generations. */
|
|
112
|
+
threads: Map<number, SubagentThread>;
|
|
113
|
+
/** Resume/fork setup that has claimed a thread but has not yet enqueued its
|
|
114
|
+
* next generation. Shutdown invalidates these claims and waits for cleanup. */
|
|
115
|
+
preflightOperations: Set<Promise<void>>;
|
|
116
|
+
/** Every session directory retained for this parent session, including
|
|
117
|
+
* auto-fix internals that are not directly controllable. */
|
|
118
|
+
sessionDirs: Set<string>;
|
|
119
|
+
retainSession: (result: Pick<SingleResult, "sessionDir">) => void;
|
|
120
|
+
retireThreadSession: (thread: SubagentThread) => void;
|
|
121
|
+
/** Worktree/patch paths intentionally retained after a failed integration. */
|
|
122
|
+
retainedArtifactPaths: Set<string>;
|
|
123
|
+
retainWorktreeArtifacts: (finalization: WorktreeFinalization) => void;
|
|
47
124
|
/** Flip sessionActive off and release all session-scoped resources. */
|
|
48
|
-
shutdown: () => void
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface PreservedSession {
|
|
52
|
-
sessionId: string;
|
|
53
|
-
sessionDir: string;
|
|
54
|
-
agentName: string;
|
|
55
|
-
task: string;
|
|
56
|
-
vision: boolean;
|
|
125
|
+
shutdown: () => Promise<void>;
|
|
57
126
|
}
|
|
58
127
|
|
|
59
128
|
export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
|
|
@@ -74,7 +143,7 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
74
143
|
// are removed from the monitor before their completion is pushed.
|
|
75
144
|
const active = monitor
|
|
76
145
|
.getRuns()
|
|
77
|
-
.filter((run) => run.status
|
|
146
|
+
.filter((run) => isRunActiveStatus(run.status) || run.retained)
|
|
78
147
|
.map((run) => ({ id: run.id, agent: run.agent, label: run.label }));
|
|
79
148
|
const message = {
|
|
80
149
|
customType: "subagent-result",
|
|
@@ -99,7 +168,30 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
99
168
|
runControllers: new Map<number, AbortController>(),
|
|
100
169
|
settledRuns: new Map<number, SingleResult>(),
|
|
101
170
|
settledListeners: new Map<number, Set<(result: SingleResult) => void>>(),
|
|
102
|
-
|
|
171
|
+
threads: new Map<number, SubagentThread>(),
|
|
172
|
+
preflightOperations: new Set<Promise<void>>(),
|
|
173
|
+
sessionDirs: new Set<string>(),
|
|
174
|
+
retainedArtifactPaths: new Set<string>(),
|
|
175
|
+
retainWorktreeArtifacts: (finalization) => {
|
|
176
|
+
if (finalization.worktreePath) runtime.retainedArtifactPaths.add(finalization.worktreePath);
|
|
177
|
+
if (finalization.patchPath) runtime.retainedArtifactPaths.add(finalization.patchPath);
|
|
178
|
+
},
|
|
179
|
+
retainSession: (result) => {
|
|
180
|
+
if (result.sessionDir) runtime.sessionDirs.add(result.sessionDir);
|
|
181
|
+
},
|
|
182
|
+
retireThreadSession: (thread) => {
|
|
183
|
+
thread.retired = true;
|
|
184
|
+
if (!thread.sessionDir) return;
|
|
185
|
+
const sessionDir = thread.sessionDir;
|
|
186
|
+
try {
|
|
187
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
188
|
+
runtime.sessionDirs.delete(sessionDir);
|
|
189
|
+
thread.sessionDir = undefined;
|
|
190
|
+
thread.sessionId = undefined;
|
|
191
|
+
} catch {
|
|
192
|
+
/* best-effort; shutdown retries the still-retained directory */
|
|
193
|
+
}
|
|
194
|
+
},
|
|
103
195
|
registerRunResult: (runId, result) => {
|
|
104
196
|
runtime.settledRuns.set(runId, result);
|
|
105
197
|
const listeners = runtime.settledListeners.get(runId);
|
|
@@ -114,27 +206,75 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
114
206
|
}
|
|
115
207
|
}
|
|
116
208
|
},
|
|
117
|
-
shutdown: () => {
|
|
209
|
+
shutdown: async () => {
|
|
210
|
+
if (!runtime.sessionActive) return;
|
|
118
211
|
runtime.sessionActive = false;
|
|
212
|
+
const shutdownThreads = [...runtime.threads.values()];
|
|
213
|
+
// Invalidate every lifecycle claim synchronously before the first await.
|
|
214
|
+
// Resume/fork preflight checks both this version and sessionActive, then
|
|
215
|
+
// cleans any worktree/session it created before resolving its tracker.
|
|
216
|
+
for (const thread of shutdownThreads) {
|
|
217
|
+
thread.lifecycleVersion++;
|
|
218
|
+
thread.lifecycleOperation = "stop";
|
|
219
|
+
thread.retired = true;
|
|
220
|
+
thread.retireOnSettle = true;
|
|
221
|
+
thread.state = "stopped";
|
|
222
|
+
}
|
|
223
|
+
const preflights = [...runtime.preflightOperations];
|
|
119
224
|
runtime.completionBatcher.dispose();
|
|
120
225
|
runtime.backgroundQueue.cancelAll();
|
|
226
|
+
// Await live RPC process-tree cleanup and continuation preflight rollback
|
|
227
|
+
// before removing sessions/worktrees or clearing ownership maps.
|
|
228
|
+
await Promise.all([
|
|
229
|
+
Promise.all(
|
|
230
|
+
shutdownThreads.map((thread) =>
|
|
231
|
+
thread.control.stop("Parent session shut down").catch(() => undefined),
|
|
232
|
+
),
|
|
233
|
+
),
|
|
234
|
+
Promise.allSettled(preflights),
|
|
235
|
+
runtime.backgroundQueue.waitForIdle(),
|
|
236
|
+
]);
|
|
237
|
+
// Parked work owns no queue task, so shutdown is its final settlement.
|
|
238
|
+
// Active/stopped tasks may already have finalized; the handle and callback
|
|
239
|
+
// are idempotent and generation-guarded.
|
|
240
|
+
const recoveryRecords: RecoveryRecord[] = [];
|
|
241
|
+
for (const thread of runtime.threads.values()) {
|
|
242
|
+
const finalization = await thread.finalizeIsolation(thread.generation).catch(() => undefined);
|
|
243
|
+
if (finalization?.status === "retained") {
|
|
244
|
+
runtime.retainWorktreeArtifacts(finalization);
|
|
245
|
+
recoveryRecords.push(recoveryRecordFromFinalization(thread.id, finalization));
|
|
246
|
+
if (!thread.isolationFailureNotified) {
|
|
247
|
+
thread.isolationFailureNotified = true;
|
|
248
|
+
try {
|
|
249
|
+
thread.notifyIsolationFailure?.(finalization);
|
|
250
|
+
} catch {
|
|
251
|
+
/* the parent UI may already be shutting down */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Persist before tearing down the old runtime. A /new, /resume, or quit
|
|
257
|
+
// must not make the only recovery paths unreachable.
|
|
258
|
+
await persistRecoveryRecords(runtime.configPath, recoveryRecords).catch(() => undefined);
|
|
121
259
|
runtime.settledRuns.clear();
|
|
122
260
|
runtime.settledListeners.clear();
|
|
123
261
|
runtime.runControllers.clear();
|
|
124
|
-
|
|
125
|
-
// ended/crashed session does not leak temp files; the OS reclaims
|
|
126
|
-
// tmpdir eventually, but this keeps things tidy between sessions.
|
|
127
|
-
for (const { sessionDir } of runtime.preservedSessions.values()) {
|
|
262
|
+
for (const sessionDir of runtime.sessionDirs) {
|
|
128
263
|
try {
|
|
129
264
|
rmSync(sessionDir, { recursive: true, force: true });
|
|
130
265
|
} catch {
|
|
131
266
|
/* best-effort */
|
|
132
267
|
}
|
|
133
268
|
}
|
|
134
|
-
runtime.
|
|
135
|
-
|
|
136
|
-
//
|
|
269
|
+
runtime.sessionDirs.clear();
|
|
270
|
+
runtime.preflightOperations.clear();
|
|
271
|
+
// Deliberately do not remove retainedArtifactPaths: they are the recovery
|
|
272
|
+
// path after a failed patch apply/cleanup.
|
|
273
|
+
runtime.threads.clear();
|
|
137
274
|
monitor.clear();
|
|
275
|
+
// The append-only inspector history (trajectory + transcript) is
|
|
276
|
+
// parent-session scoped: it must never leak into the next session.
|
|
277
|
+
inspectorStore.clearAll();
|
|
138
278
|
},
|
|
139
279
|
};
|
|
140
280
|
|
|
@@ -0,0 +1,84 @@
|
|
|
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 { mkdtemp, rm } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
export interface ForkedSession {
|
|
10
|
+
sourceSessionFile: string;
|
|
11
|
+
sessionDir: string;
|
|
12
|
+
sessionId: string;
|
|
13
|
+
sessionFile: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Locate one retained session by its authoritative header id. */
|
|
17
|
+
export async function findRetainedSessionFile(
|
|
18
|
+
cwd: string,
|
|
19
|
+
sessionDir: string,
|
|
20
|
+
sessionId: string,
|
|
21
|
+
): Promise<string> {
|
|
22
|
+
// The retained header may point at a worktree that has since been removed.
|
|
23
|
+
// The session id is authoritative inside this explicit private directory;
|
|
24
|
+
// listing the directory directly avoids a stale-cwd filter rejecting it.
|
|
25
|
+
const sessions = await SessionManager.listAll(sessionDir);
|
|
26
|
+
const matches = sessions.filter((session) => session.id === sessionId);
|
|
27
|
+
if (matches.length === 0) {
|
|
28
|
+
throw new Error(`Retained session ${sessionId} was not found in ${sessionDir}.`);
|
|
29
|
+
}
|
|
30
|
+
if (matches.length > 1) {
|
|
31
|
+
throw new Error(`Retained session id ${sessionId} is ambiguous in ${sessionDir}.`);
|
|
32
|
+
}
|
|
33
|
+
return matches[0].path;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Copy only the source file's active branch into a new isolated temp session
|
|
38
|
+
* directory. SessionManager performs all JSONL/tree handling; source state is
|
|
39
|
+
* never mutated.
|
|
40
|
+
*/
|
|
41
|
+
export async function forkRetainedSession(options: {
|
|
42
|
+
/** Cwd stored in the source session header (used for exact lookup). */
|
|
43
|
+
cwd: string;
|
|
44
|
+
/** Optional cwd for the cloned session header and future child tools. */
|
|
45
|
+
targetCwd?: string;
|
|
46
|
+
sessionDir: string;
|
|
47
|
+
sessionId: string;
|
|
48
|
+
}): Promise<ForkedSession> {
|
|
49
|
+
const sourceSessionFile = await findRetainedSessionFile(
|
|
50
|
+
options.cwd,
|
|
51
|
+
options.sessionDir,
|
|
52
|
+
options.sessionId,
|
|
53
|
+
);
|
|
54
|
+
const sessionDir = await mkdtemp(join(tmpdir(), "pi-subagent-session-fork-"));
|
|
55
|
+
try {
|
|
56
|
+
// Supplying the new directory makes createBranchedSession write there.
|
|
57
|
+
// cwdOverride rewrites the cloned header so a settled isolated session can
|
|
58
|
+
// safely continue in its fresh worktree instead of a removed old path.
|
|
59
|
+
const manager = SessionManager.open(
|
|
60
|
+
sourceSessionFile,
|
|
61
|
+
sessionDir,
|
|
62
|
+
options.targetCwd ?? options.cwd,
|
|
63
|
+
);
|
|
64
|
+
const leafId = manager.getLeafId();
|
|
65
|
+
if (!leafId) throw new Error(`Retained session ${options.sessionId} has no active branch to fork.`);
|
|
66
|
+
const sessionFile = manager.createBranchedSession(leafId);
|
|
67
|
+
if (!sessionFile) throw new Error("Pi SessionManager did not create a persistent fork.");
|
|
68
|
+
// Pi defers branch files that contain no assistant response. Such a file
|
|
69
|
+
// cannot be resumed by RPC without creating a blank session, so reject
|
|
70
|
+
// rather than pretending context was preserved.
|
|
71
|
+
if (!existsSync(sessionFile)) {
|
|
72
|
+
throw new Error(`Forked session branch has no persisted assistant checkpoint at ${sessionFile}.`);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
sourceSessionFile,
|
|
76
|
+
sessionDir,
|
|
77
|
+
sessionId: manager.getSessionId(),
|
|
78
|
+
sessionFile,
|
|
79
|
+
};
|
|
80
|
+
} catch (error) {
|
|
81
|
+
await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|