@gotgenes/pi-subagents 6.0.1 → 6.2.0
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 +27 -0
- package/docs/architecture/architecture.md +5 -3
- package/docs/plans/0098-extract-agent-record-state-machine.md +435 -0
- package/docs/plans/0099-replace-ctx-with-parent-snapshot.md +488 -0
- package/docs/retro/0098-extract-agent-record-state-machine.md +46 -0
- package/docs/retro/0102-consolidate-test-record-factory.md +30 -0
- package/package.json +1 -1
- package/src/agent-manager.ts +37 -61
- package/src/agent-record.ts +179 -0
- package/src/agent-runner.ts +16 -22
- package/src/env.ts +4 -5
- package/src/index.ts +4 -3
- package/src/parent-snapshot.ts +27 -0
- package/src/service-adapter.ts +2 -2
- package/src/types.ts +33 -39
- package/src/ui/agent-menu.ts +2 -3
package/src/agent-manager.ts
CHANGED
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
10
|
import type { Model } from "@earendil-works/pi-ai";
|
|
11
|
-
import type { AgentSession,
|
|
11
|
+
import type { AgentSession, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { AgentRecord } from "./agent-record.js";
|
|
12
13
|
import type { AgentRunner, ToolActivity } from "./agent-runner.js";
|
|
13
14
|
import { debugLog } from "./debug.js";
|
|
15
|
+
import { buildParentSnapshot } from "./parent-snapshot.js";
|
|
14
16
|
import type { RunConfig } from "./runtime.js";
|
|
15
|
-
import type { AgentInvocation,
|
|
17
|
+
import type { AgentInvocation, IsolationMode, ParentSnapshot, ShellExec, SubagentType, ThinkingLevel } from "./types.js";
|
|
16
18
|
import { addUsage } from "./usage.js";
|
|
17
19
|
import type { WorktreeManager } from "./worktree.js";
|
|
18
20
|
|
|
@@ -27,6 +29,7 @@ const DEFAULT_MAX_CONCURRENT = 4;
|
|
|
27
29
|
export interface AgentManagerOptions {
|
|
28
30
|
runner: AgentRunner;
|
|
29
31
|
worktrees: WorktreeManager;
|
|
32
|
+
exec: ShellExec;
|
|
30
33
|
maxConcurrent?: number;
|
|
31
34
|
getRunConfig?: () => RunConfig;
|
|
32
35
|
onStart?: OnAgentStart;
|
|
@@ -35,8 +38,7 @@ export interface AgentManagerOptions {
|
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
interface SpawnArgs {
|
|
38
|
-
|
|
39
|
-
ctx: ExtensionContext;
|
|
41
|
+
snapshot: ParentSnapshot;
|
|
40
42
|
type: SubagentType;
|
|
41
43
|
prompt: string;
|
|
42
44
|
options: SpawnOptions;
|
|
@@ -88,6 +90,7 @@ export class AgentManager {
|
|
|
88
90
|
private onCompact?: OnAgentCompact;
|
|
89
91
|
private readonly runner: AgentRunner;
|
|
90
92
|
private readonly worktrees: WorktreeManager;
|
|
93
|
+
private readonly exec: ShellExec;
|
|
91
94
|
private maxConcurrent: number;
|
|
92
95
|
private getRunConfig?: () => RunConfig;
|
|
93
96
|
|
|
@@ -99,6 +102,7 @@ export class AgentManager {
|
|
|
99
102
|
constructor(options: AgentManagerOptions) {
|
|
100
103
|
this.runner = options.runner;
|
|
101
104
|
this.worktrees = options.worktrees;
|
|
105
|
+
this.exec = options.exec;
|
|
102
106
|
this.onComplete = options.onComplete;
|
|
103
107
|
this.onStart = options.onStart;
|
|
104
108
|
this.onCompact = options.onCompact;
|
|
@@ -125,7 +129,6 @@ export class AgentManager {
|
|
|
125
129
|
* If the concurrency limit is reached, the agent is queued.
|
|
126
130
|
*/
|
|
127
131
|
spawn(
|
|
128
|
-
pi: ExtensionAPI,
|
|
129
132
|
ctx: ExtensionContext,
|
|
130
133
|
type: SubagentType,
|
|
131
134
|
prompt: string,
|
|
@@ -133,21 +136,19 @@ export class AgentManager {
|
|
|
133
136
|
): string {
|
|
134
137
|
const id = randomUUID().slice(0, 17);
|
|
135
138
|
const abortController = new AbortController();
|
|
136
|
-
const record
|
|
139
|
+
const record = new AgentRecord({
|
|
137
140
|
id,
|
|
138
141
|
type,
|
|
139
142
|
description: options.description,
|
|
140
143
|
status: options.isBackground ? "queued" : "running",
|
|
141
|
-
toolUses: 0,
|
|
142
144
|
startedAt: Date.now(),
|
|
143
145
|
abortController,
|
|
144
|
-
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
145
|
-
compactionCount: 0,
|
|
146
146
|
invocation: options.invocation,
|
|
147
|
-
};
|
|
147
|
+
});
|
|
148
148
|
this.agents.set(id, record);
|
|
149
149
|
|
|
150
|
-
const
|
|
150
|
+
const snapshot = buildParentSnapshot(ctx, options.inheritContext);
|
|
151
|
+
const args: SpawnArgs = { snapshot, type, prompt, options };
|
|
151
152
|
|
|
152
153
|
if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
|
|
153
154
|
// Queue it — will be started when a running agent completes
|
|
@@ -167,7 +168,7 @@ export class AgentManager {
|
|
|
167
168
|
}
|
|
168
169
|
|
|
169
170
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
170
|
-
private startAgent(id: string, record: AgentRecord, {
|
|
171
|
+
private startAgent(id: string, record: AgentRecord, { snapshot, type, prompt, options }: SpawnArgs) {
|
|
171
172
|
// Worktree isolation: try to create a temporary git worktree. Strict —
|
|
172
173
|
// fail loud if not possible (no silent fallback to main tree). Done
|
|
173
174
|
// BEFORE state mutation so a throw doesn't leave the record half-running.
|
|
@@ -184,8 +185,7 @@ export class AgentManager {
|
|
|
184
185
|
worktreeCwd = wt.path;
|
|
185
186
|
}
|
|
186
187
|
|
|
187
|
-
record.
|
|
188
|
-
record.startedAt = Date.now();
|
|
188
|
+
record.markRunning(Date.now());
|
|
189
189
|
if (options.isBackground) this.runningBackground++;
|
|
190
190
|
this.onStart?.(record);
|
|
191
191
|
|
|
@@ -199,14 +199,13 @@ export class AgentManager {
|
|
|
199
199
|
const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
|
|
200
200
|
|
|
201
201
|
const runConfig = this.getRunConfig?.();
|
|
202
|
-
const promise = this.runner.run(
|
|
203
|
-
|
|
202
|
+
const promise = this.runner.run(snapshot, type, prompt, {
|
|
203
|
+
exec: this.exec,
|
|
204
204
|
model: options.model,
|
|
205
205
|
maxTurns: options.maxTurns,
|
|
206
206
|
defaultMaxTurns: runConfig?.defaultMaxTurns,
|
|
207
207
|
graceTurns: runConfig?.graceTurns,
|
|
208
208
|
isolated: options.isolated,
|
|
209
|
-
inheritContext: options.inheritContext,
|
|
210
209
|
thinkingLevel: options.thinkingLevel,
|
|
211
210
|
cwd: worktreeCwd,
|
|
212
211
|
parentSessionFile: options.parentSessionFile,
|
|
@@ -244,27 +243,26 @@ export class AgentManager {
|
|
|
244
243
|
},
|
|
245
244
|
})
|
|
246
245
|
.then(({ responseText, session, aborted, steered, sessionFile }) => {
|
|
247
|
-
// Don't overwrite status if externally stopped via abort()
|
|
248
|
-
if (record.status !== "stopped") {
|
|
249
|
-
record.status = aborted ? "aborted" : steered ? "steered" : "completed";
|
|
250
|
-
}
|
|
251
|
-
record.result = responseText;
|
|
252
|
-
record.session = session;
|
|
253
|
-
record.completedAt ??= Date.now();
|
|
254
|
-
if (sessionFile) record.outputFile = sessionFile;
|
|
255
|
-
|
|
256
246
|
detach();
|
|
257
247
|
|
|
258
|
-
// Clean up worktree
|
|
248
|
+
// Clean up worktree before transition so the final result includes branch text
|
|
249
|
+
let finalResult = responseText;
|
|
259
250
|
if (record.worktree) {
|
|
260
251
|
const wtResult = this.worktrees.cleanup(record.worktree, options.description);
|
|
261
252
|
record.worktreeResult = wtResult;
|
|
262
253
|
if (wtResult.hasChanges && wtResult.branch) {
|
|
263
|
-
|
|
264
|
-
`\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
|
|
254
|
+
finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
|
|
265
255
|
}
|
|
266
256
|
}
|
|
267
257
|
|
|
258
|
+
// Transition — guards against overwriting externally-stopped status
|
|
259
|
+
if (aborted) record.markAborted(finalResult);
|
|
260
|
+
else if (steered) record.markSteered(finalResult);
|
|
261
|
+
else record.markCompleted(finalResult);
|
|
262
|
+
|
|
263
|
+
record.session = session;
|
|
264
|
+
if (sessionFile) record.outputFile = sessionFile;
|
|
265
|
+
|
|
268
266
|
if (options.isBackground) {
|
|
269
267
|
this.runningBackground--;
|
|
270
268
|
try { this.onComplete?.(record); } catch (err) { debugLog("onComplete callback", err); }
|
|
@@ -273,17 +271,10 @@ export class AgentManager {
|
|
|
273
271
|
return responseText;
|
|
274
272
|
})
|
|
275
273
|
.catch((err) => {
|
|
276
|
-
|
|
277
|
-
if (record.status !== "stopped") {
|
|
278
|
-
record.status = "error";
|
|
279
|
-
}
|
|
280
|
-
record.error = err instanceof Error ? err.message : String(err);
|
|
281
|
-
record.completedAt ??= Date.now();
|
|
274
|
+
record.markError(err);
|
|
282
275
|
|
|
283
276
|
detach();
|
|
284
277
|
|
|
285
|
-
|
|
286
|
-
|
|
287
278
|
// Best-effort worktree cleanup on error
|
|
288
279
|
if (record.worktree) {
|
|
289
280
|
try {
|
|
@@ -314,9 +305,7 @@ export class AgentManager {
|
|
|
314
305
|
} catch (err) {
|
|
315
306
|
// Late failure (e.g. strict worktree-isolation) — surface on the record
|
|
316
307
|
// so the user/agent can see it via /agents, then keep draining.
|
|
317
|
-
record.
|
|
318
|
-
record.error = err instanceof Error ? err.message : String(err);
|
|
319
|
-
record.completedAt = Date.now();
|
|
308
|
+
record.markError(err);
|
|
320
309
|
this.onComplete?.(record);
|
|
321
310
|
}
|
|
322
311
|
}
|
|
@@ -327,13 +316,12 @@ export class AgentManager {
|
|
|
327
316
|
* Foreground agents bypass the concurrency queue.
|
|
328
317
|
*/
|
|
329
318
|
async spawnAndWait(
|
|
330
|
-
pi: ExtensionAPI,
|
|
331
319
|
ctx: ExtensionContext,
|
|
332
320
|
type: SubagentType,
|
|
333
321
|
prompt: string,
|
|
334
322
|
options: Omit<SpawnOptions, "isBackground">,
|
|
335
323
|
): Promise<AgentRecord> {
|
|
336
|
-
const id = this.spawn(
|
|
324
|
+
const id = this.spawn(ctx, type, prompt, { ...options, isBackground: false });
|
|
337
325
|
const record = this.agents.get(id)!;
|
|
338
326
|
await record.promise;
|
|
339
327
|
return record;
|
|
@@ -350,11 +338,7 @@ export class AgentManager {
|
|
|
350
338
|
const record = this.agents.get(id);
|
|
351
339
|
if (!record?.session) return undefined;
|
|
352
340
|
|
|
353
|
-
record.
|
|
354
|
-
record.startedAt = Date.now();
|
|
355
|
-
record.completedAt = undefined;
|
|
356
|
-
record.result = undefined;
|
|
357
|
-
record.error = undefined;
|
|
341
|
+
record.resetForResume(Date.now());
|
|
358
342
|
|
|
359
343
|
try {
|
|
360
344
|
const responseText = await this.runner.resume(record.session, prompt, {
|
|
@@ -370,13 +354,9 @@ export class AgentManager {
|
|
|
370
354
|
},
|
|
371
355
|
signal,
|
|
372
356
|
});
|
|
373
|
-
record.
|
|
374
|
-
record.result = responseText;
|
|
375
|
-
record.completedAt = Date.now();
|
|
357
|
+
record.markCompleted(responseText);
|
|
376
358
|
} catch (err) {
|
|
377
|
-
record.
|
|
378
|
-
record.error = err instanceof Error ? err.message : String(err);
|
|
379
|
-
record.completedAt = Date.now();
|
|
359
|
+
record.markError(err);
|
|
380
360
|
}
|
|
381
361
|
|
|
382
362
|
return record;
|
|
@@ -399,15 +379,13 @@ export class AgentManager {
|
|
|
399
379
|
// Remove from queue if queued
|
|
400
380
|
if (record.status === "queued") {
|
|
401
381
|
this.queue = this.queue.filter(q => q.id !== id);
|
|
402
|
-
record.
|
|
403
|
-
record.completedAt = Date.now();
|
|
382
|
+
record.markStopped();
|
|
404
383
|
return true;
|
|
405
384
|
}
|
|
406
385
|
|
|
407
386
|
if (record.status !== "running") return false;
|
|
408
387
|
record.abortController?.abort();
|
|
409
|
-
record.
|
|
410
|
-
record.completedAt = Date.now();
|
|
388
|
+
record.markStopped();
|
|
411
389
|
return true;
|
|
412
390
|
}
|
|
413
391
|
|
|
@@ -452,8 +430,7 @@ export class AgentManager {
|
|
|
452
430
|
for (const queued of this.queue) {
|
|
453
431
|
const record = this.agents.get(queued.id);
|
|
454
432
|
if (record) {
|
|
455
|
-
record.
|
|
456
|
-
record.completedAt = Date.now();
|
|
433
|
+
record.markStopped();
|
|
457
434
|
count++;
|
|
458
435
|
}
|
|
459
436
|
}
|
|
@@ -462,8 +439,7 @@ export class AgentManager {
|
|
|
462
439
|
for (const record of this.agents.values()) {
|
|
463
440
|
if (record.status === "running") {
|
|
464
441
|
record.abortController?.abort();
|
|
465
|
-
record.
|
|
466
|
-
record.completedAt = Date.now();
|
|
442
|
+
record.markStopped();
|
|
467
443
|
count++;
|
|
468
444
|
}
|
|
469
445
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-record.ts — AgentRecord class with encapsulated status-transition logic.
|
|
3
|
+
*
|
|
4
|
+
* Status transitions (status, result, error, startedAt, completedAt) are owned
|
|
5
|
+
* by the class and exposed via transition methods. External code reads these
|
|
6
|
+
* fields through public properties but cannot write them directly.
|
|
7
|
+
*
|
|
8
|
+
* Non-transition state (session, toolUses, lifetimeUsage, etc.) remains public.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { AgentInvocation, SubagentType } from "./types.js";
|
|
13
|
+
import type { LifetimeUsage } from "./usage.js";
|
|
14
|
+
|
|
15
|
+
export type AgentRecordStatus =
|
|
16
|
+
| "queued"
|
|
17
|
+
| "running"
|
|
18
|
+
| "completed"
|
|
19
|
+
| "steered"
|
|
20
|
+
| "aborted"
|
|
21
|
+
| "stopped"
|
|
22
|
+
| "error";
|
|
23
|
+
|
|
24
|
+
export interface AgentRecordInit {
|
|
25
|
+
id: string;
|
|
26
|
+
type: SubagentType;
|
|
27
|
+
description: string;
|
|
28
|
+
status?: AgentRecordStatus;
|
|
29
|
+
startedAt?: number;
|
|
30
|
+
completedAt?: number;
|
|
31
|
+
result?: string;
|
|
32
|
+
error?: string;
|
|
33
|
+
toolUses?: number;
|
|
34
|
+
lifetimeUsage?: LifetimeUsage;
|
|
35
|
+
compactionCount?: number;
|
|
36
|
+
abortController?: AbortController;
|
|
37
|
+
invocation?: AgentInvocation;
|
|
38
|
+
session?: AgentSession;
|
|
39
|
+
promise?: Promise<string>;
|
|
40
|
+
resultConsumed?: boolean;
|
|
41
|
+
pendingSteers?: string[];
|
|
42
|
+
worktree?: { path: string; branch: string };
|
|
43
|
+
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
44
|
+
toolCallId?: string;
|
|
45
|
+
outputFile?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class AgentRecord {
|
|
49
|
+
// Identity — set once at construction
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly type: SubagentType;
|
|
52
|
+
readonly description: string;
|
|
53
|
+
readonly invocation?: AgentInvocation;
|
|
54
|
+
|
|
55
|
+
// Transition state — encapsulated behind getters, mutated only via transition methods
|
|
56
|
+
private _status: AgentRecordStatus;
|
|
57
|
+
get status(): AgentRecordStatus { return this._status; }
|
|
58
|
+
|
|
59
|
+
private _result?: string;
|
|
60
|
+
get result(): string | undefined { return this._result; }
|
|
61
|
+
|
|
62
|
+
private _error?: string;
|
|
63
|
+
get error(): string | undefined { return this._error; }
|
|
64
|
+
|
|
65
|
+
private _startedAt: number;
|
|
66
|
+
get startedAt(): number { return this._startedAt; }
|
|
67
|
+
|
|
68
|
+
private _completedAt?: number;
|
|
69
|
+
get completedAt(): number | undefined { return this._completedAt; }
|
|
70
|
+
|
|
71
|
+
// Non-transition mutable state
|
|
72
|
+
toolUses: number;
|
|
73
|
+
lifetimeUsage: LifetimeUsage;
|
|
74
|
+
compactionCount: number;
|
|
75
|
+
session?: AgentSession;
|
|
76
|
+
abortController?: AbortController;
|
|
77
|
+
promise?: Promise<string>;
|
|
78
|
+
resultConsumed?: boolean;
|
|
79
|
+
pendingSteers?: string[];
|
|
80
|
+
worktree?: { path: string; branch: string };
|
|
81
|
+
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
82
|
+
toolCallId?: string;
|
|
83
|
+
outputFile?: string;
|
|
84
|
+
|
|
85
|
+
constructor(init: AgentRecordInit) {
|
|
86
|
+
this.id = init.id;
|
|
87
|
+
this.type = init.type;
|
|
88
|
+
this.description = init.description;
|
|
89
|
+
this.invocation = init.invocation;
|
|
90
|
+
|
|
91
|
+
this._status = init.status ?? "queued";
|
|
92
|
+
this._result = init.result;
|
|
93
|
+
this._error = init.error;
|
|
94
|
+
this._startedAt = init.startedAt ?? Date.now();
|
|
95
|
+
this._completedAt = init.completedAt;
|
|
96
|
+
|
|
97
|
+
this.toolUses = init.toolUses ?? 0;
|
|
98
|
+
this.lifetimeUsage = init.lifetimeUsage ?? { input: 0, output: 0, cacheWrite: 0 };
|
|
99
|
+
this.compactionCount = init.compactionCount ?? 0;
|
|
100
|
+
this.abortController = init.abortController;
|
|
101
|
+
this.session = init.session;
|
|
102
|
+
this.promise = init.promise;
|
|
103
|
+
this.resultConsumed = init.resultConsumed;
|
|
104
|
+
this.pendingSteers = init.pendingSteers;
|
|
105
|
+
this.worktree = init.worktree;
|
|
106
|
+
this.worktreeResult = init.worktreeResult;
|
|
107
|
+
this.toolCallId = init.toolCallId;
|
|
108
|
+
this.outputFile = init.outputFile;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Transition to running state. Sets status and startedAt. */
|
|
112
|
+
markRunning(startedAt: number): void {
|
|
113
|
+
this._status = "running";
|
|
114
|
+
this._startedAt = startedAt;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Transition to completed state.
|
|
119
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
120
|
+
*/
|
|
121
|
+
markCompleted(result: string, completedAt?: number): void {
|
|
122
|
+
this._result = result;
|
|
123
|
+
this._completedAt ??= completedAt ?? Date.now();
|
|
124
|
+
if (this._status !== "stopped") {
|
|
125
|
+
this._status = "completed";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Transition to aborted state.
|
|
131
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
132
|
+
*/
|
|
133
|
+
markAborted(result: string, completedAt?: number): void {
|
|
134
|
+
this._result = result;
|
|
135
|
+
this._completedAt ??= completedAt ?? Date.now();
|
|
136
|
+
if (this._status !== "stopped") {
|
|
137
|
+
this._status = "aborted";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Transition to steered state.
|
|
143
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
144
|
+
*/
|
|
145
|
+
markSteered(result: string, completedAt?: number): void {
|
|
146
|
+
this._result = result;
|
|
147
|
+
this._completedAt ??= completedAt ?? Date.now();
|
|
148
|
+
if (this._status !== "stopped") {
|
|
149
|
+
this._status = "steered";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Transition to error state.
|
|
155
|
+
* Always sets error (formatted) and completedAt (??=). Only changes status if not stopped.
|
|
156
|
+
*/
|
|
157
|
+
markError(error: unknown, completedAt?: number): void {
|
|
158
|
+
this._error = error instanceof Error ? error.message : String(error);
|
|
159
|
+
this._completedAt ??= completedAt ?? Date.now();
|
|
160
|
+
if (this._status !== "stopped") {
|
|
161
|
+
this._status = "error";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Transition to stopped state. Always valid — no guard. */
|
|
166
|
+
markStopped(completedAt?: number): void {
|
|
167
|
+
this._status = "stopped";
|
|
168
|
+
this._completedAt = completedAt ?? Date.now();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Reset for resume: running status, new startedAt, clear completedAt/result/error. */
|
|
172
|
+
resetForResume(startedAt: number): void {
|
|
173
|
+
this._status = "running";
|
|
174
|
+
this._startedAt = startedAt;
|
|
175
|
+
this._completedAt = undefined;
|
|
176
|
+
this._result = undefined;
|
|
177
|
+
this._error = undefined;
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/agent-runner.ts
CHANGED
|
@@ -3,22 +3,20 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Model } from "@earendil-works/pi-ai";
|
|
6
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
6
|
import {
|
|
8
7
|
type AgentSession,
|
|
9
8
|
type AgentSessionEvent,
|
|
10
9
|
createAgentSession,
|
|
11
10
|
DefaultResourceLoader,
|
|
12
|
-
type ExtensionAPI,
|
|
13
11
|
getAgentDir,
|
|
14
12
|
SessionManager,
|
|
15
13
|
SettingsManager,
|
|
16
14
|
} from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import {
|
|
15
|
+
import { extractText } from "./context.js";
|
|
18
16
|
import { detectEnv } from "./env.js";
|
|
19
17
|
import { assembleSessionConfig } from "./session-config.js";
|
|
20
18
|
import { deriveSubagentSessionDir } from "./session-dir.js";
|
|
21
|
-
import type { SubagentType, ThinkingLevel } from "./types.js";
|
|
19
|
+
import type { ParentSnapshot, ShellExec, SubagentType, ThinkingLevel } from "./types.js";
|
|
22
20
|
|
|
23
21
|
/** Names of tools registered by this extension that subagents must NOT inherit. */
|
|
24
22
|
const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
|
|
@@ -73,13 +71,12 @@ export interface ToolActivity {
|
|
|
73
71
|
}
|
|
74
72
|
|
|
75
73
|
export interface RunOptions {
|
|
76
|
-
/**
|
|
77
|
-
|
|
74
|
+
/** Shell-exec callback for detectEnv — injected from pi.exec(). */
|
|
75
|
+
exec: ShellExec;
|
|
78
76
|
model?: Model<any>;
|
|
79
77
|
maxTurns?: number;
|
|
80
78
|
signal?: AbortSignal;
|
|
81
79
|
isolated?: boolean;
|
|
82
|
-
inheritContext?: boolean;
|
|
83
80
|
thinkingLevel?: ThinkingLevel;
|
|
84
81
|
/** Override working directory (e.g. for worktree isolation). */
|
|
85
82
|
cwd?: string;
|
|
@@ -149,7 +146,7 @@ export interface ResumeOptions {
|
|
|
149
146
|
* SDK session orchestration in runAgent/resumeAgent.
|
|
150
147
|
*/
|
|
151
148
|
export interface AgentRunner {
|
|
152
|
-
run(
|
|
149
|
+
run(snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult>;
|
|
153
150
|
resume(session: AgentSession, prompt: string, options?: ResumeOptions): Promise<string>;
|
|
154
151
|
}
|
|
155
152
|
|
|
@@ -199,23 +196,23 @@ function forwardAbortSignal(
|
|
|
199
196
|
}
|
|
200
197
|
|
|
201
198
|
export async function runAgent(
|
|
202
|
-
|
|
199
|
+
snapshot: ParentSnapshot,
|
|
203
200
|
type: SubagentType,
|
|
204
201
|
prompt: string,
|
|
205
202
|
options: RunOptions,
|
|
206
203
|
): Promise<RunResult> {
|
|
207
204
|
// Resolve working directory upfront — needed for detectEnv before assembly.
|
|
208
|
-
const effectiveCwd = options.cwd ??
|
|
209
|
-
const env = await detectEnv(options.
|
|
205
|
+
const effectiveCwd = options.cwd ?? snapshot.cwd;
|
|
206
|
+
const env = await detectEnv(options.exec, effectiveCwd);
|
|
210
207
|
|
|
211
208
|
// Assemble session configuration (synchronous, no SDK objects).
|
|
212
209
|
const cfg = assembleSessionConfig(
|
|
213
210
|
type,
|
|
214
211
|
{
|
|
215
|
-
cwd:
|
|
216
|
-
parentSystemPrompt:
|
|
217
|
-
parentModel:
|
|
218
|
-
modelRegistry:
|
|
212
|
+
cwd: snapshot.cwd,
|
|
213
|
+
parentSystemPrompt: snapshot.systemPrompt,
|
|
214
|
+
parentModel: snapshot.model,
|
|
215
|
+
modelRegistry: snapshot.modelRegistry,
|
|
219
216
|
},
|
|
220
217
|
{
|
|
221
218
|
cwd: options.cwd,
|
|
@@ -259,7 +256,7 @@ export async function runAgent(
|
|
|
259
256
|
agentDir,
|
|
260
257
|
sessionManager,
|
|
261
258
|
settingsManager: SettingsManager.create(cfg.effectiveCwd, agentDir),
|
|
262
|
-
modelRegistry:
|
|
259
|
+
modelRegistry: snapshot.modelRegistry as any,
|
|
263
260
|
model: cfg.model as Model<any> | undefined,
|
|
264
261
|
tools: cfg.toolNames,
|
|
265
262
|
resourceLoader: loader,
|
|
@@ -378,13 +375,10 @@ export async function runAgent(
|
|
|
378
375
|
const collector = collectResponseText(session);
|
|
379
376
|
const cleanupAbort = forwardAbortSignal(session, options.signal);
|
|
380
377
|
|
|
381
|
-
//
|
|
378
|
+
// Prepend parent context if it was captured at spawn time
|
|
382
379
|
let effectivePrompt = prompt;
|
|
383
|
-
if (
|
|
384
|
-
|
|
385
|
-
if (parentContext) {
|
|
386
|
-
effectivePrompt = parentContext + prompt;
|
|
387
|
-
}
|
|
380
|
+
if (snapshot.parentContext) {
|
|
381
|
+
effectivePrompt = snapshot.parentContext + prompt;
|
|
388
382
|
}
|
|
389
383
|
|
|
390
384
|
try {
|
package/src/env.ts
CHANGED
|
@@ -2,16 +2,15 @@
|
|
|
2
2
|
* env.ts — Detect environment info (git, platform) for subagent system prompts.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
5
|
import { debugLog } from "./debug.js";
|
|
7
|
-
import type { EnvInfo } from "./types.js";
|
|
6
|
+
import type { EnvInfo, ShellExec } from "./types.js";
|
|
8
7
|
|
|
9
|
-
export async function detectEnv(
|
|
8
|
+
export async function detectEnv(exec: ShellExec, cwd: string): Promise<EnvInfo> {
|
|
10
9
|
let isGitRepo = false;
|
|
11
10
|
let branch = "";
|
|
12
11
|
|
|
13
12
|
try {
|
|
14
|
-
const result = await
|
|
13
|
+
const result = await exec("git", ["rev-parse", "--is-inside-work-tree"], { cwd, timeout: 5000 });
|
|
15
14
|
isGitRepo = result.code === 0 && result.stdout.trim() === "true";
|
|
16
15
|
} catch (err) {
|
|
17
16
|
debugLog("git rev-parse", err);
|
|
@@ -19,7 +18,7 @@ export async function detectEnv(pi: ExtensionAPI, cwd: string): Promise<EnvInfo>
|
|
|
19
18
|
|
|
20
19
|
if (isGitRepo) {
|
|
21
20
|
try {
|
|
22
|
-
const result = await
|
|
21
|
+
const result = await exec("git", ["branch", "--show-current"], { cwd, timeout: 5000 });
|
|
23
22
|
branch = result.code === 0 ? result.stdout.trim() : "unknown";
|
|
24
23
|
} catch (err) {
|
|
25
24
|
debugLog("git branch", err);
|
package/src/index.ts
CHANGED
|
@@ -66,6 +66,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
66
66
|
const manager = new AgentManager({
|
|
67
67
|
runner: { run: runAgent, resume: resumeAgent },
|
|
68
68
|
worktrees: new GitWorktreeManager(process.cwd()),
|
|
69
|
+
exec: (cmd, args, opts) => pi.exec(cmd, args, opts),
|
|
69
70
|
onComplete: (record) => {
|
|
70
71
|
// Emit lifecycle event based on terminal status
|
|
71
72
|
const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
|
|
@@ -185,8 +186,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
185
186
|
|
|
186
187
|
pi.registerTool(defineTool(createAgentTool({
|
|
187
188
|
manager: {
|
|
188
|
-
spawn: (ctx, type, prompt, opts) => manager.spawn(
|
|
189
|
-
spawnAndWait: (ctx, type, prompt, opts) => manager.spawnAndWait(
|
|
189
|
+
spawn: (ctx, type, prompt, opts) => manager.spawn(ctx, type, prompt, opts),
|
|
190
|
+
spawnAndWait: (ctx, type, prompt, opts) => manager.spawnAndWait(ctx, type, prompt, opts),
|
|
190
191
|
resume: (id, prompt, signal) => manager.resume(id, prompt, signal),
|
|
191
192
|
getRecord: (id) => manager.getRecord(id),
|
|
192
193
|
getMaxConcurrent: () => manager.getMaxConcurrent(),
|
|
@@ -229,7 +230,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
229
230
|
manager: {
|
|
230
231
|
listAgents: () => manager.listAgents(),
|
|
231
232
|
getRecord: (id) => manager.getRecord(id),
|
|
232
|
-
spawnAndWait: (
|
|
233
|
+
spawnAndWait: (ctx, type, prompt, opts) => manager.spawnAndWait(ctx, type, prompt, opts),
|
|
233
234
|
getMaxConcurrent: () => manager.getMaxConcurrent(),
|
|
234
235
|
setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
|
|
235
236
|
},
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parent-snapshot.ts — Capture parent session state as a plain data snapshot.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { buildParentContext } from "./context.js";
|
|
7
|
+
import type { ParentSnapshot } from "./types.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build an immutable snapshot of the parent session state.
|
|
11
|
+
*
|
|
12
|
+
* Called once at spawn time so queued agents capture state as it existed
|
|
13
|
+
* when the user requested the agent, not when a queue slot opens.
|
|
14
|
+
*/
|
|
15
|
+
export function buildParentSnapshot(
|
|
16
|
+
ctx: ExtensionContext,
|
|
17
|
+
inheritContext?: boolean,
|
|
18
|
+
): ParentSnapshot {
|
|
19
|
+
const parentContext = inheritContext ? buildParentContext(ctx) : undefined;
|
|
20
|
+
return {
|
|
21
|
+
cwd: ctx.cwd,
|
|
22
|
+
systemPrompt: ctx.getSystemPrompt(),
|
|
23
|
+
model: ctx.model,
|
|
24
|
+
modelRegistry: ctx.modelRegistry,
|
|
25
|
+
parentContext: parentContext || undefined,
|
|
26
|
+
};
|
|
27
|
+
}
|
package/src/service-adapter.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { AgentRecord } from "./types.js";
|
|
|
11
11
|
|
|
12
12
|
/** Narrow interface for the AgentManager — avoids coupling to the concrete class. */
|
|
13
13
|
export interface AgentManagerLike {
|
|
14
|
-
spawn(
|
|
14
|
+
spawn(ctx: unknown, type: string, prompt: string, options: unknown): string;
|
|
15
15
|
getRecord(id: string): AgentRecord | undefined;
|
|
16
16
|
listAgents(): AgentRecord[];
|
|
17
17
|
abort(id: string): boolean;
|
|
@@ -54,7 +54,7 @@ export function createSubagentsService(deps: AdapterDeps): SubagentsService {
|
|
|
54
54
|
const description = options?.description ?? prompt.slice(0, 80);
|
|
55
55
|
const isBackground = !(options?.foreground ?? false);
|
|
56
56
|
|
|
57
|
-
return manager.spawn(session.
|
|
57
|
+
return manager.spawn(session.ctx, type, prompt, {
|
|
58
58
|
description,
|
|
59
59
|
model,
|
|
60
60
|
maxTurns: options?.maxTurns,
|