@gotgenes/pi-subagents 6.1.0 → 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 +14 -0
- package/docs/architecture/architecture.md +5 -3
- 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/package.json +1 -1
- package/src/agent-manager.ts +13 -12
- 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 +30 -0
- package/src/ui/agent-menu.ts +2 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [6.2.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.1.0...pi-subagents-v6.2.0) (2026-05-21)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add ParentSnapshot type and builder ([#99](https://github.com/gotgenes/pi-packages/issues/99)) ([ee24eb9](https://github.com/gotgenes/pi-packages/commit/ee24eb907eba9f6f917bc166c912e5482eff5bd5))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* **pi-subagents:** cross-reference issues in architecture decomposition plan ([2242e45](https://github.com/gotgenes/pi-packages/commit/2242e457b7e1bf8cb44e9a1df6fb4d2fd1ba1116))
|
|
19
|
+
* plan replace live ctx capture with ParentSnapshot ([#99](https://github.com/gotgenes/pi-packages/issues/99)) ([b6b63f8](https://github.com/gotgenes/pi-packages/commit/b6b63f8677231617a00cb1e3d1227667cbae7ecd))
|
|
20
|
+
* **retro:** add retro notes for issue [#98](https://github.com/gotgenes/pi-packages/issues/98) ([ef52aaa](https://github.com/gotgenes/pi-packages/commit/ef52aaa4d8b690b309f2129ff34f90c44368cc57))
|
|
21
|
+
|
|
8
22
|
## [6.1.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.0.1...pi-subagents-v6.1.0) (2026-05-20)
|
|
9
23
|
|
|
10
24
|
|
|
@@ -489,7 +489,7 @@ The `if (record.status !== "stopped")` guards in `.then()` and `.catch()` become
|
|
|
489
489
|
The three designs are independent and can land in any order.
|
|
490
490
|
The recommended sequence minimizes intermediate churn.
|
|
491
491
|
|
|
492
|
-
#### Step 1: Record state machine
|
|
492
|
+
#### Step 1: Record state machine ✓ (done — #98, #102)
|
|
493
493
|
|
|
494
494
|
Extract status-transition methods onto `AgentRecord` (or a `RecordManager` wrapper).
|
|
495
495
|
Purely mechanical — replace scattered field writes with method calls.
|
|
@@ -497,7 +497,9 @@ No interface changes for callers.
|
|
|
497
497
|
|
|
498
498
|
This is the lowest-risk change and immediately reduces `startAgent()` line count.
|
|
499
499
|
|
|
500
|
-
|
|
500
|
+
Issue #102 consolidated test `AgentRecord` construction into a shared factory as follow-up.
|
|
501
|
+
|
|
502
|
+
#### Step 2: Parent snapshot (#99)
|
|
501
503
|
|
|
502
504
|
Replace `ctx: ExtensionContext` in `SpawnArgs` with a `ParentSnapshot` data object.
|
|
503
505
|
Capture the snapshot in `spawn()` or at the tool call site.
|
|
@@ -506,7 +508,7 @@ Remove `pi: ExtensionAPI` from `SpawnArgs` (it is only used to pass to `runner.r
|
|
|
506
508
|
|
|
507
509
|
This change narrows the `AgentRunner` interface and eliminates live-reference capture.
|
|
508
510
|
|
|
509
|
-
#### Step 3: Session-event observation
|
|
511
|
+
#### Step 3: Session-event observation (#100)
|
|
510
512
|
|
|
511
513
|
Replace the callback-threading pattern with direct session subscriptions.
|
|
512
514
|
AgentManager subscribes to the session after creation to update the record.
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 99
|
|
3
|
+
issue_title: "Replace live `ctx` capture with ParentSnapshot in AgentManager"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Replace live ctx capture with ParentSnapshot
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`AgentManager.spawn()` captures a live `ctx: ExtensionContext` reference into `SpawnArgs`, which is held in the concurrency queue until a slot opens.
|
|
11
|
+
When the queued agent eventually dequeues, `runAgent()` reads from this live reference — `ctx.cwd`, `ctx.getSystemPrompt()`, `ctx.model`, `ctx.modelRegistry` — all of which may have changed since the agent was queued (model switch, cwd change, session restart).
|
|
12
|
+
|
|
13
|
+
Additionally, `inheritContext` calls `ctx.sessionManager.getBranch()` at run time, forking the conversation as it exists at dequeue rather than at the point the user asked for the agent.
|
|
14
|
+
|
|
15
|
+
`pi: ExtensionAPI` is also stored in `SpawnArgs` but is only relayed to `runner.run()`, which only uses it for `detectEnv()` — a classic parameter-relay smell.
|
|
16
|
+
|
|
17
|
+
## Goals
|
|
18
|
+
|
|
19
|
+
- Replace `ctx: ExtensionContext` in `SpawnArgs` with a `ParentSnapshot` data object captured once at spawn time.
|
|
20
|
+
- Remove `pi: ExtensionAPI` from `SpawnArgs` by injecting a narrow `ShellExec` callback into `AgentManager` at construction.
|
|
21
|
+
- Update `AgentRunner.run()` to accept `ParentSnapshot` instead of `ctx`.
|
|
22
|
+
- Narrow `detectEnv()` to accept `ShellExec` instead of the full `ExtensionAPI`.
|
|
23
|
+
- Simplify test mocks — plain data snapshots replace SDK mock objects.
|
|
24
|
+
|
|
25
|
+
## Non-Goals
|
|
26
|
+
|
|
27
|
+
- Session-event observation / callback-threading removal (architecture.md Step 3, #100) — separate issue, depends on this one.
|
|
28
|
+
- Cleaning up `runtime.currentCtx` — it holds a live `{ pi, ctx }` for the service-adapter but is always "current" (set on `session_start`, cleared on `session_shutdown`); staleness is not a risk there.
|
|
29
|
+
Simplifying it is a natural follow-up but out of scope.
|
|
30
|
+
- Changes to `SpawnOptions` or the public `SubagentsService` API — both are unchanged.
|
|
31
|
+
|
|
32
|
+
## Background
|
|
33
|
+
|
|
34
|
+
### Relevant modules
|
|
35
|
+
|
|
36
|
+
| Module | Role in this change |
|
|
37
|
+
| ------------------------ | --------------------------------------------------------------------------------------- |
|
|
38
|
+
| `src/agent-manager.ts` | Owns `SpawnArgs`, `spawn()`, `startAgent()` — primary target |
|
|
39
|
+
| `src/agent-runner.ts` | `runAgent()` consumes `ctx` + `pi`; `AgentRunner` interface defines `run()` signature |
|
|
40
|
+
| `src/env.ts` | `detectEnv()` accepts `ExtensionAPI` — narrowing to `ShellExec` |
|
|
41
|
+
| `src/context.ts` | `buildParentContext()` reads `ctx.sessionManager.getBranch()` — called at snapshot time |
|
|
42
|
+
| `src/session-config.ts` | `AssemblerContext` is already a narrow interface; `runAgent` builds it from ctx today |
|
|
43
|
+
| `src/types.ts` | Will host `ParentSnapshot` and `ShellExec` type definitions |
|
|
44
|
+
| `src/service-adapter.ts` | `AgentManagerLike.spawn` — narrow interface that mirrors `AgentManager.spawn()` |
|
|
45
|
+
| `src/index.ts` | Wires `pi.exec` into `AgentManager`; wraps `spawn`/`spawnAndWait` for tools |
|
|
46
|
+
| `src/ui/agent-menu.ts` | `AgentMenuManagerDeps.spawnAndWait` — narrow interface referencing `pi` parameter |
|
|
47
|
+
|
|
48
|
+
### Code-style constraints
|
|
49
|
+
|
|
50
|
+
- **Parameter relay** (code-design skill): `pi` threads through `spawn` → `SpawnArgs` → `startAgent` → `RunOptions` → `runAgent` → `detectEnv`.
|
|
51
|
+
The intermediaries (`spawn`, `startAgent`) never read `pi`.
|
|
52
|
+
Fix: inject exec at the `AgentManager` level.
|
|
53
|
+
- **Dependency width** (code-design skill): `runAgent` receives `ctx: ExtensionContext` but only reads 4 fields plus `sessionManager.getBranch()`.
|
|
54
|
+
Fix: `ParentSnapshot` — a narrow interface with exactly the fields consumed.
|
|
55
|
+
- **Law of Demeter** (design-review): `ctx.sessionManager.getBranch()` is a reach-through that forces tests to mock a nested `sessionManager` object.
|
|
56
|
+
Fix: snapshot pre-computes `parentContext` as a string.
|
|
57
|
+
|
|
58
|
+
### Prerequisite status
|
|
59
|
+
|
|
60
|
+
- Issue #98 (AgentRecord state machine) — **done**.
|
|
61
|
+
`AgentRecord` is a class with encapsulated transition methods.
|
|
62
|
+
- Issue #102 (shared test record factory) — **done**.
|
|
63
|
+
All test record construction goes through `createTestRecord()`.
|
|
64
|
+
|
|
65
|
+
## Design Overview
|
|
66
|
+
|
|
67
|
+
### ParentSnapshot interface
|
|
68
|
+
|
|
69
|
+
A plain data object capturing everything `runAgent()` reads from `ctx`:
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
export interface ParentSnapshot {
|
|
73
|
+
/** Parent working directory. */
|
|
74
|
+
cwd: string;
|
|
75
|
+
/** Parent's effective system prompt (for append-mode agents). */
|
|
76
|
+
systemPrompt: string;
|
|
77
|
+
/** Parent's current model instance (fallback when agent config has no model). */
|
|
78
|
+
model: unknown;
|
|
79
|
+
/** Model registry for resolving config.model strings and creating sessions. */
|
|
80
|
+
modelRegistry: {
|
|
81
|
+
find(provider: string, modelId: string): unknown;
|
|
82
|
+
getAvailable?(): Array<{ provider: string; id: string }>;
|
|
83
|
+
};
|
|
84
|
+
/** Pre-built parent conversation text (when inheritContext was requested). */
|
|
85
|
+
parentContext?: string;
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The `modelRegistry` field is a reference capture, not a data copy — it's a registry object with methods.
|
|
90
|
+
This is acceptable because the registry is structurally stable within a session (models don't change at runtime).
|
|
91
|
+
The key staleness risks (`cwd`, `systemPrompt`, `model`, conversation state) are all captured as values.
|
|
92
|
+
|
|
93
|
+
### ShellExec type
|
|
94
|
+
|
|
95
|
+
A narrow callback type replacing `ExtensionAPI` in `detectEnv`:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
export type ShellExec = (
|
|
99
|
+
command: string,
|
|
100
|
+
args: string[],
|
|
101
|
+
options?: { cwd?: string; timeout?: number },
|
|
102
|
+
) => Promise<{ stdout: string; stderr: string; code: number }>;
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
This matches the shape of `pi.exec()` but carries no SDK dependency.
|
|
106
|
+
|
|
107
|
+
### Snapshot built in spawn()
|
|
108
|
+
|
|
109
|
+
`spawn()` keeps receiving `ctx` as a parameter (so callers don't need to know about `ParentSnapshot`).
|
|
110
|
+
Internally, it immediately snapshots `ctx` and stores the snapshot in `SpawnArgs` — never the live `ctx` reference.
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
spawn(ctx: ExtensionContext, type: SubagentType, prompt: string, options: SpawnOptions): string {
|
|
114
|
+
const snapshot: ParentSnapshot = buildParentSnapshot(ctx, options.inheritContext);
|
|
115
|
+
const args: SpawnArgs = { snapshot, type, prompt, options };
|
|
116
|
+
// ...
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The `pi` parameter is removed from `spawn()`.
|
|
121
|
+
The `exec` function is injected into `AgentManager` at construction time via `AgentManagerOptions`, since `pi.exec` is a stable capability (same function reference for the extension's lifetime).
|
|
122
|
+
|
|
123
|
+
### buildParentSnapshot helper
|
|
124
|
+
|
|
125
|
+
A new `src/parent-snapshot.ts` module with a single exported function:
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
export function buildParentSnapshot(
|
|
129
|
+
ctx: ExtensionContext,
|
|
130
|
+
inheritContext?: boolean,
|
|
131
|
+
): ParentSnapshot {
|
|
132
|
+
return {
|
|
133
|
+
cwd: ctx.cwd,
|
|
134
|
+
systemPrompt: ctx.getSystemPrompt(),
|
|
135
|
+
model: ctx.model,
|
|
136
|
+
modelRegistry: ctx.modelRegistry,
|
|
137
|
+
parentContext: inheritContext ? buildParentContext(ctx) : undefined,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
This is the only place that touches `ExtensionContext` to build a snapshot.
|
|
143
|
+
It calls `buildParentContext(ctx)` (from `context.ts`) which reads `ctx.sessionManager.getBranch()` — capturing the conversation at spawn time, not dequeue time.
|
|
144
|
+
|
|
145
|
+
### SpawnArgs changes
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
// Before
|
|
149
|
+
interface SpawnArgs {
|
|
150
|
+
pi: ExtensionAPI;
|
|
151
|
+
ctx: ExtensionContext;
|
|
152
|
+
type: SubagentType;
|
|
153
|
+
prompt: string;
|
|
154
|
+
options: SpawnOptions;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// After
|
|
158
|
+
interface SpawnArgs {
|
|
159
|
+
snapshot: ParentSnapshot;
|
|
160
|
+
type: SubagentType;
|
|
161
|
+
prompt: string;
|
|
162
|
+
options: SpawnOptions;
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### AgentRunner.run() signature change
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
// Before
|
|
170
|
+
run(ctx: ExtensionContext, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult>;
|
|
171
|
+
|
|
172
|
+
// After
|
|
173
|
+
run(snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult>;
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### RunOptions changes
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
// Removed fields:
|
|
180
|
+
// pi: ExtensionAPI → replaced by exec: ShellExec
|
|
181
|
+
// inheritContext?: boolean → handled by snapshot.parentContext
|
|
182
|
+
|
|
183
|
+
// Added field:
|
|
184
|
+
exec: ShellExec;
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### runAgent flow changes
|
|
188
|
+
|
|
189
|
+
Inside `runAgent()`, all `ctx.*` reads become `snapshot.*` reads:
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
// Before
|
|
193
|
+
const effectiveCwd = options.cwd ?? ctx.cwd;
|
|
194
|
+
const env = await detectEnv(options.pi, effectiveCwd);
|
|
195
|
+
const cfg = assembleSessionConfig(type, {
|
|
196
|
+
cwd: ctx.cwd,
|
|
197
|
+
parentSystemPrompt: ctx.getSystemPrompt(),
|
|
198
|
+
parentModel: ctx.model,
|
|
199
|
+
modelRegistry: ctx.modelRegistry,
|
|
200
|
+
}, ...);
|
|
201
|
+
|
|
202
|
+
// After
|
|
203
|
+
const effectiveCwd = options.cwd ?? snapshot.cwd;
|
|
204
|
+
const env = await detectEnv(options.exec, effectiveCwd);
|
|
205
|
+
const cfg = assembleSessionConfig(type, {
|
|
206
|
+
cwd: snapshot.cwd,
|
|
207
|
+
parentSystemPrompt: snapshot.systemPrompt,
|
|
208
|
+
parentModel: snapshot.model,
|
|
209
|
+
modelRegistry: snapshot.modelRegistry,
|
|
210
|
+
}, ...);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The `inheritContext` block changes:
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
// Before
|
|
217
|
+
if (options.inheritContext) {
|
|
218
|
+
const parentContext = buildParentContext(ctx);
|
|
219
|
+
if (parentContext) effectivePrompt = parentContext + prompt;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// After
|
|
223
|
+
if (snapshot.parentContext) {
|
|
224
|
+
effectivePrompt = snapshot.parentContext + prompt;
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The `createAgentSession()` call changes:
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
// Before: modelRegistry: ctx.modelRegistry
|
|
232
|
+
// After: modelRegistry: snapshot.modelRegistry as ModelRegistry
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### AgentManager constructor change
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
export interface AgentManagerOptions {
|
|
239
|
+
runner: AgentRunner;
|
|
240
|
+
worktrees: WorktreeManager;
|
|
241
|
+
exec: ShellExec; // NEW — injected from pi.exec
|
|
242
|
+
maxConcurrent?: number;
|
|
243
|
+
getRunConfig?: () => RunConfig;
|
|
244
|
+
onStart?: OnAgentStart;
|
|
245
|
+
onComplete?: OnAgentComplete;
|
|
246
|
+
onCompact?: OnAgentCompact;
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`startAgent()` uses `this.exec` when building `RunOptions`:
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
const promise = this.runner.run(snapshot, type, prompt, {
|
|
254
|
+
exec: this.exec,
|
|
255
|
+
model: options.model,
|
|
256
|
+
// ... (no more pi field)
|
|
257
|
+
});
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Caller updates
|
|
261
|
+
|
|
262
|
+
`index.ts`:
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
// Before
|
|
266
|
+
const manager = new AgentManager({
|
|
267
|
+
runner: { run: runAgent, resume: resumeAgent },
|
|
268
|
+
worktrees: new GitWorktreeManager(process.cwd()),
|
|
269
|
+
// ...
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// After — adds exec
|
|
273
|
+
const manager = new AgentManager({
|
|
274
|
+
runner: { run: runAgent, resume: resumeAgent },
|
|
275
|
+
worktrees: new GitWorktreeManager(process.cwd()),
|
|
276
|
+
exec: (cmd, args, opts) => pi.exec(cmd, args, opts),
|
|
277
|
+
// ...
|
|
278
|
+
});
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Spawn wrappers simplify — no more `pi` injection:
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
// Before
|
|
285
|
+
spawn: (ctx, type, prompt, opts) => manager.spawn(pi, ctx, type, prompt, opts),
|
|
286
|
+
// After
|
|
287
|
+
spawn: (ctx, type, prompt, opts) => manager.spawn(ctx, type, prompt, opts),
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
`service-adapter.ts`:
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
// AgentManagerLike.spawn — drops pi parameter
|
|
294
|
+
spawn(ctx: unknown, type: string, prompt: string, options: unknown): string;
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
`ui/agent-menu.ts` — `spawnAndWait` drops `pi` parameter:
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
// Before
|
|
301
|
+
spawnAndWait: (pi: ExtensionAPI | null, ctx: ExtensionContext, ...) => Promise<AgentRecord>;
|
|
302
|
+
// After
|
|
303
|
+
spawnAndWait: (ctx: ExtensionContext, ...) => Promise<AgentRecord>;
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
## Module-Level Changes
|
|
307
|
+
|
|
308
|
+
### New files
|
|
309
|
+
|
|
310
|
+
1. `src/parent-snapshot.ts` — `buildParentSnapshot()` function (cycle 3).
|
|
311
|
+
2. `test/parent-snapshot.test.ts` — unit tests for the builder (cycle 3).
|
|
312
|
+
|
|
313
|
+
### Changed files (source)
|
|
314
|
+
|
|
315
|
+
Phase 1 (pi elimination):
|
|
316
|
+
|
|
317
|
+
1. `src/types.ts` — add `ShellExec` type (cycle 1); add `ParentSnapshot` interface (cycle 3).
|
|
318
|
+
2. `src/env.ts` — `detectEnv()` accepts `ShellExec` instead of `ExtensionAPI` (cycle 1).
|
|
319
|
+
3. `src/agent-runner.ts` — `RunOptions` replaces `pi` with `exec` (cycle 1); `runAgent()` first param changes to `ParentSnapshot`, removes `inheritContext` from `RunOptions` (cycle 4).
|
|
320
|
+
4. `src/agent-manager.ts` — `AgentManagerOptions` adds `exec`, `startAgent` uses `this.exec` (cycle 1); `spawn`/`spawnAndWait` drop `pi`, `SpawnArgs` drops `pi` (cycle 2); `spawn` builds snapshot, `SpawnArgs` replaces `ctx` with `snapshot` (cycle 5).
|
|
321
|
+
5. `src/index.ts` — pass `exec` to `AgentManager` constructor (cycle 1); simplify `spawn`/`spawnAndWait` wrappers (cycle 2).
|
|
322
|
+
6. `src/service-adapter.ts` — `AgentManagerLike.spawn` drops `pi` (cycle 2).
|
|
323
|
+
7. `src/ui/agent-menu.ts` — `AgentMenuManagerDeps.spawnAndWait` drops `pi` (cycle 2).
|
|
324
|
+
|
|
325
|
+
### Changed files (tests)
|
|
326
|
+
|
|
327
|
+
1. `test/env.test.ts` — `mockPi()` → `mockExec()` (cycle 1).
|
|
328
|
+
2. `test/agent-runner.test.ts` — `{ pi }` → `{ exec: vi.fn() }` (cycle 1); `ctx` mock → `ParentSnapshot` (cycle 4).
|
|
329
|
+
3. `test/agent-runner-extension-tools.test.ts` — same as agent-runner (cycles 1, 4).
|
|
330
|
+
4. `test/agent-manager.test.ts` — add `exec` to `createManager()` (cycle 1); remove `mockPi` from 27 `spawn()` calls (cycle 2); mock `buildParentSnapshot` (cycle 5).
|
|
331
|
+
5. `test/service-adapter.test.ts` — `AgentManagerLike.spawn` mock drops `pi` (cycle 2).
|
|
332
|
+
6. `test/ui/agent-menu.test.ts` — `spawnAndWait` mock drops `pi` (cycle 2).
|
|
333
|
+
|
|
334
|
+
### Unchanged files
|
|
335
|
+
|
|
336
|
+
- `src/service.ts` — public API unchanged.
|
|
337
|
+
- `src/context.ts` — `buildParentContext()` keeps its current signature; called by `buildParentSnapshot()`.
|
|
338
|
+
- `src/session-config.ts` — `AssemblerContext` unchanged; `runAgent` builds it from snapshot fields.
|
|
339
|
+
- `src/agent-record.ts` — unrelated.
|
|
340
|
+
- `src/tools/agent-tool.ts` — already uses narrow `deps.manager` interface without `pi`; the `(ctx, type, prompt, opts)` shape is unchanged.
|
|
341
|
+
- All other source and test files.
|
|
342
|
+
|
|
343
|
+
## Test Impact Analysis
|
|
344
|
+
|
|
345
|
+
### New tests enabled by the extraction
|
|
346
|
+
|
|
347
|
+
- `buildParentSnapshot()` tested in isolation — verifies field mapping and `inheritContext` pre-computation.
|
|
348
|
+
Previously impossible: the snapshot was implicit (live ctx was passed through).
|
|
349
|
+
- `detectEnv()` tests become SDK-free — a plain `ShellExec` stub replaces the `ExtensionAPI` mock.
|
|
350
|
+
|
|
351
|
+
### Existing tests that simplify
|
|
352
|
+
|
|
353
|
+
- `agent-runner.test.ts` — the `ctx` mock loses its methods (`getSystemPrompt: vi.fn()`, `sessionManager.getBranch: vi.fn()`) and becomes a plain data object.
|
|
354
|
+
The `pi` mock (`{} as any`) is replaced by `exec: vi.fn()`.
|
|
355
|
+
Every `runAgent(ctx, ...)` call is a mechanical replacement.
|
|
356
|
+
- `agent-manager.test.ts` — `mockPi` is eliminated entirely (28 spawn calls).
|
|
357
|
+
The `createManager()` helper gains an `exec` field.
|
|
358
|
+
- `env.test.ts` — `mockPi()` factory simplifies to a `vi.fn()` returning exec results.
|
|
359
|
+
|
|
360
|
+
### Existing tests that stay as-is
|
|
361
|
+
|
|
362
|
+
All `agent-manager.test.ts` tests that verify lifecycle (spawn, complete, abort, resume, queue drain, worktree) remain unchanged in intent — they verify the wiring between `AgentManager` and the record/runner.
|
|
363
|
+
Only the mock construction and `spawn()` call sites change mechanically.
|
|
364
|
+
|
|
365
|
+
## TDD Order
|
|
366
|
+
|
|
367
|
+
The cycles are organized into two phases following the Kent Beck principle "make the change that makes the change easy."
|
|
368
|
+
Phase 1 (cycles 1–2) eliminates the `pi` parameter relay — an orthogonal concern that would otherwise cascade through every snapshot cycle.
|
|
369
|
+
Phase 2 (cycles 3–5) introduces `ParentSnapshot`, landing on clean ground.
|
|
370
|
+
|
|
371
|
+
### Phase 1: Eliminate pi relay
|
|
372
|
+
|
|
373
|
+
#### Cycle 1: ShellExec type + exec injection into runner path
|
|
374
|
+
|
|
375
|
+
Test surface: `test/env.test.ts`, `test/agent-runner.test.ts`, `test/agent-runner-extension-tools.test.ts`, `test/agent-manager.test.ts` (updated).
|
|
376
|
+
|
|
377
|
+
This cycle replaces `pi: ExtensionAPI` with `exec: ShellExec` in the runner path (leaf → middle → top), and injects `exec` into `AgentManager`.
|
|
378
|
+
`SpawnArgs` still carries `pi` (unused) — removed in cycle 2.
|
|
379
|
+
|
|
380
|
+
Changes:
|
|
381
|
+
|
|
382
|
+
- `src/types.ts`: add `ShellExec` type.
|
|
383
|
+
- `src/env.ts`: `detectEnv()` accepts `ShellExec` instead of `ExtensionAPI`.
|
|
384
|
+
- `src/agent-runner.ts`: `RunOptions` replaces `pi: ExtensionAPI` with `exec: ShellExec`; `runAgent()` uses `options.exec` for `detectEnv()`; remove `ExtensionAPI` import.
|
|
385
|
+
- `src/agent-manager.ts`: `AgentManagerOptions` adds `exec: ShellExec`; `startAgent()` passes `exec: this.exec` in `RunOptions` instead of `pi` from `SpawnArgs`; stop destructuring `pi` from `SpawnArgs` (it stays in the type for one cycle).
|
|
386
|
+
- `src/index.ts`: pass `exec: (cmd, args, opts) => pi.exec(cmd, args, opts)` to `AgentManager` constructor.
|
|
387
|
+
- `test/env.test.ts`: `mockPi()` → `mockExec()` returning a `ShellExec` stub.
|
|
388
|
+
- `test/agent-runner.test.ts`: `{ pi }` → `{ exec: vi.fn() }` in all `runAgent()` calls.
|
|
389
|
+
- `test/agent-runner-extension-tools.test.ts`: same.
|
|
390
|
+
- `test/agent-manager.test.ts`: add `exec: vi.fn()` to `createManager()` defaults.
|
|
391
|
+
- Run full test suite + `pnpm run check`.
|
|
392
|
+
|
|
393
|
+
Commit: `refactor: inject ShellExec into runner path, replacing ExtensionAPI (#99)`
|
|
394
|
+
|
|
395
|
+
#### Cycle 2: Remove pi from spawn path and callers
|
|
396
|
+
|
|
397
|
+
Test surface: `test/agent-manager.test.ts`, `test/service-adapter.test.ts`, `test/ui/agent-menu.test.ts` (updated).
|
|
398
|
+
|
|
399
|
+
With `exec` already injected and `pi` unused in `startAgent()`, this cycle removes it from the remaining surfaces.
|
|
400
|
+
|
|
401
|
+
Changes:
|
|
402
|
+
|
|
403
|
+
- `src/agent-manager.ts`: `SpawnArgs` drops `pi`; `spawn()` drops `pi` parameter; `spawnAndWait()` drops `pi` parameter; remove `ExtensionAPI` import.
|
|
404
|
+
- `src/service-adapter.ts`: `AgentManagerLike.spawn()` drops `pi` parameter; `createSubagentsService().spawn()` passes `session.ctx` only.
|
|
405
|
+
- `src/ui/agent-menu.ts`: `AgentMenuManagerDeps.spawnAndWait()` drops `pi` parameter; call site drops `null` first arg.
|
|
406
|
+
- `src/index.ts`: simplify `spawn` wrapper — `(ctx, type, prompt, opts) => manager.spawn(ctx, type, prompt, opts)`; simplify `spawnAndWait` wrappers similarly.
|
|
407
|
+
- `test/agent-manager.test.ts`: remove `mockPi` constant; drop it from all 27 `spawn()` call sites.
|
|
408
|
+
- `test/service-adapter.test.ts`: update `AgentManagerLike.spawn` mock.
|
|
409
|
+
- `test/ui/agent-menu.test.ts`: update `spawnAndWait` mock.
|
|
410
|
+
- Run full test suite + `pnpm run check`.
|
|
411
|
+
|
|
412
|
+
Commit: `refactor: remove pi parameter from spawn path (#99)`
|
|
413
|
+
|
|
414
|
+
### Phase 2: ParentSnapshot
|
|
415
|
+
|
|
416
|
+
#### Cycle 3: ParentSnapshot type and builder
|
|
417
|
+
|
|
418
|
+
Test surface: `test/parent-snapshot.test.ts` (new file).
|
|
419
|
+
|
|
420
|
+
Tests cover:
|
|
421
|
+
|
|
422
|
+
- Snapshots `cwd`, `systemPrompt` (from `getSystemPrompt()`), `model`, `modelRegistry` from a mock `ctx`.
|
|
423
|
+
- When `inheritContext` is true and conversation exists, `parentContext` is populated.
|
|
424
|
+
- When `inheritContext` is false or undefined, `parentContext` is undefined.
|
|
425
|
+
- When `inheritContext` is true but conversation is empty, `parentContext` is undefined.
|
|
426
|
+
|
|
427
|
+
Changes:
|
|
428
|
+
|
|
429
|
+
- Add `ParentSnapshot` interface to `src/types.ts`.
|
|
430
|
+
- Create `src/parent-snapshot.ts` with `buildParentSnapshot()`.
|
|
431
|
+
- Create `test/parent-snapshot.test.ts`.
|
|
432
|
+
|
|
433
|
+
Commit: `feat: add ParentSnapshot type and builder (#99)`
|
|
434
|
+
|
|
435
|
+
#### Cycle 4: runAgent and AgentRunner accept ParentSnapshot
|
|
436
|
+
|
|
437
|
+
Test surface: `test/agent-runner.test.ts`, `test/agent-runner-extension-tools.test.ts` (updated).
|
|
438
|
+
|
|
439
|
+
With `pi` already eliminated (phase 1), this cycle only changes the first parameter of `runAgent()` — no other `RunOptions` churn.
|
|
440
|
+
|
|
441
|
+
Changes:
|
|
442
|
+
|
|
443
|
+
- `src/agent-runner.ts`:
|
|
444
|
+
- `runAgent()` first parameter: `ctx: ExtensionContext` → `snapshot: ParentSnapshot`.
|
|
445
|
+
- `AgentRunner.run()` interface: first parameter changes to `snapshot: ParentSnapshot`.
|
|
446
|
+
- Remove `inheritContext` from `RunOptions` (snapshot has `parentContext`).
|
|
447
|
+
- `inheritContext` block → `if (snapshot.parentContext)`.
|
|
448
|
+
- `assembleSessionConfig` and `createAgentSession` calls read from `snapshot.*` instead of `ctx.*`.
|
|
449
|
+
- Remove `ExtensionContext` import; remove `buildParentContext` import (no longer called here).
|
|
450
|
+
- `test/agent-runner.test.ts`: replace `ctx` mock with a plain `ParentSnapshot` object.
|
|
451
|
+
- `test/agent-runner-extension-tools.test.ts`: same.
|
|
452
|
+
- Run `pnpm run check` — catches `agent-manager.ts` type error in `startAgent()`'s `runner.run()` call (still passing `ctx`); addressed in cycle 5.
|
|
453
|
+
|
|
454
|
+
Commit: `refactor: AgentRunner accepts ParentSnapshot instead of ExtensionContext (#99)`
|
|
455
|
+
|
|
456
|
+
#### Cycle 5: AgentManager spawn builds snapshot
|
|
457
|
+
|
|
458
|
+
Test surface: `test/agent-manager.test.ts` (updated).
|
|
459
|
+
|
|
460
|
+
Changes:
|
|
461
|
+
|
|
462
|
+
- `src/agent-manager.ts`:
|
|
463
|
+
- `spawn()` calls `buildParentSnapshot(ctx, options.inheritContext)` and stores the snapshot in `SpawnArgs`.
|
|
464
|
+
- `SpawnArgs`: replace `ctx: ExtensionContext` with `snapshot: ParentSnapshot`.
|
|
465
|
+
- `startAgent()` destructures `snapshot` from `SpawnArgs`; passes it to `runner.run(snapshot, ...)`.
|
|
466
|
+
- Remove `ExtensionContext` import.
|
|
467
|
+
- `test/agent-manager.test.ts`: `vi.mock("../src/parent-snapshot.js")` to isolate `buildParentSnapshot`; `mockCtx` stays minimal (`{ cwd: "/tmp" } as any`) because `spawn()` delegates to the mocked builder.
|
|
468
|
+
- Run full test suite + `pnpm run check`.
|
|
469
|
+
|
|
470
|
+
Commit: `refactor: AgentManager captures ParentSnapshot at spawn time (#99)`
|
|
471
|
+
|
|
472
|
+
## Risks and Mitigations
|
|
473
|
+
|
|
474
|
+
| Risk | Mitigation |
|
|
475
|
+
| ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
476
|
+
| `modelRegistry` is a reference capture, not a data copy — could theoretically go stale | Registries are structurally stable within a session; the staleness risk applies to `cwd`/`systemPrompt`/`model`/conversation, all of which are value-captured |
|
|
477
|
+
| Large test files (~410 + ~686 lines) need mechanical updates | Phase 1 handles `pi` churn (27 spawn calls) separately from phase 2's `ctx` → `snapshot` change, so each cycle touches one concern per test file |
|
|
478
|
+
| `spawn()` still receives `ctx` — caller could accidentally use it post-snapshot | `ctx` is a function parameter, not stored; `SpawnArgs` holds only the snapshot; the type system prevents re-storing ctx since `SpawnArgs.ctx` no longer exists |
|
|
479
|
+
| `buildParentContext` still accepts `ExtensionContext` — mocking it requires a nested `sessionManager` mock | The mock only appears in `parent-snapshot.test.ts` (one place); a follow-up could narrow `buildParentContext` to accept just the branch data |
|
|
480
|
+
| Phase 1 temporarily leaves `pi` in `SpawnArgs` (cycle 1) before removing it (cycle 2) | The field is unused after cycle 1 (`startAgent` uses `this.exec` instead); cycle 2 removes it in the next commit |
|
|
481
|
+
|
|
482
|
+
## Open Questions
|
|
483
|
+
|
|
484
|
+
- Whether to narrow `buildParentContext()` to accept `getBranch()` data directly instead of `ExtensionContext`.
|
|
485
|
+
This would eliminate the last `ExtensionContext` usage in the snapshot path but is a separate refactoring of `context.ts`.
|
|
486
|
+
Deferred — the current `buildParentSnapshot` wrapper isolates the SDK dependency to one module.
|
|
487
|
+
- Whether `runtime.currentCtx` should be simplified from `{ pi, ctx }` to just `ctx` now that `pi` is not passed to `spawn()`.
|
|
488
|
+
Natural follow-up but out of scope — `currentCtx.pi` has no other consumers and removing it is a 3-line change.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 98
|
|
3
|
+
issue_title: "Extract AgentRecord state machine from scattered status transitions"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #98 — Extract AgentRecord state machine
|
|
7
|
+
|
|
8
|
+
## Final Retrospective (2026-05-20T23:45:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Converted `AgentRecord` from a plain interface to a class with 7 encapsulated transition methods, centralizing the scattered status-transition logic from 6 locations in `agent-manager.ts`.
|
|
13
|
+
The prerequisite issue #102 (shared test factory) made the class conversion a 2-file change instead of an 8-file rewrite.
|
|
14
|
+
Released as `pi-subagents-v6.1.0`.
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
#### What went well
|
|
19
|
+
|
|
20
|
+
- The "encapsulate last" TDD strategy (cycles 1–5 with public fields, cycle 6 makes them private) let the compiler verify migration completeness before locking down access.
|
|
21
|
+
`get-result-tool.test.ts` and `make-record.test.ts` failures were caught immediately.
|
|
22
|
+
- The worktree-reorder design insight (compute final result including branch text before calling the transition method) avoided the need for an `appendToResult()` method and survived implementation unchanged.
|
|
23
|
+
- The prerequisite issue #102 (shared `createTestRecord` factory) proved essential — the class conversion touched only the factory and `agent-manager.ts`, not 8 individual test files.
|
|
24
|
+
|
|
25
|
+
#### What caused friction (agent side)
|
|
26
|
+
|
|
27
|
+
- `premature-convergence` — The initial plan proposed `MutableAgentRecord` alongside the existing interface, which was the lowest-friction path but the wrong abstraction.
|
|
28
|
+
The user redirected twice ("It should definitely become a class" → "I meant encapsulate") before the design was right.
|
|
29
|
+
Impact: full plan rewrite and an extra prerequisite issue (#102).
|
|
30
|
+
The plan template's "use ask-user for ambiguous design choices" instruction applied (class vs wrapper was genuinely ambiguous), but I didn't invoke it.
|
|
31
|
+
|
|
32
|
+
- `missing-context` — The revised plan stated "No test files need updating (except the shared factory)" but missed `{ ...baseRecord, field }` spread patterns in `test/notification.test.ts`, `test/service-adapter.test.ts`, `test/tools/get-result-tool.test.ts`, and `test/helpers/make-record.test.ts`.
|
|
33
|
+
Spreading a class instance produces a plain object that lacks the class's methods.
|
|
34
|
+
Impact: 4 extra test files needed mechanical fixes in cycles 4 and 6; plan's "Unchanged files" list was wrong.
|
|
35
|
+
The compiler caught all of them, so no behavioral risk, but the plan should have predicted the churn.
|
|
36
|
+
|
|
37
|
+
#### What caused friction (user side)
|
|
38
|
+
|
|
39
|
+
- The issue body was intentionally flexible ("onto AgentRecord (or a thin wrapper)"), which left the wrapper-vs-class decision open.
|
|
40
|
+
The user's preference for a class with encapsulated state became clear only after seeing the initial plan.
|
|
41
|
+
Earlier signal (e.g., labeling the issue with a "class conversion" tag or stating "must encapsulate" in the issue body) would have avoided the plan rewrite.
|
|
42
|
+
|
|
43
|
+
### Changes made
|
|
44
|
+
|
|
45
|
+
1. `.pi/skills/testing/SKILL.md` — Added interface→class spread-pattern rule to TDD planning rules.
|
|
46
|
+
2. `.pi/skills/package-pi-subagents/SKILL.md` — Added `agent-record.ts` to the Core engine module table and updated `types.ts` description.
|
package/package.json
CHANGED
package/src/agent-manager.ts
CHANGED
|
@@ -8,12 +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
12
|
import { AgentRecord } from "./agent-record.js";
|
|
13
13
|
import type { AgentRunner, ToolActivity } from "./agent-runner.js";
|
|
14
14
|
import { debugLog } from "./debug.js";
|
|
15
|
+
import { buildParentSnapshot } from "./parent-snapshot.js";
|
|
15
16
|
import type { RunConfig } from "./runtime.js";
|
|
16
|
-
import type { AgentInvocation, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
|
|
17
|
+
import type { AgentInvocation, IsolationMode, ParentSnapshot, ShellExec, SubagentType, ThinkingLevel } from "./types.js";
|
|
17
18
|
import { addUsage } from "./usage.js";
|
|
18
19
|
import type { WorktreeManager } from "./worktree.js";
|
|
19
20
|
|
|
@@ -28,6 +29,7 @@ const DEFAULT_MAX_CONCURRENT = 4;
|
|
|
28
29
|
export interface AgentManagerOptions {
|
|
29
30
|
runner: AgentRunner;
|
|
30
31
|
worktrees: WorktreeManager;
|
|
32
|
+
exec: ShellExec;
|
|
31
33
|
maxConcurrent?: number;
|
|
32
34
|
getRunConfig?: () => RunConfig;
|
|
33
35
|
onStart?: OnAgentStart;
|
|
@@ -36,8 +38,7 @@ export interface AgentManagerOptions {
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
interface SpawnArgs {
|
|
39
|
-
|
|
40
|
-
ctx: ExtensionContext;
|
|
41
|
+
snapshot: ParentSnapshot;
|
|
41
42
|
type: SubagentType;
|
|
42
43
|
prompt: string;
|
|
43
44
|
options: SpawnOptions;
|
|
@@ -89,6 +90,7 @@ export class AgentManager {
|
|
|
89
90
|
private onCompact?: OnAgentCompact;
|
|
90
91
|
private readonly runner: AgentRunner;
|
|
91
92
|
private readonly worktrees: WorktreeManager;
|
|
93
|
+
private readonly exec: ShellExec;
|
|
92
94
|
private maxConcurrent: number;
|
|
93
95
|
private getRunConfig?: () => RunConfig;
|
|
94
96
|
|
|
@@ -100,6 +102,7 @@ export class AgentManager {
|
|
|
100
102
|
constructor(options: AgentManagerOptions) {
|
|
101
103
|
this.runner = options.runner;
|
|
102
104
|
this.worktrees = options.worktrees;
|
|
105
|
+
this.exec = options.exec;
|
|
103
106
|
this.onComplete = options.onComplete;
|
|
104
107
|
this.onStart = options.onStart;
|
|
105
108
|
this.onCompact = options.onCompact;
|
|
@@ -126,7 +129,6 @@ export class AgentManager {
|
|
|
126
129
|
* If the concurrency limit is reached, the agent is queued.
|
|
127
130
|
*/
|
|
128
131
|
spawn(
|
|
129
|
-
pi: ExtensionAPI,
|
|
130
132
|
ctx: ExtensionContext,
|
|
131
133
|
type: SubagentType,
|
|
132
134
|
prompt: string,
|
|
@@ -145,7 +147,8 @@ export class AgentManager {
|
|
|
145
147
|
});
|
|
146
148
|
this.agents.set(id, record);
|
|
147
149
|
|
|
148
|
-
const
|
|
150
|
+
const snapshot = buildParentSnapshot(ctx, options.inheritContext);
|
|
151
|
+
const args: SpawnArgs = { snapshot, type, prompt, options };
|
|
149
152
|
|
|
150
153
|
if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
|
|
151
154
|
// Queue it — will be started when a running agent completes
|
|
@@ -165,7 +168,7 @@ export class AgentManager {
|
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
168
|
-
private startAgent(id: string, record: AgentRecord, {
|
|
171
|
+
private startAgent(id: string, record: AgentRecord, { snapshot, type, prompt, options }: SpawnArgs) {
|
|
169
172
|
// Worktree isolation: try to create a temporary git worktree. Strict —
|
|
170
173
|
// fail loud if not possible (no silent fallback to main tree). Done
|
|
171
174
|
// BEFORE state mutation so a throw doesn't leave the record half-running.
|
|
@@ -196,14 +199,13 @@ export class AgentManager {
|
|
|
196
199
|
const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
|
|
197
200
|
|
|
198
201
|
const runConfig = this.getRunConfig?.();
|
|
199
|
-
const promise = this.runner.run(
|
|
200
|
-
|
|
202
|
+
const promise = this.runner.run(snapshot, type, prompt, {
|
|
203
|
+
exec: this.exec,
|
|
201
204
|
model: options.model,
|
|
202
205
|
maxTurns: options.maxTurns,
|
|
203
206
|
defaultMaxTurns: runConfig?.defaultMaxTurns,
|
|
204
207
|
graceTurns: runConfig?.graceTurns,
|
|
205
208
|
isolated: options.isolated,
|
|
206
|
-
inheritContext: options.inheritContext,
|
|
207
209
|
thinkingLevel: options.thinkingLevel,
|
|
208
210
|
cwd: worktreeCwd,
|
|
209
211
|
parentSessionFile: options.parentSessionFile,
|
|
@@ -314,13 +316,12 @@ export class AgentManager {
|
|
|
314
316
|
* Foreground agents bypass the concurrency queue.
|
|
315
317
|
*/
|
|
316
318
|
async spawnAndWait(
|
|
317
|
-
pi: ExtensionAPI,
|
|
318
319
|
ctx: ExtensionContext,
|
|
319
320
|
type: SubagentType,
|
|
320
321
|
prompt: string,
|
|
321
322
|
options: Omit<SpawnOptions, "isBackground">,
|
|
322
323
|
): Promise<AgentRecord> {
|
|
323
|
-
const id = this.spawn(
|
|
324
|
+
const id = this.spawn(ctx, type, prompt, { ...options, isBackground: false });
|
|
324
325
|
const record = this.agents.get(id)!;
|
|
325
326
|
await record.promise;
|
|
326
327
|
return record;
|
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,
|
package/src/types.ts
CHANGED
|
@@ -83,8 +83,38 @@ export interface NotificationDetails {
|
|
|
83
83
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Plain data snapshot of the parent session state captured at spawn time.
|
|
88
|
+
* Replaces live `ExtensionContext` references so queued agents don't read stale state.
|
|
89
|
+
*/
|
|
90
|
+
export interface ParentSnapshot {
|
|
91
|
+
/** Parent working directory. */
|
|
92
|
+
cwd: string;
|
|
93
|
+
/** Parent's effective system prompt (for append-mode agents). */
|
|
94
|
+
systemPrompt: string;
|
|
95
|
+
/** Parent's current model instance (fallback when agent config has no model). */
|
|
96
|
+
model: unknown;
|
|
97
|
+
/** Model registry for resolving config.model strings and creating sessions. */
|
|
98
|
+
modelRegistry: {
|
|
99
|
+
find(provider: string, modelId: string): unknown;
|
|
100
|
+
getAvailable?(): Array<{ provider: string; id: string }>;
|
|
101
|
+
};
|
|
102
|
+
/** Pre-built parent conversation text (when inheritContext was requested). */
|
|
103
|
+
parentContext?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
86
106
|
export interface EnvInfo {
|
|
87
107
|
isGitRepo: boolean;
|
|
88
108
|
branch: string;
|
|
89
109
|
platform: string;
|
|
90
110
|
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Narrow shell-exec callback replacing `ExtensionAPI` in `detectEnv()`.
|
|
114
|
+
* Matches the shape of `pi.exec()` without carrying an SDK dependency.
|
|
115
|
+
*/
|
|
116
|
+
export type ShellExec = (
|
|
117
|
+
command: string,
|
|
118
|
+
args: string[],
|
|
119
|
+
options?: { cwd?: string; timeout?: number },
|
|
120
|
+
) => Promise<{ stdout: string; stderr: string; code: number }>;
|
package/src/ui/agent-menu.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
|
-
import type {
|
|
4
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { SpawnOptions } from "../agent-manager.js";
|
|
6
6
|
import {
|
|
7
7
|
BUILTIN_TOOL_NAMES,
|
|
@@ -21,7 +21,7 @@ export interface AgentMenuManager {
|
|
|
21
21
|
listAgents: () => AgentRecord[];
|
|
22
22
|
getRecord: (id: string) => AgentRecord | undefined;
|
|
23
23
|
/** Used by generate wizard to spawn an agent that writes the .md file. */
|
|
24
|
-
spawnAndWait: (
|
|
24
|
+
spawnAndWait: (ctx: ExtensionContext, type: string, prompt: string, opts: Omit<SpawnOptions, "isBackground">) => Promise<AgentRecord>;
|
|
25
25
|
getMaxConcurrent: () => number;
|
|
26
26
|
setMaxConcurrent: (n: number) => void;
|
|
27
27
|
}
|
|
@@ -487,7 +487,6 @@ Guidelines for choosing settings:
|
|
|
487
487
|
Write the file using the write tool. Only write the file, nothing else.`;
|
|
488
488
|
|
|
489
489
|
const record = await deps.manager.spawnAndWait(
|
|
490
|
-
null,
|
|
491
490
|
ctx,
|
|
492
491
|
"general-purpose",
|
|
493
492
|
generatePrompt,
|