@gotgenes/pi-subagents 6.9.0 → 6.9.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 CHANGED
@@ -5,6 +5,15 @@ 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.9.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.9.0...pi-subagents-v6.9.1) (2026-05-22)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * plan decompose agent-tool.ts into foreground/background modules ([#115](https://github.com/gotgenes/pi-packages/issues/115)) ([4b9aefb](https://github.com/gotgenes/pi-packages/commit/4b9aefb18fe0431a033d90f10bcbe7d37c53a6b8))
14
+ * **retro:** add retro notes for issue [#114](https://github.com/gotgenes/pi-packages/issues/114) ([e6095e7](https://github.com/gotgenes/pi-packages/commit/e6095e7465f482ac18756305b9740f1101dbd41a))
15
+ * update architecture for E1 agent-tool decomposition ([#115](https://github.com/gotgenes/pi-packages/issues/115)) ([8bccf0a](https://github.com/gotgenes/pi-packages/commit/8bccf0ab90787d906c82c5a362c0763d3b7bd2f5))
16
+
8
17
  ## [6.9.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.8.3...pi-subagents-v6.9.0) (2026-05-22)
9
18
 
10
19
 
@@ -54,10 +54,12 @@ settings.ts — persistent operational settings; `SettingsManager`
54
54
  service.ts — SubagentsService interface + Symbol.for() accessors
55
55
  service-adapter.ts — SubagentsService implementation wrapping AgentManager
56
56
 
57
- tools/agent-tool.ts — Agent tool definition + execute
58
- tools/get-result-tool.ts get_subagent_result tool
59
- tools/steer-tool.ts steer_subagent tool
60
- tools/helpers.ts shared tool utilities
57
+ tools/agent-tool.ts — Agent tool definition, parameter validation, dispatch
58
+ tools/foreground-runner.ts foreground execution loop (spinner, streaming, result)
59
+ tools/background-spawner.ts background spawn (activity setup, notification wiring)
60
+ tools/get-result-tool.ts get_subagent_result tool
61
+ tools/steer-tool.ts — steer_subagent tool
62
+ tools/helpers.ts — shared tool utilities (textResult, buildDetails, getStatusNote, …)
61
63
 
62
64
  handlers/lifecycle.ts — session_start, session_before_switch, session_shutdown
63
65
  handlers/tool-start.ts — tool_execution_start handler
@@ -259,10 +261,12 @@ src/
259
261
  ├── index.ts — slimmed entry point: init, tool registration
260
262
  ├── runtime.ts — SubagentRuntime: session-scoped state + methods
261
263
  ├── tools/
262
- │ ├── agent-tool.ts — Agent tool definition + execute
264
+ │ ├── agent-tool.ts — Agent tool definition, parameter validation, dispatch
265
+ │ ├── foreground-runner.ts — foreground execution loop (spinner, streaming, result)
266
+ │ ├── background-spawner.ts — background spawn (activity setup, notification wiring)
263
267
  │ ├── get-result-tool.ts — get_subagent_result tool
264
268
  │ ├── steer-tool.ts — steer_subagent tool
265
- │ └── helpers.ts — shared tool utilities
269
+ │ └── helpers.ts — shared tool utilities (textResult, buildDetails, getStatusNote, …)
266
270
  ├── handlers/
267
271
  │ ├── lifecycle.ts — session_start, session_before_switch, session_shutdown
268
272
  │ └── tool-start.ts — tool_execution_start handler
@@ -437,7 +441,7 @@ Split post-construction mutation into phase-specific collaborators, each born co
437
441
 
438
442
  - **`ExecutionState`** (`session`, `outputFile`) — constructed in `onSessionCreated`, attached as `record.execution`.
439
443
  - **`WorktreeState`** (`path`, `branch`, `cleanupResult`) — constructed at worktree setup, attached as `record.worktreeState`.
440
- - **`NotificationState`** (`toolCallId`, `resultConsumed`) — constructed by agent-tool after spawn, attached as `record.notification`.
444
+ - **`NotificationState`** (`toolCallId`, `resultConsumed`) — constructed by `AgentManager.spawn()` when `toolCallId` is provided in `AgentSpawnConfig`, attached as `record.notification`.
441
445
  - **`pendingSteers`** moved to `Map<string, string[]>` on `AgentManager`; steer-tool and service-adapter call `manager.queueSteer()`.
442
446
  - Stats (`toolUses`, `lifetimeUsage`, `compactionCount`) encapsulated behind mutation methods (`incrementToolUses`, `addUsage`, `incrementCompactions`) with read-only getters.
443
447
  - `AgentRecordInit` trimmed from 19 optional fields to 4 construction-time fields.
@@ -476,10 +480,12 @@ Public `SpawnOptions` in `service.ts` is unchanged.
476
480
 
477
481
  ### Step E: Decompose large files and relocate types (parallel)
478
482
 
479
- #### E1. Split `agent-tool.ts` foreground/background (#115)
483
+ #### E1. Split `agent-tool.ts` foreground/background (#115)
480
484
 
481
- Extract the foreground execution loop (spinner, streaming, result rendering) and background spawn path into separate modules.
482
- The 654-line file splits along a natural seam.
485
+ **Done.**
486
+ Fixed two upstream API gaps before extracting: `onSessionCreated` now receives `(session, record)` (eliminating a `listAgents()` reverse-search), and `AgentSpawnConfig` accepts `toolCallId` (moving `NotificationState` wiring into `AgentManager.spawn()`).
487
+ Extracted `foreground-runner.ts` (~175 lines) and `background-spawner.ts` (~116 lines).
488
+ `agent-tool.ts` reduced from 579 → 411 lines (orchestrator + rendering).
483
489
 
484
490
  #### E2. Type housekeeping (#116)
485
491
 
@@ -0,0 +1,337 @@
1
+ ---
2
+ issue: 115
3
+ issue_title: "refactor(pi-subagents): decompose agent-tool.ts into foreground/background modules"
4
+ ---
5
+
6
+ # Decompose agent-tool.ts into foreground/background modules
7
+
8
+ ## Problem Statement
9
+
10
+ `tools/agent-tool.ts` is the largest file in the package at 579 lines.
11
+ The `execute` function handles three distinct execution paths — resume, background spawn, and foreground streaming — each with different dependencies.
12
+ Before those paths can be cleanly extracted, two upstream API gaps force the tool to work around the manager:
13
+
14
+ 1. The foreground `onSessionCreated` callback loops through `manager.listAgents()` matching by session object just to discover the agent's ID — because `onSessionCreated` only receives the session, not the record.
15
+ 2. The background path mutates `record.notification` after spawn — reaching into the record returned by `getRecord()` to attach a `NotificationState` — because the manager has no way to wire notification state at spawn time.
16
+
17
+ These workarounds would simply move into the extracted modules unchanged.
18
+ Fixing the API gaps first makes the extraction clean: each extracted module receives what it actually needs from the manager, without reaching through or reverse-searching.
19
+
20
+ ## Goals
21
+
22
+ - Widen `onSessionCreated` to `(session, record)` so callers receive the agent ID and record directly.
23
+ - Accept `toolCallId` in `AgentSpawnConfig` so the manager wires `record.notification` internally for background agents.
24
+ - Extract the foreground execution loop into `tools/foreground-runner.ts`.
25
+ - Extract the background spawn path into `tools/background-spawner.ts`.
26
+ - Move `getStatusNote` and `buildDetails` to `tools/helpers.ts`.
27
+ - Keep `agent-tool.ts` as the orchestrator (~250 lines): tool definition, parameter validation, shared setup, dispatch, resume.
28
+ - Preserve all existing behavior.
29
+
30
+ ## Non-Goals
31
+
32
+ - Extracting the resume path (~27 lines) — too small to warrant a separate file.
33
+ - Extracting `renderCall`/`renderResult` — tightly coupled to the tool definition.
34
+ - Changing `AgentToolDeps` shape — #114 already narrowed it.
35
+ - Removing `onSessionCreated` from `AgentSpawnConfig` entirely — it is still useful for UI observer wiring that the manager should not own.
36
+
37
+ ## Background
38
+
39
+ ### Prerequisite status
40
+
41
+ | Issue | Title | Status |
42
+ | ----- | ------------------------------------------ | ------- |
43
+ | #114 | Narrow `AgentToolDeps` and `AgentMenuDeps` | ✅ Done |
44
+
45
+ ### Current API gaps
46
+
47
+ #### Gap 1: foreground ID discovery
48
+
49
+ The foreground path needs the agent ID *during* execution (inside `onSessionCreated`, before `spawnAndWait` resolves) to register the activity tracker in the widget.
50
+ The manager's internal `onSessionCreated` handler already has `id` and `record` in scope but passes only `session` to the caller's callback.
51
+ The tool works around this by iterating `listAgents()` and matching by session identity:
52
+
53
+ ```typescript
54
+ onSessionCreated: (session) => {
55
+ for (const a of deps.manager.listAgents()) {
56
+ if (a.execution?.session === session) {
57
+ fgId = a.id;
58
+ deps.agentActivity.set(a.id, fgState);
59
+ // ...
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ This is a violation of Tell-Don't-Ask: the tool asks the manager for data it already has.
66
+
67
+ #### Gap 2: post-spawn notification mutation
68
+
69
+ The background path calls `manager.spawn()`, then immediately calls `manager.getRecord(id)` to mutate `record.notification`:
70
+
71
+ ```typescript
72
+ const id = deps.manager.spawn(ctx, subagentType, prompt, { ... });
73
+ const record = deps.manager.getRecord(id);
74
+ if (record) {
75
+ record.notification = new NotificationState(toolCallId);
76
+ }
77
+ ```
78
+
79
+ This is an output argument — the tool writes back into a record it doesn't own.
80
+ The notification could be wired at spawn time if the manager accepted a `toolCallId`.
81
+
82
+ ### Relevant design principles
83
+
84
+ - **Tell-Don't-Ask** (code-design skill): the `listAgents()` loop asks the manager for data it already has.
85
+ - **Output arguments** (code-design skill): writing `record.notification` after spawn mutates an object owned by the manager.
86
+ - **SRP**: foreground streaming and background spawning are independent concerns.
87
+ - **One concern per file** (AGENTS.md): the file mixes orchestration, streaming, spawning, and formatting.
88
+
89
+ ## Design Overview
90
+
91
+ ### Phase 1: Fix manager API gaps
92
+
93
+ #### Widen `onSessionCreated` to include record
94
+
95
+ Change the callback signature in both `AgentSpawnConfig` and the runner's `RunOptions`:
96
+
97
+ ```typescript
98
+ // agent-manager.ts — AgentSpawnConfig
99
+ onSessionCreated?: (session: AgentSession, record: AgentRecord) => void;
100
+ ```
101
+
102
+ The manager's internal handler already has `record` in scope — pass it through:
103
+
104
+ ```typescript
105
+ // In startAgent(), existing line:
106
+ options.onSessionCreated?.(session);
107
+ // Becomes:
108
+ options.onSessionCreated?.(session, record);
109
+ ```
110
+
111
+ The runner's `onSessionCreated` stays `(session: AgentSession) => void` — it doesn't know about records.
112
+ The manager wraps the runner callback and adds `record` before forwarding to the caller.
113
+
114
+ This lets the foreground tool callback access `record.id` directly, eliminating the `listAgents()` loop.
115
+
116
+ #### Accept `toolCallId` in `AgentSpawnConfig`
117
+
118
+ Add an optional `toolCallId` field to `AgentSpawnConfig`:
119
+
120
+ ```typescript
121
+ export interface AgentSpawnConfig {
122
+ // ... existing fields ...
123
+ /** Tool call ID for background notification wiring. When set, spawn attaches NotificationState. */
124
+ toolCallId?: string;
125
+ }
126
+ ```
127
+
128
+ In `AgentManager.spawn()`, after creating the record:
129
+
130
+ ```typescript
131
+ if (options.toolCallId) {
132
+ record.notification = new NotificationState(options.toolCallId);
133
+ }
134
+ ```
135
+
136
+ This moves the notification wiring into the manager, eliminating the post-spawn mutation in the tool.
137
+
138
+ ### Phase 2: Extract modules
139
+
140
+ With the API gaps fixed, the extracted modules no longer need `listAgents` or `getRecord` or post-spawn record mutation.
141
+
142
+ #### Foreground runner
143
+
144
+ After the `onSessionCreated` widening, the foreground callback simplifies to:
145
+
146
+ ```typescript
147
+ onSessionCreated: (session, record) => {
148
+ fgState.setSession(session);
149
+ unsubUI = subscribeUIObserver(session, fgState, streamUpdate);
150
+ fgId = record.id;
151
+ deps.agentActivity.set(record.id, fgState);
152
+ deps.widget.ensureTimer();
153
+ }
154
+ ```
155
+
156
+ The `runForeground` function receives narrow deps:
157
+
158
+ ```typescript
159
+ export interface ForegroundDeps {
160
+ manager: { spawnAndWait: AgentToolManager["spawnAndWait"] };
161
+ widget: { ensureTimer(): void; markFinished(id: string): void };
162
+ agentActivity: AgentActivityAccess;
163
+ }
164
+ ```
165
+
166
+ No `listAgents` needed — the record is delivered by the callback.
167
+
168
+ #### Background spawner
169
+
170
+ After the `toolCallId` change, the background path simplifies to:
171
+
172
+ ```typescript
173
+ const id = deps.manager.spawn(ctx, subagentType, prompt, {
174
+ ...spawnConfig,
175
+ toolCallId,
176
+ });
177
+ // No getRecord + mutation needed — notification already wired
178
+ ```
179
+
180
+ The `spawnBackground` function receives narrow deps:
181
+
182
+ ```typescript
183
+ export interface BackgroundDeps {
184
+ manager: { spawn: AgentToolManager["spawn"]; getRecord: AgentToolManager["getRecord"]; getMaxConcurrent: AgentToolManager["getMaxConcurrent"] };
185
+ widget: { ensureTimer(): void; update(): void };
186
+ agentActivity: AgentActivityAccess;
187
+ }
188
+ ```
189
+
190
+ `getRecord` is still needed for building the result message (checking `status`, `execution.outputFile`), but not for mutation.
191
+
192
+ #### What stays in agent-tool.ts
193
+
194
+ - `AgentToolDeps`, `AgentToolManager`, `AgentToolWidget`, `AgentActivityAccess` interfaces.
195
+ - `createAgentTool` factory: tool name/label/description, parameters schema, `renderCall`, `renderResult`.
196
+ - Execute's shared setup: registry reload, type resolution, model resolution, config assembly, detail base.
197
+ - Resume path (~27 lines).
198
+ - Dispatch to `spawnBackground()` or `runForeground()`.
199
+
200
+ #### Helpers relocation
201
+
202
+ `getStatusNote` and `buildDetails` move to `tools/helpers.ts`.
203
+ Both are pure formatting functions with no dependency on `AgentToolDeps`.
204
+
205
+ ### Post-extraction file sizes (estimated)
206
+
207
+ | File | Lines |
208
+ | ----------------------- | -------------- |
209
+ | `agent-tool.ts` | ~250 (was 579) |
210
+ | `foreground-runner.ts` | ~110 |
211
+ | `background-spawner.ts` | ~70 |
212
+ | `helpers.ts` additions | ~50 |
213
+
214
+ ## Module-Level Changes
215
+
216
+ ### Modified files
217
+
218
+ 1. **`src/agent-manager.ts`**
219
+ - Change `onSessionCreated` in `AgentSpawnConfig` to `(session: AgentSession, record: AgentRecord) => void`.
220
+ - Pass `record` as second argument in `startAgent`'s internal `onSessionCreated` call.
221
+ - Add optional `toolCallId?: string` to `AgentSpawnConfig`.
222
+ - Wire `record.notification = new NotificationState(options.toolCallId)` in `spawn()` when present.
223
+ - Add `NotificationState` import.
224
+
225
+ 2. **`src/tools/agent-tool.ts`**
226
+ - Update `onSessionCreated` callbacks to accept `(session, record)`.
227
+ - Remove `listAgents()` loop in foreground callback — use `record.id` directly.
228
+ - Pass `toolCallId` in background spawn config — remove post-spawn `getRecord` + mutation.
229
+ - Remove foreground block → `runForeground()` call.
230
+ - Remove background block → `spawnBackground()` call.
231
+ - Remove `getStatusNote`, `buildDetails` → imported from `helpers.ts`.
232
+ - Remove `listAgents` from `AgentToolManager` interface (no longer needed).
233
+ - Remove unused imports: `NotificationState`, `describeActivity`, `SPINNER`, `formatMs`.
234
+
235
+ 3. **`src/tools/helpers.ts`**
236
+ - Add `getStatusNote()` and `buildDetails()` (relocated from `agent-tool.ts`).
237
+
238
+ ### New files
239
+
240
+ 4. **`src/tools/foreground-runner.ts`**
241
+ - `ForegroundDeps` interface, `runForeground()` function.
242
+ - Owns: spinner interval, `AgentActivityTracker` creation, `subscribeUIObserver`, streaming `onUpdate`, cleanup, result formatting via `buildDetails`/`getStatusNote`.
243
+
244
+ 5. **`src/tools/background-spawner.ts`**
245
+ - `BackgroundDeps` interface, `spawnBackground()` function.
246
+ - Owns: `AgentActivityTracker` creation, `subscribeUIObserver`, activity map registration, widget update, launch message formatting.
247
+
248
+ ### Test files
249
+
250
+ 6. **`test/agent-manager.test.ts`**
251
+ - Update mock runner calls to pass `record` in `onSessionCreated`.
252
+ - Add test: `spawn` wires `NotificationState` when `toolCallId` is provided.
253
+ - Add test: `spawn` does not wire `NotificationState` when `toolCallId` is absent.
254
+
255
+ 7. **`test/tools/agent-tool.test.ts`**
256
+ - Update `onSessionCreated` mock signatures if needed (structural — tests call through `execute`).
257
+ - Existing tests remain as integration tests for the dispatch path.
258
+
259
+ 8. **`test/tools/helpers.test.ts`** (new or extended)
260
+ - Unit tests for `getStatusNote` (all status branches) and `buildDetails`.
261
+
262
+ 9. **`test/tools/foreground-runner.test.ts`** (new)
263
+ - Spinner lifecycle, streaming updates, cleanup on success/error, result formatting, fallback note.
264
+
265
+ 10. **`test/tools/background-spawner.test.ts`** (new)
266
+ - Activity tracker registered, widget updated, queued message, launch message format.
267
+
268
+ ## Test Impact Analysis
269
+
270
+ 1. **New unit tests enabled:**
271
+ - `foreground-runner.test.ts` tests spinner lifecycle and streaming with narrow mocks (no full `AgentToolDeps`).
272
+ - `background-spawner.test.ts` tests activity registration and message formatting in isolation.
273
+ - `helpers.test.ts` tests `getStatusNote` and `buildDetails` as pure functions.
274
+ - `agent-manager.test.ts` tests notification wiring at the manager level — moved from tool-level integration.
275
+
276
+ 2. **Existing tests that simplify:**
277
+ - `agent-tool.test.ts` background tests no longer need to verify notification wiring (now the manager's job).
278
+ - The "registers activity in agentActivity map" test stays but becomes a dispatch-level integration test.
279
+
280
+ 3. **Existing tests that must stay:**
281
+ - All `agent-tool.test.ts` tests exercise the full dispatch path and remain valuable as integration tests.
282
+ - All `agent-manager.test.ts` tests that fire `onSessionCreated` must update the mock signature to `(session, record)`.
283
+
284
+ ## TDD Order
285
+
286
+ 1. **Widen `onSessionCreated` callback to include record.**
287
+ Change `AgentSpawnConfig.onSessionCreated` signature to `(session, record)`.
288
+ Update `startAgent` to pass `record` as second argument.
289
+ Update `agent-tool.ts` foreground callback to use `record.id` instead of `listAgents()` loop.
290
+ Remove `listAgents` from `AgentToolManager` interface.
291
+ Update `agent-manager.test.ts` mock runner calls.
292
+ Test: verify foreground callback receives `record.id` (existing integration tests pass).
293
+ Commit: `refactor: widen onSessionCreated to include record`
294
+
295
+ 2. **Accept `toolCallId` in `AgentSpawnConfig`.**
296
+ Add `toolCallId?: string` to `AgentSpawnConfig`.
297
+ Wire `NotificationState` in `spawn()` when `toolCallId` is provided.
298
+ Update `agent-tool.ts` background path to pass `toolCallId` instead of post-spawn mutation.
299
+ Test: `agent-manager.test.ts` — `spawn` wires notification when `toolCallId` present, skips when absent.
300
+ Commit: `refactor: wire NotificationState at spawn time via toolCallId`
301
+
302
+ 3. **Relocate `getStatusNote` and `buildDetails` to `tools/helpers.ts`.**
303
+ Move both functions.
304
+ Update imports in `agent-tool.ts`.
305
+ Test: unit tests for `getStatusNote` (all branches) and `buildDetails`.
306
+ Commit: `refactor: move getStatusNote and buildDetails to tools/helpers`
307
+
308
+ 4. **Extract `spawnBackground` into `tools/background-spawner.ts`.**
309
+ Define `BackgroundDeps` interface and `spawnBackground()` function.
310
+ Replace background block in `execute` with a call to `spawnBackground()`.
311
+ Remove unused imports from `agent-tool.ts`.
312
+ Test: `background-spawner.test.ts` — activity registration, widget update, launch message.
313
+ Commit: `refactor: extract background spawn to tools/background-spawner`
314
+
315
+ 5. **Extract `runForeground` into `tools/foreground-runner.ts`.**
316
+ Define `ForegroundDeps` interface and `runForeground()` function.
317
+ Replace foreground block in `execute` with a call to `runForeground()`.
318
+ Remove unused imports from `agent-tool.ts`.
319
+ Test: `foreground-runner.test.ts` — spinner lifecycle, streaming, cleanup, result formatting.
320
+ Commit: `refactor: extract foreground execution to tools/foreground-runner`
321
+
322
+ 6. **Verify integration.**
323
+ Run full test suite and `pnpm run check`.
324
+ Commit: `test: verify agent-tool decomposition integration`
325
+
326
+ ## Risks and Mitigations
327
+
328
+ | Risk | Mitigation |
329
+ | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
330
+ | Widening `onSessionCreated` signature is a breaking change to `AgentSpawnConfig` | `AgentSpawnConfig` is internal (not in package `exports`). The only external caller is `agent-tool.ts`. All test mocks update in the same step. |
331
+ | `toolCallId` on `AgentSpawnConfig` couples the manager to notification concerns | The manager already owns the record lifecycle. `NotificationState` is a record collaborator like `execution` and `worktreeState` — the manager already wires those. `toolCallId` is a data-in, not a behavior coupling. |
332
+ | Runner's `onSessionCreated` signature stays `(session)` while manager's is `(session, record)` | The manager wraps the runner's callback — the runner never sees the record. No change to runner interface. |
333
+ | Circular imports between new modules and `helpers.ts` | `helpers.ts` is a leaf module. The new modules import from it but it imports nothing from them. |
334
+
335
+ ## Open Questions
336
+
337
+ None — the design is unambiguous after resolving the two API gaps.
@@ -0,0 +1,38 @@
1
+ ---
2
+ issue: 114
3
+ issue_title: "refactor(pi-subagents): narrow AgentToolDeps and AgentMenuDeps"
4
+ ---
5
+
6
+ # Retro: #114 — narrow AgentToolDeps and AgentMenuDeps
7
+
8
+ ## Final Retrospective (2026-05-21T21:43:48-04:00)
9
+
10
+ ### Session summary
11
+
12
+ Narrowed `AgentToolDeps` from 9 to 6 fields and `AgentMenuDeps` from 8 to 7 fields.
13
+ Moved `subagents:created` event emission from the Agent tool to a new `AgentManagerObserver.onAgentCreated` method.
14
+ Extracted `buildTypeListText` to `tools/helpers.ts`, derived description text inside `createAgentTool`, removed dead `emitEvent` from `AgentMenuDeps`, and narrowed `agentActivity` to typed `AgentActivityAccess`/`AgentActivityReader` interfaces.
15
+ Test count increased from 638 to 660.
16
+ Released as `pi-subagents-v6.9.0`.
17
+
18
+ ### Observations
19
+
20
+ #### What went well
21
+
22
+ - The `ask_user` gate during planning was well-targeted.
23
+ The first question (where to move `emitEvent`) had a clear answer.
24
+ The second (description-text derivation) genuinely needed user input, and the user requested more context via the "I could use more context" response — the follow-up `preview`-type question with fenced code blocks handled this cleanly.
25
+ - The 6-step TDD plan mapped to implementation with only one deviation (see below), caught exactly where the workflow is designed to catch it (the `pnpm run check` step).
26
+ - All 6 prerequisites (#108, #109, #110, #112, #113, #118) were verified as closed before planning.
27
+ The observer issue (#112) was correctly identified from a `gh issue list` grep despite not being explicitly numbered in the issue body (the issue said "the observer issue").
28
+
29
+ #### What caused friction (agent side)
30
+
31
+ - `missing-context` (self-identified) — Step 6 narrowed `agentActivity` from `Map<string, AgentActivityTracker>` to `AgentActivityAccess` (which exposes only `get`/`set`/`delete`), but the test in `agent-tool.test.ts` used `.has()` on the map.
32
+ The `pnpm run check` typecheck caught `Property 'has' does not exist on type 'AgentActivityAccess'`.
33
+ Fixed by replacing `.has(id)` with `.get(id) !== undefined` in the same commit.
34
+ Impact: one extra read + edit cycle (~30 seconds), no rework.
35
+
36
+ #### What caused friction (user side)
37
+
38
+ - Nothing — no user corrections or redirections needed during the session.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "6.9.0",
3
+ "version": "6.9.1",
4
4
  "exports": {
5
5
  ".": "./src/service.ts"
6
6
  },
@@ -13,6 +13,7 @@ import { AgentRecord } from "./agent-record.js";
13
13
  import type { AgentRunner } from "./agent-runner.js";
14
14
  import { AgentTypeRegistry } from "./agent-types.js";
15
15
  import { debugLog } from "./debug.js";
16
+ import { NotificationState } from "./notification-state.js";
16
17
  import { buildParentSnapshot } from "./parent-snapshot.js";
17
18
  import { subscribeRecordObserver } from "./record-observer.js";
18
19
  import type { RunConfig } from "./runtime.js";
@@ -72,12 +73,14 @@ export interface AgentSpawnConfig {
72
73
  invocation?: AgentInvocation;
73
74
  /** Parent abort signal — when aborted, the subagent is also stopped. */
74
75
  signal?: AbortSignal;
75
- /** Called when the agent session is created — the one remaining callback. */
76
- onSessionCreated?: (session: AgentSession) => void;
76
+ /** Called when the agent session is created — receives the session and the agent's record. */
77
+ onSessionCreated?: (session: AgentSession, record: AgentRecord) => void;
77
78
  /** Path to the parent session's JSONL file (for deriving the subagent session directory). */
78
79
  parentSessionFile?: string;
79
80
  /** Session ID of the parent agent (stored in the child session's parentSession header). */
80
81
  parentSessionId?: string;
82
+ /** Tool call ID for background notification wiring. When set, spawn attaches NotificationState. */
83
+ toolCallId?: string;
81
84
  }
82
85
 
83
86
  export class AgentManager {
@@ -155,6 +158,10 @@ export class AgentManager {
155
158
  });
156
159
  this.agents.set(id, record);
157
160
 
161
+ if (options.toolCallId) {
162
+ record.notification = new NotificationState(options.toolCallId);
163
+ }
164
+
158
165
  if (options.isBackground) {
159
166
  this.observer?.onAgentCreated(record);
160
167
  }
@@ -244,7 +251,7 @@ export class AgentManager {
244
251
  unsubRecordObserver = subscribeRecordObserver(session, record, {
245
252
  onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
246
253
  });
247
- options.onSessionCreated?.(session);
254
+ options.onSessionCreated?.(session, record);
248
255
  },
249
256
  })
250
257
  .then(({ responseText, session, aborted, steered, sessionFile }) => {
package/src/index.ts CHANGED
@@ -169,7 +169,6 @@ export default function (pi: ExtensionAPI) {
169
169
  resume: (id, prompt, signal) => manager.resume(id, prompt, signal),
170
170
  getRecord: (id) => manager.getRecord(id),
171
171
  getMaxConcurrent: () => settings.maxConcurrent,
172
- listAgents: () => manager.listAgents(),
173
172
  },
174
173
  widget: {
175
174
  setUICtx: (ctx) => runtime.setUICtx(ctx as UICtx),
@@ -7,13 +7,11 @@ import { AgentTypeRegistry } from "../agent-types.js";
7
7
  import { resolveAgentInvocationConfig } from "../invocation-config.js";
8
8
  import { resolveInvocationModel } from "../model-resolver.js";
9
9
 
10
- import { NotificationState } from "../notification-state.js";
11
10
  import type { AgentInvocation, AgentRecord, SubagentType } from "../types.js";
12
11
  import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
13
12
  import {
14
13
  type AgentDetails,
15
14
  buildInvocationTags,
16
- describeActivity,
17
15
  formatMs,
18
16
  formatTurns,
19
17
  getDisplayName,
@@ -21,55 +19,9 @@ import {
21
19
  SPINNER,
22
20
  type UICtx,
23
21
  } from "../ui/agent-widget.js";
24
- import { subscribeUIObserver } from "../ui/ui-observer.js";
25
- import type { LifetimeUsage } from "../usage.js";
26
- import { buildTypeListText, formatLifetimeTokens, textResult } from "./helpers.js";
27
-
28
- // ---- Agent-tool-specific helpers ----
29
-
30
- /** Parenthetical status note for completed agent result text. */
31
- export function getStatusNote(status: string): string {
32
- switch (status) {
33
- case "aborted":
34
- return " (aborted — max turns exceeded, output may be incomplete)";
35
- case "steered":
36
- return " (wrapped up — reached turn limit)";
37
- case "stopped":
38
- return " (stopped by user)";
39
- default:
40
- return "";
41
- }
42
- }
43
-
44
- /** Build AgentDetails from a base + record-specific fields. */
45
- export function buildDetails(
46
- base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
47
- record: {
48
- toolUses: number;
49
- startedAt: number;
50
- completedAt?: number;
51
- status: string;
52
- error?: string;
53
- id?: string;
54
- session?: any;
55
- lifetimeUsage: LifetimeUsage;
56
- },
57
- activity?: AgentActivityTracker,
58
- overrides?: Partial<AgentDetails>,
59
- ): AgentDetails {
60
- return {
61
- ...base,
62
- toolUses: record.toolUses,
63
- tokens: formatLifetimeTokens(record),
64
- turnCount: activity?.turnCount,
65
- maxTurns: activity?.maxTurns,
66
- durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
67
- status: record.status as AgentDetails["status"],
68
- agentId: record.id,
69
- error: record.error,
70
- ...overrides,
71
- };
72
- }
22
+ import { spawnBackground } from "./background-spawner.js";
23
+ import { runForeground } from "./foreground-runner.js";
24
+ import { buildDetails, buildTypeListText, formatLifetimeTokens, getStatusNote, textResult } from "./helpers.js";
73
25
 
74
26
  // ---- Deps interface ----
75
27
 
@@ -80,7 +32,6 @@ export interface AgentToolManager {
80
32
  resume: (id: string, prompt: string, signal: AbortSignal) => Promise<AgentRecord | undefined>;
81
33
  getRecord: (id: string) => AgentRecord | undefined;
82
34
  getMaxConcurrent: () => number;
83
- listAgents: () => AgentRecord[];
84
35
  }
85
36
 
86
37
  /** Narrow widget interface — only the methods the Agent tool calls. */
@@ -412,167 +363,48 @@ Guidelines:
412
363
 
413
364
  // Background execution
414
365
  if (runInBackground) {
415
- const bgState = new AgentActivityTracker(effectiveMaxTurns);
416
-
417
- let id: string;
418
-
419
- try {
420
- id = deps.manager.spawn(ctx, subagentType, params.prompt as string, {
421
- parentSessionFile: ctx.sessionManager.getSessionFile(),
422
- parentSessionId: ctx.sessionManager.getSessionId(),
366
+ return spawnBackground(
367
+ { manager: deps.manager, widget: deps.widget, agentActivity: deps.agentActivity },
368
+ {
369
+ ctx,
370
+ subagentType,
371
+ prompt: params.prompt as string,
423
372
  description: params.description as string,
373
+ displayName,
374
+ toolCallId,
375
+ detailBase,
424
376
  model,
425
- maxTurns: effectiveMaxTurns,
377
+ effectiveMaxTurns,
426
378
  isolated,
427
379
  inheritContext,
428
- thinkingLevel: thinking,
429
- isBackground: true,
380
+ thinking,
430
381
  isolation,
431
- invocation: agentInvocation,
432
- onSessionCreated: (session: any) => {
433
- bgState.setSession(session);
434
- subscribeUIObserver(session, bgState);
435
- },
436
- });
437
- } catch (err) {
438
- return textResult(err instanceof Error ? err.message : String(err));
439
- }
440
-
441
- const record = deps.manager.getRecord(id);
442
- if (record) {
443
- // Born complete: notification-state object owns toolCallId + resultConsumed.
444
- record.notification = new NotificationState(toolCallId);
445
- }
446
-
447
- deps.agentActivity.set(id, bgState);
448
- deps.widget.ensureTimer();
449
- deps.widget.update();
450
-
451
- const isQueued = record?.status === "queued";
452
- return textResult(
453
- `Agent ${isQueued ? "queued" : "started"} in background.\n` +
454
- `Agent ID: ${id}\n` +
455
- `Type: ${displayName}\n` +
456
- `Description: ${params.description}\n` +
457
- (record?.execution?.outputFile ? `Output file: ${record.execution.outputFile}\n` : "") +
458
- (isQueued
459
- ? `Position: queued (max ${deps.manager.getMaxConcurrent()} concurrent)\n`
460
- : "") +
461
- `\nYou will be notified when this agent completes.\n` +
462
- `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
463
- `Do not duplicate this agent's work.`,
464
- {
465
- ...detailBase,
466
- toolUses: 0,
467
- tokens: "",
468
- durationMs: 0,
469
- status: "background" as const,
470
- agentId: id,
382
+ agentInvocation,
471
383
  },
472
384
  );
473
385
  }
474
386
 
475
387
  // Foreground (synchronous) execution — stream progress via onUpdate
476
- let spinnerFrame = 0;
477
- const startedAt = Date.now();
478
- let fgId: string | undefined;
479
-
480
- const fgState = new AgentActivityTracker(effectiveMaxTurns);
481
- let unsubUI: (() => void) | undefined;
482
-
483
- const streamUpdate = () => {
484
- const details: AgentDetails = {
485
- ...detailBase,
486
- toolUses: fgState.toolUses,
487
- tokens: formatLifetimeTokens(fgState),
488
- turnCount: fgState.turnCount,
489
- maxTurns: fgState.maxTurns,
490
- durationMs: Date.now() - startedAt,
491
- status: "running",
492
- activity: describeActivity(fgState.activeTools, fgState.responseText),
493
- spinnerFrame: spinnerFrame % SPINNER.length,
494
- };
495
- onUpdate?.({
496
- content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
497
- details: details as any,
498
- });
499
- };
500
-
501
- // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
502
- const spinnerInterval = setInterval(() => {
503
- spinnerFrame++;
504
- streamUpdate();
505
- }, 80);
506
-
507
- streamUpdate();
508
-
509
- let record: AgentRecord;
510
- try {
511
- record = await deps.manager.spawnAndWait(
388
+ return runForeground(
389
+ { manager: deps.manager, widget: deps.widget, agentActivity: deps.agentActivity },
390
+ {
512
391
  ctx,
513
392
  subagentType,
514
- params.prompt as string,
515
- {
516
- description: params.description as string,
517
- model,
518
- maxTurns: effectiveMaxTurns,
519
- isolated,
520
- inheritContext,
521
- thinkingLevel: thinking,
522
- isolation,
523
- invocation: agentInvocation,
524
- signal,
525
- parentSessionFile: ctx.sessionManager.getSessionFile(),
526
- parentSessionId: ctx.sessionManager.getSessionId(),
527
- onSessionCreated: (session: any) => {
528
- fgState.setSession(session);
529
- unsubUI = subscribeUIObserver(session, fgState, streamUpdate);
530
- for (const a of deps.manager.listAgents()) {
531
- if (a.execution?.session === session) {
532
- fgId = a.id;
533
- deps.agentActivity.set(a.id, fgState);
534
- deps.widget.ensureTimer();
535
- break;
536
- }
537
- }
538
- },
539
- },
540
- );
541
- } catch (err) {
542
- clearInterval(spinnerInterval);
543
- unsubUI?.();
544
- return textResult(err instanceof Error ? err.message : String(err));
545
- }
546
-
547
- clearInterval(spinnerInterval);
548
- unsubUI?.();
549
-
550
- // Clean up foreground agent from widget
551
- if (fgId) {
552
- deps.agentActivity.delete(fgId);
553
- deps.widget.markFinished(fgId);
554
- }
555
-
556
- // Get final token count
557
- const tokenText = formatLifetimeTokens(fgState);
558
-
559
- const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
560
-
561
- const fallbackNote = fellBack
562
- ? `Note: Unknown agent type "${rawType}" — using general-purpose.\n\n`
563
- : "";
564
-
565
- if (record.status === "error") {
566
- return textResult(`${fallbackNote}Agent failed: ${record.error}`, details);
567
- }
568
-
569
- const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
570
- const statsParts = [`${record.toolUses} tool uses`];
571
- if (tokenText) statsParts.push(tokenText);
572
- return textResult(
573
- `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
574
- (record.result?.trim() || "No output."),
575
- details,
393
+ prompt: params.prompt as string,
394
+ description: params.description as string,
395
+ detailBase,
396
+ rawType,
397
+ fellBack,
398
+ model,
399
+ effectiveMaxTurns,
400
+ isolated,
401
+ inheritContext,
402
+ thinking,
403
+ isolation,
404
+ agentInvocation,
405
+ },
406
+ signal,
407
+ onUpdate,
576
408
  );
577
409
  },
578
410
  };
@@ -0,0 +1,116 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+ import type { AgentSpawnConfig } from "../agent-manager.js";
3
+ import type { AgentInvocation, AgentRecord, IsolationMode, ThinkingLevel } from "../types.js";
4
+ import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
5
+ import type { AgentDetails } from "../ui/agent-widget.js";
6
+ import { subscribeUIObserver } from "../ui/ui-observer.js";
7
+ import type { AgentActivityAccess } from "./agent-tool.js";
8
+ import { textResult } from "./helpers.js";
9
+
10
+ /** Narrow manager interface for the background spawner. */
11
+ export interface BackgroundManagerDeps {
12
+ spawn(ctx: any, type: string, prompt: string, opts: AgentSpawnConfig): string;
13
+ getRecord(id: string): AgentRecord | undefined;
14
+ getMaxConcurrent(): number;
15
+ }
16
+
17
+ /** Narrow widget interface for the background spawner. */
18
+ export interface BackgroundWidgetDeps {
19
+ ensureTimer(): void;
20
+ update(): void;
21
+ }
22
+
23
+ /** Injected collaborators for spawnBackground. */
24
+ export interface BackgroundDeps {
25
+ manager: BackgroundManagerDeps;
26
+ widget: BackgroundWidgetDeps;
27
+ agentActivity: AgentActivityAccess;
28
+ }
29
+
30
+ /** All values the background spawner needs, bundled from shared execute setup. */
31
+ export interface BackgroundParams {
32
+ ctx: {
33
+ sessionManager: {
34
+ getSessionFile(): string;
35
+ getSessionId(): string;
36
+ };
37
+ };
38
+ subagentType: string;
39
+ prompt: string;
40
+ description: string;
41
+ displayName: string;
42
+ toolCallId: string;
43
+ detailBase: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">;
44
+ model: Model<any> | undefined;
45
+ effectiveMaxTurns: number | undefined;
46
+ isolated: boolean | undefined;
47
+ inheritContext: boolean | undefined;
48
+ thinking: ThinkingLevel | undefined;
49
+ isolation: IsolationMode | undefined;
50
+ agentInvocation: AgentInvocation;
51
+ }
52
+
53
+ /**
54
+ * Spawn a background agent and return the tool result immediately.
55
+ * Owns: activity tracker creation, UI observer subscription, activity map
56
+ * registration, widget update, and launch message formatting.
57
+ */
58
+ export function spawnBackground(
59
+ deps: BackgroundDeps,
60
+ params: BackgroundParams,
61
+ ) {
62
+ const bgState = new AgentActivityTracker(params.effectiveMaxTurns);
63
+
64
+ let id: string;
65
+ try {
66
+ id = deps.manager.spawn(params.ctx, params.subagentType, params.prompt, {
67
+ parentSessionFile: params.ctx.sessionManager.getSessionFile(),
68
+ parentSessionId: params.ctx.sessionManager.getSessionId(),
69
+ description: params.description,
70
+ model: params.model,
71
+ maxTurns: params.effectiveMaxTurns,
72
+ isolated: params.isolated,
73
+ inheritContext: params.inheritContext,
74
+ thinkingLevel: params.thinking,
75
+ isBackground: true,
76
+ isolation: params.isolation,
77
+ invocation: params.agentInvocation,
78
+ toolCallId: params.toolCallId,
79
+ onSessionCreated: (session) => {
80
+ bgState.setSession(session);
81
+ subscribeUIObserver(session, bgState);
82
+ },
83
+ });
84
+ } catch (err) {
85
+ return textResult(err instanceof Error ? err.message : String(err));
86
+ }
87
+
88
+ const record = deps.manager.getRecord(id);
89
+
90
+ deps.agentActivity.set(id, bgState);
91
+ deps.widget.ensureTimer();
92
+ deps.widget.update();
93
+
94
+ const isQueued = record?.status === "queued";
95
+ return textResult(
96
+ `Agent ${isQueued ? "queued" : "started"} in background.\n` +
97
+ `Agent ID: ${id}\n` +
98
+ `Type: ${params.displayName}\n` +
99
+ `Description: ${params.description}\n` +
100
+ (record?.execution?.outputFile ? `Output file: ${record.execution.outputFile}\n` : "") +
101
+ (isQueued
102
+ ? `Position: queued (max ${deps.manager.getMaxConcurrent()} concurrent)\n`
103
+ : "") +
104
+ `\nYou will be notified when this agent completes.\n` +
105
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
106
+ `Do not duplicate this agent's work.`,
107
+ {
108
+ ...params.detailBase,
109
+ toolUses: 0,
110
+ tokens: "",
111
+ durationMs: 0,
112
+ status: "background" as const,
113
+ agentId: id,
114
+ },
115
+ );
116
+ }
@@ -0,0 +1,175 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
3
+ import type { AgentSpawnConfig } from "../agent-manager.js";
4
+ import type { AgentInvocation, AgentRecord, IsolationMode, ThinkingLevel } from "../types.js";
5
+ import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
6
+ import {
7
+ type AgentDetails,
8
+ describeActivity,
9
+ formatMs,
10
+ SPINNER,
11
+ } from "../ui/agent-widget.js";
12
+ import { subscribeUIObserver } from "../ui/ui-observer.js";
13
+ import type { AgentActivityAccess } from "./agent-tool.js";
14
+ import {
15
+ buildDetails,
16
+ formatLifetimeTokens,
17
+ getStatusNote,
18
+ textResult,
19
+ } from "./helpers.js";
20
+
21
+ /** Narrow manager interface for the foreground runner. */
22
+ export interface ForegroundManagerDeps {
23
+ spawnAndWait(
24
+ ctx: any,
25
+ type: string,
26
+ prompt: string,
27
+ opts: Omit<AgentSpawnConfig, "isBackground">,
28
+ ): Promise<AgentRecord>;
29
+ }
30
+
31
+ /** Narrow widget interface for the foreground runner. */
32
+ export interface ForegroundWidgetDeps {
33
+ ensureTimer(): void;
34
+ markFinished(id: string): void;
35
+ }
36
+
37
+ /** Injected collaborators for runForeground. */
38
+ export interface ForegroundDeps {
39
+ manager: ForegroundManagerDeps;
40
+ widget: ForegroundWidgetDeps;
41
+ agentActivity: AgentActivityAccess;
42
+ }
43
+
44
+ /** All values the foreground runner needs, bundled from shared execute setup. */
45
+ export interface ForegroundParams {
46
+ ctx: {
47
+ sessionManager: {
48
+ getSessionFile(): string;
49
+ getSessionId(): string;
50
+ };
51
+ };
52
+ subagentType: string;
53
+ prompt: string;
54
+ description: string;
55
+ detailBase: Pick<
56
+ AgentDetails,
57
+ "displayName" | "description" | "subagentType" | "modelName" | "tags"
58
+ >;
59
+ rawType: string;
60
+ fellBack: boolean;
61
+ model: Model<any> | undefined;
62
+ effectiveMaxTurns: number | undefined;
63
+ isolated: boolean | undefined;
64
+ inheritContext: boolean | undefined;
65
+ thinking: ThinkingLevel | undefined;
66
+ isolation: IsolationMode | undefined;
67
+ agentInvocation: AgentInvocation;
68
+ }
69
+
70
+ /**
71
+ * Run an agent synchronously in the foreground, streaming spinner updates.
72
+ * Owns: spinner interval, AgentActivityTracker creation, UI observer subscription,
73
+ * streaming onUpdate callbacks, cleanup, and result formatting.
74
+ */
75
+ export async function runForeground(
76
+ deps: ForegroundDeps,
77
+ params: ForegroundParams,
78
+ signal: AbortSignal | undefined,
79
+ onUpdate: ((update: AgentToolResult<any>) => void) | undefined,
80
+ ) {
81
+ let spinnerFrame = 0;
82
+ const startedAt = Date.now();
83
+ let fgId: string | undefined;
84
+
85
+ const fgState = new AgentActivityTracker(params.effectiveMaxTurns);
86
+ let unsubUI: (() => void) | undefined;
87
+
88
+ const streamUpdate = () => {
89
+ const details: AgentDetails = {
90
+ ...params.detailBase,
91
+ toolUses: fgState.toolUses,
92
+ tokens: formatLifetimeTokens(fgState),
93
+ turnCount: fgState.turnCount,
94
+ maxTurns: fgState.maxTurns,
95
+ durationMs: Date.now() - startedAt,
96
+ status: "running",
97
+ activity: describeActivity(fgState.activeTools, fgState.responseText),
98
+ spinnerFrame: spinnerFrame % SPINNER.length,
99
+ };
100
+ onUpdate?.({
101
+ content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
102
+ details: details as any,
103
+ });
104
+ };
105
+
106
+ // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
107
+ const spinnerInterval = setInterval(() => {
108
+ spinnerFrame++;
109
+ streamUpdate();
110
+ }, 80);
111
+
112
+ streamUpdate();
113
+
114
+ let record: AgentRecord;
115
+ try {
116
+ record = await deps.manager.spawnAndWait(
117
+ params.ctx,
118
+ params.subagentType,
119
+ params.prompt,
120
+ {
121
+ description: params.description,
122
+ model: params.model,
123
+ maxTurns: params.effectiveMaxTurns,
124
+ isolated: params.isolated,
125
+ inheritContext: params.inheritContext,
126
+ thinkingLevel: params.thinking,
127
+ isolation: params.isolation,
128
+ invocation: params.agentInvocation,
129
+ signal,
130
+ parentSessionFile: params.ctx.sessionManager.getSessionFile(),
131
+ parentSessionId: params.ctx.sessionManager.getSessionId(),
132
+ onSessionCreated: (session, record) => {
133
+ fgState.setSession(session);
134
+ unsubUI = subscribeUIObserver(session, fgState, streamUpdate);
135
+ fgId = record.id;
136
+ deps.agentActivity.set(record.id, fgState);
137
+ deps.widget.ensureTimer();
138
+ },
139
+ },
140
+ );
141
+ } catch (err) {
142
+ clearInterval(spinnerInterval);
143
+ unsubUI?.();
144
+ return textResult(err instanceof Error ? err.message : String(err));
145
+ }
146
+
147
+ clearInterval(spinnerInterval);
148
+ unsubUI?.();
149
+
150
+ // Clean up foreground agent from widget
151
+ if (fgId) {
152
+ deps.agentActivity.delete(fgId);
153
+ deps.widget.markFinished(fgId);
154
+ }
155
+
156
+ const tokenText = formatLifetimeTokens(fgState);
157
+ const details = buildDetails(params.detailBase, record, fgState, { tokens: tokenText });
158
+
159
+ const fallbackNote = params.fellBack
160
+ ? `Note: Unknown agent type "${params.rawType}" \u2014 using general-purpose.\n\n`
161
+ : "";
162
+
163
+ if (record.status === "error") {
164
+ return textResult(`${fallbackNote}Agent failed: ${record.error}`, details);
165
+ }
166
+
167
+ const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
168
+ const statsParts = [`${record.toolUses} tool uses`];
169
+ if (tokenText) statsParts.push(tokenText);
170
+ return textResult(
171
+ `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
172
+ (record.result?.trim() || "No output."),
173
+ details,
174
+ );
175
+ }
@@ -1,7 +1,51 @@
1
1
  import type { AgentConfigLookup } from "../agent-types.js";
2
- import { formatTokens } from "../ui/agent-widget.js";
2
+ import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
3
+ import { type AgentDetails, formatTokens } from "../ui/agent-widget.js";
3
4
  import { getLifetimeTotal, type LifetimeUsage } from "../usage.js";
4
5
 
6
+ /** Parenthetical status note for completed agent result text. */
7
+ export function getStatusNote(status: string): string {
8
+ switch (status) {
9
+ case "aborted":
10
+ return " (aborted \u2014 max turns exceeded, output may be incomplete)";
11
+ case "steered":
12
+ return " (wrapped up \u2014 reached turn limit)";
13
+ case "stopped":
14
+ return " (stopped by user)";
15
+ default:
16
+ return "";
17
+ }
18
+ }
19
+
20
+ /** Build AgentDetails from a base + record-specific fields. */
21
+ export function buildDetails(
22
+ base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
23
+ record: {
24
+ toolUses: number;
25
+ startedAt: number;
26
+ completedAt?: number;
27
+ status: string;
28
+ error?: string;
29
+ id?: string;
30
+ lifetimeUsage: LifetimeUsage;
31
+ },
32
+ activity?: AgentActivityTracker,
33
+ overrides?: Partial<AgentDetails>,
34
+ ): AgentDetails {
35
+ return {
36
+ ...base,
37
+ toolUses: record.toolUses,
38
+ tokens: formatLifetimeTokens(record),
39
+ turnCount: activity?.turnCount,
40
+ maxTurns: activity?.maxTurns,
41
+ durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
42
+ status: record.status as AgentDetails["status"],
43
+ agentId: record.id,
44
+ error: record.error,
45
+ ...overrides,
46
+ };
47
+ }
48
+
5
49
  /** Tool execute return value for a text response. */
6
50
  export function textResult(msg: string, details?: unknown) {
7
51
  return { content: [{ type: "text" as const, text: msg }], details: details as any };