@gotgenes/pi-subagents 6.12.1 → 6.13.1
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 +300 -161
- package/docs/plans/0136-decompose-agent-menu.md +300 -0
- package/docs/retro/0135-extract-display-helpers.md +38 -0
- package/docs/retro/0136-decompose-agent-menu.md +43 -0
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/ui/agent-config-editor.ts +202 -0
- package/src/ui/agent-creation-wizard.ts +246 -0
- package/src/ui/agent-file-ops.ts +59 -0
- package/src/ui/agent-menu.ts +21 -393
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 136
|
|
3
|
+
issue_title: "Decompose `agent-menu.ts`"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Decompose agent-menu into config editor, creation wizard, and file-ops abstraction
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`agent-menu.ts` (668 lines) has 8 distinct responsibilities: menu FSM orchestration, agent listing, config editing, agent ejection, two creation wizards, running-agent viewer, and settings form.
|
|
11
|
+
Filesystem operations (`readFileSync`, `writeFileSync`, `unlinkSync`, `existsSync`, `mkdirSync`) are scattered across 10+ call sites with no abstraction layer, forcing tests to use `vi.mock("node:fs")` rather than injecting stubs.
|
|
12
|
+
|
|
13
|
+
## Goals
|
|
14
|
+
|
|
15
|
+
- Extract an `AgentFileOps` interface abstracting all filesystem calls, with a production `FsAgentFileOps` implementation.
|
|
16
|
+
- Extract `ui/agent-config-editor.ts` (~170 lines) containing `showAgentDetail` with eject/disable/enable/edit/delete/reset transitions.
|
|
17
|
+
- Extract `ui/agent-creation-wizard.ts` (~200 lines) containing both the AI-generation and manual-form creation paths.
|
|
18
|
+
- Leave menu FSM, agent listing, running-agent viewer, and settings form in `agent-menu.ts` (~280 lines).
|
|
19
|
+
- Each extracted module receives dependencies via injection — no direct `node:fs` imports outside `FsAgentFileOps`.
|
|
20
|
+
- Enable unit testing of config editor and creation wizard without `vi.mock("node:fs")`.
|
|
21
|
+
|
|
22
|
+
## Non-Goals
|
|
23
|
+
|
|
24
|
+
- Refactoring the settings form or running-agent viewer — they stay in `agent-menu.ts`.
|
|
25
|
+
- Changing any user-facing behavior or menu flow.
|
|
26
|
+
- Extracting `showAllAgentsList` — it is menu orchestration (presents the list, delegates to editor for detail).
|
|
27
|
+
- Deduplicating the YAML frontmatter builders in eject and manual wizard (structurally different content shapes).
|
|
28
|
+
|
|
29
|
+
## Background
|
|
30
|
+
|
|
31
|
+
### Prerequisite
|
|
32
|
+
|
|
33
|
+
Issue #135 (extract display helpers) is **implemented** — `ui/display.ts` exists and provides `formatDuration`, `getDisplayName`, and other formatters.
|
|
34
|
+
Extracted menu sub-modules can import display helpers without pulling in the widget.
|
|
35
|
+
|
|
36
|
+
### Existing IO-injection convention
|
|
37
|
+
|
|
38
|
+
The codebase uses injectable IO interfaces to decouple domain logic from `node:fs` and the Pi SDK:
|
|
39
|
+
|
|
40
|
+
- `AssemblerIO` in `session-config.ts` — 4 methods for prompt assembly IO.
|
|
41
|
+
- `RunnerIO` in `agent-runner.ts` — 7 methods for session creation IO.
|
|
42
|
+
|
|
43
|
+
Both follow the same pattern: interface defined in the module that uses it, production implementation wired at the edge (`index.ts`), test stubs injected directly.
|
|
44
|
+
|
|
45
|
+
### Current fs call inventory in agent-menu.ts
|
|
46
|
+
|
|
47
|
+
| Function | fs calls |
|
|
48
|
+
| -------------------------------- | ------------------------------------------------------------- |
|
|
49
|
+
| `findAgentFile` | `existsSync` ×2 |
|
|
50
|
+
| `showAgentDetail` (edit) | `readFileSync`, `writeFileSync` |
|
|
51
|
+
| `showAgentDetail` (delete/reset) | `unlinkSync` |
|
|
52
|
+
| `ejectAgent` | `mkdirSync`, `existsSync`, `writeFileSync` |
|
|
53
|
+
| `disableAgent` | `readFileSync`, `writeFileSync`, `mkdirSync`, `writeFileSync` |
|
|
54
|
+
| `enableAgent` | `readFileSync`, `writeFileSync`, `unlinkSync` |
|
|
55
|
+
| `showGenerateWizard` | `mkdirSync`, `existsSync` ×2 |
|
|
56
|
+
| `showManualWizard` | `mkdirSync`, `existsSync`, `writeFileSync` |
|
|
57
|
+
|
|
58
|
+
### Current test file
|
|
59
|
+
|
|
60
|
+
`agent-menu.test.ts` (212 lines) uses `vi.mock("node:fs")` with `vi.hoisted` stubs.
|
|
61
|
+
Only 7 tests exist — they cover the top-level menu, agent listing, projectAgentsDir injection, and settings delegation.
|
|
62
|
+
No tests exercise config-editor transitions (edit/delete/eject/disable/enable) or creation wizards.
|
|
63
|
+
|
|
64
|
+
## Design Overview
|
|
65
|
+
|
|
66
|
+
### AgentFileOps interface
|
|
67
|
+
|
|
68
|
+
A narrow interface abstracting all agent `.md` file operations:
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
interface AgentFileOps {
|
|
72
|
+
exists(filePath: string): boolean;
|
|
73
|
+
read(filePath: string): string | undefined;
|
|
74
|
+
write(filePath: string, content: string): void;
|
|
75
|
+
remove(filePath: string): void;
|
|
76
|
+
ensureDir(dirPath: string): void;
|
|
77
|
+
findAgentFile(name: string, dirs: string[]): string | undefined;
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Design notes:
|
|
82
|
+
|
|
83
|
+
- `read` returns `undefined` when the file does not exist (wraps `readFileSync` with a try/catch).
|
|
84
|
+
- `write` ensures parent directories exist before writing (internalizes `mkdirSync`).
|
|
85
|
+
- `ensureDir` is needed separately because `showGenerateWizard` creates the directory before spawning an agent that writes via Pi's tool (not via `AgentFileOps.write`).
|
|
86
|
+
- `findAgentFile` takes an ordered list of directories and returns the first matching path.
|
|
87
|
+
The current code returns `{ path, location }` but only `showAgentDetail` uses `location` (for a confirmation dialog) — the full path already conveys the location to the user, so a plain `string | undefined` return suffices.
|
|
88
|
+
|
|
89
|
+
### FsAgentFileOps production implementation
|
|
90
|
+
|
|
91
|
+
Thin wrapper over `node:fs` synchronous APIs:
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
class FsAgentFileOps implements AgentFileOps {
|
|
95
|
+
exists(filePath: string): boolean {
|
|
96
|
+
return existsSync(filePath);
|
|
97
|
+
}
|
|
98
|
+
read(filePath: string): string | undefined {
|
|
99
|
+
try { return readFileSync(filePath, "utf-8"); }
|
|
100
|
+
catch { return undefined; }
|
|
101
|
+
}
|
|
102
|
+
write(filePath: string, content: string): void {
|
|
103
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
104
|
+
writeFileSync(filePath, content, "utf-8");
|
|
105
|
+
}
|
|
106
|
+
remove(filePath: string): void {
|
|
107
|
+
unlinkSync(filePath);
|
|
108
|
+
}
|
|
109
|
+
ensureDir(dirPath: string): void {
|
|
110
|
+
mkdirSync(dirPath, { recursive: true });
|
|
111
|
+
}
|
|
112
|
+
findAgentFile(name: string, dirs: string[]): string | undefined {
|
|
113
|
+
for (const dir of dirs) {
|
|
114
|
+
const p = join(dir, `${name}.md`);
|
|
115
|
+
if (existsSync(p)) return p;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Extracted module patterns
|
|
123
|
+
|
|
124
|
+
Both extracted modules follow the existing factory-function pattern (`createAgentsMenuHandler(deps)`) with narrow deps interfaces per ISP:
|
|
125
|
+
|
|
126
|
+
Config editor call-site sketch (from orchestrator):
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
const editor = createAgentConfigEditor({
|
|
130
|
+
fileOps: deps.fileOps,
|
|
131
|
+
registry: deps.registry,
|
|
132
|
+
personalAgentsDir: deps.personalAgentsDir,
|
|
133
|
+
projectAgentsDir: deps.projectAgentsDir,
|
|
134
|
+
});
|
|
135
|
+
// ...
|
|
136
|
+
await editor.showAgentDetail(ctx, agentName);
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Creation wizard call-site sketch (from orchestrator):
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
const wizard = createAgentCreationWizard({
|
|
143
|
+
fileOps: deps.fileOps,
|
|
144
|
+
manager: deps.manager,
|
|
145
|
+
registry: deps.registry,
|
|
146
|
+
personalAgentsDir: deps.personalAgentsDir,
|
|
147
|
+
projectAgentsDir: deps.projectAgentsDir,
|
|
148
|
+
});
|
|
149
|
+
// ...
|
|
150
|
+
await wizard.showCreateWizard(ctx);
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Deps interfaces (ISP-compliant)
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// agent-config-editor.ts
|
|
157
|
+
interface AgentConfigEditorDeps {
|
|
158
|
+
fileOps: AgentFileOps;
|
|
159
|
+
registry: AgentTypeRegistry;
|
|
160
|
+
personalAgentsDir: string;
|
|
161
|
+
projectAgentsDir: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// agent-creation-wizard.ts
|
|
165
|
+
interface AgentCreationWizardDeps {
|
|
166
|
+
fileOps: AgentFileOps;
|
|
167
|
+
manager: AgentMenuManager;
|
|
168
|
+
registry: AgentTypeRegistry;
|
|
169
|
+
personalAgentsDir: string;
|
|
170
|
+
projectAgentsDir: string;
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The config editor does not need `manager` (no agent spawning).
|
|
175
|
+
The creation wizard does not need `agentActivity`, `getModelLabel`, or `settings`.
|
|
176
|
+
|
|
177
|
+
### Updated AgentMenuDeps
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
interface AgentMenuDeps {
|
|
181
|
+
manager: AgentMenuManager;
|
|
182
|
+
registry: AgentTypeRegistry;
|
|
183
|
+
agentActivity: AgentActivityReader;
|
|
184
|
+
getModelLabel: (type: string, registry?: ModelRegistry) => string;
|
|
185
|
+
settings: AgentMenuSettings;
|
|
186
|
+
fileOps: AgentFileOps; // ← new
|
|
187
|
+
personalAgentsDir: string;
|
|
188
|
+
projectAgentsDir: string;
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The single new field (`fileOps`) replaces all direct `node:fs` imports.
|
|
193
|
+
|
|
194
|
+
## Module-Level Changes
|
|
195
|
+
|
|
196
|
+
### New files
|
|
197
|
+
|
|
198
|
+
1. `src/ui/agent-file-ops.ts` — `AgentFileOps` interface + `FsAgentFileOps` class.
|
|
199
|
+
2. `src/ui/agent-config-editor.ts` — `AgentConfigEditorDeps` interface, `createAgentConfigEditor` factory.
|
|
200
|
+
Moves in: `showAgentDetail`, `ejectAgent`, `disableAgent`, `enableAgent`.
|
|
201
|
+
3. `src/ui/agent-creation-wizard.ts` — `AgentCreationWizardDeps` interface, `createAgentCreationWizard` factory.
|
|
202
|
+
Moves in: `showCreateWizard`, `showGenerateWizard`, `showManualWizard`.
|
|
203
|
+
4. `test/ui/agent-file-ops.test.ts` — unit tests for `FsAgentFileOps`.
|
|
204
|
+
5. `test/ui/agent-config-editor.test.ts` — unit tests for config editor transitions with injected `AgentFileOps` stubs.
|
|
205
|
+
6. `test/ui/agent-creation-wizard.test.ts` — unit tests for wizard paths with injected stubs.
|
|
206
|
+
|
|
207
|
+
### Modified files
|
|
208
|
+
|
|
209
|
+
1. `src/ui/agent-menu.ts`:
|
|
210
|
+
- Remove `import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"`.
|
|
211
|
+
- Remove `findAgentFile`, `showAgentDetail`, `ejectAgent`, `disableAgent`, `enableAgent`, `showCreateWizard`, `showGenerateWizard`, `showManualWizard` (~375 lines removed).
|
|
212
|
+
- Add `fileOps: AgentFileOps` to `AgentMenuDeps`.
|
|
213
|
+
- Import and instantiate `createAgentConfigEditor` and `createAgentCreationWizard`.
|
|
214
|
+
- Update `showAllAgentsList` to call `editor.showAgentDetail(ctx, agentName)`.
|
|
215
|
+
- Update `showAgentsMenu` to call `wizard.showCreateWizard(ctx)`.
|
|
216
|
+
- Re-export `AgentMenuManager` (still needed by the wizard deps).
|
|
217
|
+
- Net result: ~280 lines (orchestration, listing, running agents, settings).
|
|
218
|
+
2. `src/index.ts`:
|
|
219
|
+
- Import `FsAgentFileOps` from `./ui/agent-file-ops.js`.
|
|
220
|
+
- Construct `new FsAgentFileOps()` and pass as `fileOps` in the `createAgentsMenuHandler` call.
|
|
221
|
+
3. `test/ui/agent-menu.test.ts`:
|
|
222
|
+
- Remove `vi.mock("node:fs")` and `vi.hoisted` stubs entirely.
|
|
223
|
+
- Add `fileOps` stub to `makeDeps` factory.
|
|
224
|
+
- Rewrite the "projectAgentsDir injection" test to verify that the orchestrator delegates to the editor (or move to `agent-config-editor.test.ts`).
|
|
225
|
+
|
|
226
|
+
### Removed symbols
|
|
227
|
+
|
|
228
|
+
Grep confirms these functions are only referenced within `agent-menu.ts` (closures inside `createAgentsMenuHandler`) — no external consumers:
|
|
229
|
+
|
|
230
|
+
- `findAgentFile` — moves to `agent-config-editor.ts` (reimplemented via `AgentFileOps.findAgentFile`).
|
|
231
|
+
- `showAgentDetail`, `ejectAgent`, `disableAgent`, `enableAgent` — move to `agent-config-editor.ts`.
|
|
232
|
+
- `showCreateWizard`, `showGenerateWizard`, `showManualWizard` — move to `agent-creation-wizard.ts`.
|
|
233
|
+
|
|
234
|
+
## Test Impact Analysis
|
|
235
|
+
|
|
236
|
+
### New unit tests enabled by extraction
|
|
237
|
+
|
|
238
|
+
1. **Config editor transitions** — `showAgentDetail` has 6 menu paths (edit, delete, reset, eject, disable, enable) with sub-branches (confirm/cancel, file exists/not-exists, default/custom agent).
|
|
239
|
+
Currently untestable in isolation because the functions are closures inside `createAgentsMenuHandler`.
|
|
240
|
+
After extraction, each transition is testable with injected `AgentFileOps` stubs — no `vi.mock("node:fs")` needed.
|
|
241
|
+
2. **Creation wizard flows** — generate wizard (spawn + check result) and manual wizard (multi-step form → write file) are currently untested.
|
|
242
|
+
After extraction, both are testable with injected stubs for `AgentFileOps` and `AgentMenuManager.spawnAndWait`.
|
|
243
|
+
3. **FsAgentFileOps** — thin tests verifying the production fs wrapper (read returns undefined on missing file, write ensures parent dirs, findAgentFile checks directories in order).
|
|
244
|
+
|
|
245
|
+
### Existing tests that become redundant
|
|
246
|
+
|
|
247
|
+
The "projectAgentsDir injection" test (`agent-menu.test.ts`) navigates through the main menu → agent types → agent detail, then asserts `mockExistsSync` was called with the correct path.
|
|
248
|
+
After extraction, this end-to-end path tests orchestration + editor together; a focused test in `agent-config-editor.test.ts` replaces it.
|
|
249
|
+
The orchestrator test can be simplified to verify it delegates to the editor.
|
|
250
|
+
|
|
251
|
+
### Existing tests that stay as-is
|
|
252
|
+
|
|
253
|
+
- Menu structure tests (shows options, reload, running agents) — these test the orchestrator directly.
|
|
254
|
+
- Settings delegation tests — these test code that remains in `agent-menu.ts`.
|
|
255
|
+
|
|
256
|
+
## TDD Order
|
|
257
|
+
|
|
258
|
+
### Cycle 1: AgentFileOps interface and FsAgentFileOps
|
|
259
|
+
|
|
260
|
+
1. `test:` write `test/ui/agent-file-ops.test.ts` — tests for `read` (existing file, missing file), `write` (ensures parent dir), `remove`, `exists`, `ensureDir`, `findAgentFile` (first-match ordering, no match).
|
|
261
|
+
2. `feat:` implement `src/ui/agent-file-ops.ts` — interface + `FsAgentFileOps` class.
|
|
262
|
+
3. Commit: `feat: add AgentFileOps interface and FsAgentFileOps (#136)`.
|
|
263
|
+
|
|
264
|
+
### Cycle 2: Extract agent-config-editor
|
|
265
|
+
|
|
266
|
+
1. `test:` write `test/ui/agent-config-editor.test.ts` — tests for `showAgentDetail` transitions: edit (save/cancel), delete (confirm/cancel), reset-to-default (confirm/cancel), eject (project/personal location, overwrite check), disable (existing file toggle, new disable-only file), enable (remove enabled:false line, remove empty override file).
|
|
267
|
+
All tests inject stub `AgentFileOps` — no `vi.mock`.
|
|
268
|
+
2. `refactor:` create `src/ui/agent-config-editor.ts` — move `showAgentDetail`, `ejectAgent`, `disableAgent`, `enableAgent` from `agent-menu.ts`.
|
|
269
|
+
Replace direct fs calls with `deps.fileOps.*` calls.
|
|
270
|
+
Replace the closure `findAgentFile` with `deps.fileOps.findAgentFile(name, [deps.projectAgentsDir, deps.personalAgentsDir])`.
|
|
271
|
+
3. `refactor:` update `src/ui/agent-menu.ts` — add `fileOps` to `AgentMenuDeps`, import `createAgentConfigEditor`, wire into `showAllAgentsList`.
|
|
272
|
+
4. `refactor:` update `src/index.ts` — import `FsAgentFileOps`, pass `fileOps: new FsAgentFileOps()` in deps.
|
|
273
|
+
5. `test:` update `test/ui/agent-menu.test.ts` — add `fileOps` stub to `makeDeps`, rewrite the "projectAgentsDir injection" test to verify orchestrator→editor delegation.
|
|
274
|
+
6. Run `pnpm run check` (interface change).
|
|
275
|
+
7. Commit: `refactor: extract agent-config-editor from agent-menu (#136)`.
|
|
276
|
+
|
|
277
|
+
### Cycle 3: Extract agent-creation-wizard
|
|
278
|
+
|
|
279
|
+
1. `test:` write `test/ui/agent-creation-wizard.test.ts` — tests for `showCreateWizard` (location + method selection), `showGenerateWizard` (spawn success, spawn error, overwrite check), `showManualWizard` (full form flow, overwrite check, tool/model/thinking selections).
|
|
280
|
+
All tests inject stub `AgentFileOps` + `AgentMenuManager` — no `vi.mock`.
|
|
281
|
+
2. `refactor:` create `src/ui/agent-creation-wizard.ts` — move `showCreateWizard`, `showGenerateWizard`, `showManualWizard` from `agent-menu.ts`.
|
|
282
|
+
Replace direct fs calls with `deps.fileOps.*` calls.
|
|
283
|
+
3. `refactor:` update `src/ui/agent-menu.ts` — import `createAgentCreationWizard`, wire into `showAgentsMenu`.
|
|
284
|
+
Remove the `import { ... } from "node:fs"` line (no longer needed).
|
|
285
|
+
4. `test:` remove `vi.mock("node:fs")` and `vi.hoisted` stubs from `test/ui/agent-menu.test.ts` entirely.
|
|
286
|
+
5. Run `pnpm run check`.
|
|
287
|
+
6. Commit: `refactor: extract agent-creation-wizard from agent-menu (#136)`.
|
|
288
|
+
|
|
289
|
+
## Risks and Mitigations
|
|
290
|
+
|
|
291
|
+
| Risk | Mitigation |
|
|
292
|
+
| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
293
|
+
| Circular dependency between orchestrator and editor (editor calling back to menu) | The editor's `showAgentDetail` does not recurse to the agent list — the orchestrator does (`showAllAgentsList` calls editor then recurses itself). No circular dependency. |
|
|
294
|
+
| `FsAgentFileOps.write` silently creates parent directories when callers expect the directory to not exist | `write` mirrors current behavior — every call site already calls `mkdirSync` before `writeFileSync`. The consolidation only changes where the `mkdirSync` happens, not whether it runs. |
|
|
295
|
+
| Adding `fileOps` to `AgentMenuDeps` breaks existing test factory | Cycle 2 updates `makeDeps` in the same commit — the interface change and call-site update land together per the testing skill's single-call-site rule. |
|
|
296
|
+
| Generate wizard spawns an agent that writes via Pi tools, not via `AgentFileOps.write` | The wizard uses `fileOps.ensureDir` for directory creation and `fileOps.exists` for the post-spawn success check. The spawned agent's file write is outside the menu's control and is not abstracted. |
|
|
297
|
+
|
|
298
|
+
## Open Questions
|
|
299
|
+
|
|
300
|
+
- None — the issue's proposed changes section is unambiguous and the prerequisite (#135) is already implemented.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 135
|
|
3
|
+
issue_title: "Extract display helpers from `agent-widget.ts`"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #135 — Extract display helpers from agent-widget.ts
|
|
7
|
+
|
|
8
|
+
## Final Retrospective (2026-05-22T19:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Extracted 11 helper functions, 3 constants, and 2 types from `agent-widget.ts` into a new `ui/display.ts` module.
|
|
13
|
+
Updated 10 source consumers and 2 test consumers.
|
|
14
|
+
Pure code-motion refactoring — no behavior change, no test-count delta (714 tests throughout).
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
#### What went well
|
|
19
|
+
|
|
20
|
+
- Plan accurately identified all 10 source and 2 test import sites with no misses — the consumer import table in the plan mapped 1:1 to actual changes.
|
|
21
|
+
- TDD execution was mechanical and smooth: create module → update source imports → rename test file → verify.
|
|
22
|
+
Zero surprises or deviations from the plan.
|
|
23
|
+
|
|
24
|
+
#### What caused friction (agent side)
|
|
25
|
+
|
|
26
|
+
- `rabbit-hole` — During `/ship-issue`, wasted ~6 tool calls investigating whether `pi-subagents-v6.12.0` was at HEAD.
|
|
27
|
+
Ran `git log --oneline HEAD --not --remotes=origin/main` which dumped the entire repo history (50KB truncation), then misread `git describe --tags --abbrev=0` (nearest ancestor tag) as confirming the tag was at HEAD.
|
|
28
|
+
`git tag --points-at HEAD` returned empty, disproving the assumption, but I still spent cycles reasoning about CI release-please behavior.
|
|
29
|
+
Impact: added friction but no rework — the close comment and release-please merge were correct.
|
|
30
|
+
|
|
31
|
+
#### What caused friction (user side)
|
|
32
|
+
|
|
33
|
+
- None observed.
|
|
34
|
+
The issue was unambiguous, the architecture doc prescribed the exact extraction set, and no user intervention was needed during implementation.
|
|
35
|
+
|
|
36
|
+
### Changes made
|
|
37
|
+
|
|
38
|
+
1. Created `packages/pi-subagents/docs/retro/0135-extract-display-helpers.md` (this file).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 136
|
|
3
|
+
issue_title: "Decompose `agent-menu.ts`"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #136 — Decompose `agent-menu.ts`
|
|
7
|
+
|
|
8
|
+
## Final Retrospective (2026-05-22T20:10:00-04:00)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Decomposed `agent-menu.ts` (668 lines) into four focused modules: `agent-file-ops.ts`, `agent-config-editor.ts`, `agent-creation-wizard.ts`, and a slimmed-down `agent-menu.ts` (296 lines).
|
|
13
|
+
Three TDD cycles shipped cleanly, adding 47 tests (714 → 761) and eliminating `vi.mock("node:fs")` from the menu test file.
|
|
14
|
+
Released as `pi-subagents-v6.13.0`.
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
#### What went well
|
|
19
|
+
|
|
20
|
+
- The three-cycle TDD plan (file-ops → config-editor → creation-wizard) produced clean incremental commits with no rework.
|
|
21
|
+
Each cycle left the repo green for both tests and type-check.
|
|
22
|
+
- The creation wizard naturally produced narrower interfaces (`WizardManager`, `WizardRegistry`) than the plan specified — a positive deviation from the plan's `AgentTypeRegistry` concrete type, following ISP more strictly.
|
|
23
|
+
- The large edit removing ~170 lines of extracted functions from `agent-menu.ts` in Cycle 2 landed correctly on the first attempt, thanks to using exact `oldText` matching with the full function bodies.
|
|
24
|
+
|
|
25
|
+
#### What caused friction (agent side)
|
|
26
|
+
|
|
27
|
+
- `missing-context` — The config-editor test factory used `Partial<AgentConfigEditorDeps>` for the overrides parameter.
|
|
28
|
+
The `...overrides` spread created a union type that erased `Mock<...>` methods from `fileOps`, producing 28 `TS2339` errors on `pnpm run check`.
|
|
29
|
+
The testing skill warns about return-type annotations but not about `Partial<Interface>` in overrides — the same erasure mechanism applies through a different path.
|
|
30
|
+
Impact: one extra edit-check cycle to remove the `Partial<>` annotation and overrides parameter.
|
|
31
|
+
Self-identified (caught on `pnpm run check` before commit).
|
|
32
|
+
|
|
33
|
+
- `missing-context` — The config-editor test had an unused `ctx` variable from an earlier draft of the "disable-only file" test, caught only by the linter after the Cycle 3 commit.
|
|
34
|
+
Impact: required amending the Cycle 3 commit; added friction but no rework.
|
|
35
|
+
Self-identified (caught by `pnpm run lint`).
|
|
36
|
+
|
|
37
|
+
#### What caused friction (user side)
|
|
38
|
+
|
|
39
|
+
- None observed — the user's plan was unambiguous and the prerequisite (#135) was already implemented.
|
|
40
|
+
|
|
41
|
+
### Changes made
|
|
42
|
+
|
|
43
|
+
1. Added a `Partial<Interface>` type-erasure bullet to `.pi/skills/testing/SKILL.md`.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { createAgentTool } from "./tools/agent-tool.js";
|
|
|
41
41
|
import { createGetResultTool } from "./tools/get-result-tool.js";
|
|
42
42
|
import { getModelLabelFromConfig } from "./tools/helpers.js";
|
|
43
43
|
import { createSteerTool } from "./tools/steer-tool.js";
|
|
44
|
+
import { FsAgentFileOps } from "./ui/agent-file-ops.js";
|
|
44
45
|
import { createAgentsMenuHandler } from "./ui/agent-menu.js";
|
|
45
46
|
import {
|
|
46
47
|
AgentWidget,
|
|
@@ -248,6 +249,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
248
249
|
return getModelLabelFromConfig(cfg.model);
|
|
249
250
|
},
|
|
250
251
|
settings,
|
|
252
|
+
fileOps: new FsAgentFileOps(),
|
|
251
253
|
personalAgentsDir: join(getAgentDir(), 'agents'),
|
|
252
254
|
projectAgentsDir: join(process.cwd(), '.pi', 'agents'),
|
|
253
255
|
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-config-editor.ts — Agent detail view with edit/delete/eject/disable/enable transitions.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from agent-menu.ts to give each concern a single responsibility.
|
|
5
|
+
* Receives dependencies via injection — no direct `node:fs` imports.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { AgentTypeRegistry } from "../agent-types.js";
|
|
12
|
+
import type { AgentConfig } from "../types.js";
|
|
13
|
+
import type { AgentFileOps } from "./agent-file-ops.js";
|
|
14
|
+
|
|
15
|
+
// ---- Deps interface ----
|
|
16
|
+
|
|
17
|
+
export interface AgentConfigEditorDeps {
|
|
18
|
+
fileOps: AgentFileOps;
|
|
19
|
+
registry: AgentTypeRegistry;
|
|
20
|
+
personalAgentsDir: string;
|
|
21
|
+
projectAgentsDir: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---- Factory ----
|
|
25
|
+
|
|
26
|
+
export function createAgentConfigEditor(deps: AgentConfigEditorDeps) {
|
|
27
|
+
function agentDirs(): string[] {
|
|
28
|
+
return [deps.projectAgentsDir, deps.personalAgentsDir];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function showAgentDetail(ctx: ExtensionContext, name: string) {
|
|
32
|
+
if (deps.registry.resolveType(name) == null) {
|
|
33
|
+
ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const cfg = deps.registry.resolveAgentConfig(name);
|
|
37
|
+
|
|
38
|
+
const file = deps.fileOps.findAgentFile(name, agentDirs());
|
|
39
|
+
const isDefault = cfg.isDefault === true;
|
|
40
|
+
const disabled = cfg.enabled === false;
|
|
41
|
+
|
|
42
|
+
let menuOptions: string[];
|
|
43
|
+
if (disabled && file) {
|
|
44
|
+
menuOptions = isDefault
|
|
45
|
+
? ["Enable", "Edit", "Reset to default", "Delete", "Back"]
|
|
46
|
+
: ["Enable", "Edit", "Delete", "Back"];
|
|
47
|
+
} else if (isDefault && !file) {
|
|
48
|
+
menuOptions = ["Eject (export as .md)", "Disable", "Back"];
|
|
49
|
+
} else if (isDefault && file) {
|
|
50
|
+
menuOptions = ["Edit", "Disable", "Reset to default", "Delete", "Back"];
|
|
51
|
+
} else {
|
|
52
|
+
menuOptions = ["Edit", "Disable", "Delete", "Back"];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const choice = await ctx.ui.select(name, menuOptions);
|
|
56
|
+
if (!choice || choice === "Back") return;
|
|
57
|
+
|
|
58
|
+
if (choice === "Edit" && file) {
|
|
59
|
+
const content = deps.fileOps.read(file);
|
|
60
|
+
if (content !== undefined) {
|
|
61
|
+
const edited = await ctx.ui.editor(`Edit ${name}`, content);
|
|
62
|
+
if (edited !== undefined && edited !== content) {
|
|
63
|
+
deps.fileOps.write(file, edited);
|
|
64
|
+
deps.registry.reload();
|
|
65
|
+
ctx.ui.notify(`Updated ${file}`, "info");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} else if (choice === "Delete") {
|
|
69
|
+
if (file) {
|
|
70
|
+
const confirmed = await ctx.ui.confirm(
|
|
71
|
+
"Delete agent",
|
|
72
|
+
`Delete ${name} (${file})?`,
|
|
73
|
+
);
|
|
74
|
+
if (confirmed) {
|
|
75
|
+
deps.fileOps.remove(file);
|
|
76
|
+
deps.registry.reload();
|
|
77
|
+
ctx.ui.notify(`Deleted ${file}`, "info");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} else if (choice === "Reset to default" && file) {
|
|
81
|
+
const confirmed = await ctx.ui.confirm(
|
|
82
|
+
"Reset to default",
|
|
83
|
+
`Delete override ${file} and restore embedded default?`,
|
|
84
|
+
);
|
|
85
|
+
if (confirmed) {
|
|
86
|
+
deps.fileOps.remove(file);
|
|
87
|
+
deps.registry.reload();
|
|
88
|
+
ctx.ui.notify(`Restored default ${name}`, "info");
|
|
89
|
+
}
|
|
90
|
+
} else if (choice.startsWith("Eject")) {
|
|
91
|
+
await ejectAgent(ctx, name, cfg);
|
|
92
|
+
} else if (choice === "Disable") {
|
|
93
|
+
await disableAgent(ctx, name);
|
|
94
|
+
} else if (choice === "Enable") {
|
|
95
|
+
await enableAgent(ctx, name);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function ejectAgent(ctx: ExtensionContext, name: string, cfg: AgentConfig) {
|
|
100
|
+
const location = await ctx.ui.select("Choose location", [
|
|
101
|
+
"Project (.pi/agents/)",
|
|
102
|
+
`Personal (${deps.personalAgentsDir})`,
|
|
103
|
+
]);
|
|
104
|
+
if (!location) return;
|
|
105
|
+
|
|
106
|
+
const targetDir = location.startsWith("Project")
|
|
107
|
+
? deps.projectAgentsDir
|
|
108
|
+
: deps.personalAgentsDir;
|
|
109
|
+
|
|
110
|
+
const targetPath = join(targetDir, `${name}.md`);
|
|
111
|
+
if (deps.fileOps.exists(targetPath)) {
|
|
112
|
+
const overwrite = await ctx.ui.confirm(
|
|
113
|
+
"Overwrite",
|
|
114
|
+
`${targetPath} already exists. Overwrite?`,
|
|
115
|
+
);
|
|
116
|
+
if (!overwrite) return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const fmFields: string[] = [];
|
|
120
|
+
fmFields.push(`description: ${cfg.description}`);
|
|
121
|
+
if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`);
|
|
122
|
+
fmFields.push(`tools: ${cfg.builtinToolNames?.join(", ") || "all"}`);
|
|
123
|
+
if (cfg.model) fmFields.push(`model: ${cfg.model}`);
|
|
124
|
+
if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`);
|
|
125
|
+
if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`);
|
|
126
|
+
fmFields.push(`prompt_mode: ${cfg.promptMode}`);
|
|
127
|
+
if (cfg.extensions === false) fmFields.push("extensions: false");
|
|
128
|
+
else if (Array.isArray(cfg.extensions))
|
|
129
|
+
fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
|
|
130
|
+
if (cfg.skills === false) fmFields.push("skills: false");
|
|
131
|
+
else if (Array.isArray(cfg.skills))
|
|
132
|
+
fmFields.push(`skills: ${cfg.skills.join(", ")}`);
|
|
133
|
+
if (cfg.disallowedTools?.length)
|
|
134
|
+
fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
|
|
135
|
+
if (cfg.inheritContext) fmFields.push("inherit_context: true");
|
|
136
|
+
if (cfg.runInBackground) fmFields.push("run_in_background: true");
|
|
137
|
+
if (cfg.isolated) fmFields.push("isolated: true");
|
|
138
|
+
if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`);
|
|
139
|
+
if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`);
|
|
140
|
+
|
|
141
|
+
const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
|
|
142
|
+
|
|
143
|
+
deps.fileOps.write(targetPath, content);
|
|
144
|
+
deps.registry.reload();
|
|
145
|
+
ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function disableAgent(ctx: ExtensionContext, name: string) {
|
|
149
|
+
const file = deps.fileOps.findAgentFile(name, agentDirs());
|
|
150
|
+
if (file) {
|
|
151
|
+
const content = deps.fileOps.read(file);
|
|
152
|
+
if (content?.includes("\nenabled: false\n")) {
|
|
153
|
+
ctx.ui.notify(`${name} is already disabled.`, "info");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (content) {
|
|
157
|
+
const updated = content.replace(/^---\n/, "---\nenabled: false\n");
|
|
158
|
+
deps.fileOps.write(file, updated);
|
|
159
|
+
deps.registry.reload();
|
|
160
|
+
ctx.ui.notify(`Disabled ${name} (${file})`, "info");
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const location = await ctx.ui.select("Choose location", [
|
|
166
|
+
"Project (.pi/agents/)",
|
|
167
|
+
`Personal (${deps.personalAgentsDir})`,
|
|
168
|
+
]);
|
|
169
|
+
if (!location) return;
|
|
170
|
+
|
|
171
|
+
const targetDir = location.startsWith("Project")
|
|
172
|
+
? deps.projectAgentsDir
|
|
173
|
+
: deps.personalAgentsDir;
|
|
174
|
+
|
|
175
|
+
const targetPath = join(targetDir, `${name}.md`);
|
|
176
|
+
deps.fileOps.write(targetPath, "---\nenabled: false\n---\n");
|
|
177
|
+
deps.registry.reload();
|
|
178
|
+
ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function enableAgent(ctx: ExtensionContext, name: string) {
|
|
182
|
+
const file = deps.fileOps.findAgentFile(name, agentDirs());
|
|
183
|
+
if (!file) return;
|
|
184
|
+
|
|
185
|
+
const content = deps.fileOps.read(file);
|
|
186
|
+
if (!content) return;
|
|
187
|
+
|
|
188
|
+
const updated = content.replace(/^(---\n)enabled: false\n/, "$1");
|
|
189
|
+
|
|
190
|
+
if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") {
|
|
191
|
+
deps.fileOps.remove(file);
|
|
192
|
+
deps.registry.reload();
|
|
193
|
+
ctx.ui.notify(`Enabled ${name} (removed ${file})`, "info");
|
|
194
|
+
} else {
|
|
195
|
+
deps.fileOps.write(file, updated);
|
|
196
|
+
deps.registry.reload();
|
|
197
|
+
ctx.ui.notify(`Enabled ${name} (${file})`, "info");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { showAgentDetail };
|
|
202
|
+
}
|