@gotgenes/pi-subagents 16.2.2 → 16.3.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,25 @@ 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
+ ## [16.3.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.3.0...pi-subagents-v16.3.1) (2026-06-16)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * reframe pi-subagents positioning away from "Claude Code-style" ([c8d380d](https://github.com/gotgenes/pi-packages/commit/c8d380d440a4ae29f9173673523337cf667fb3da))
14
+
15
+ ## [16.3.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.2.2...pi-subagents-v16.3.0) (2026-06-16)
16
+
17
+
18
+ ### Features
19
+
20
+ * self-seed finished agents in AgentWidget.update ([#377](https://github.com/gotgenes/pi-packages/issues/377)) ([fd99a29](https://github.com/gotgenes/pi-packages/commit/fd99a297f70ce4c1c078fd486be042f691fe5a79))
21
+
22
+
23
+ ### Documentation
24
+
25
+ * **pi-subagents:** drop removed memory, skills, isolation surfaces ([7cf20ee](https://github.com/gotgenes/pi-packages/commit/7cf20eebf109c979a864000cd8b9d84a13d0df90))
26
+
8
27
  ## [16.2.2](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.2.1...pi-subagents-v16.2.2) (2026-06-15)
9
28
 
10
29
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@gotgenes/pi-subagents?style=flat&logo=npm&logoColor=white)](https://www.npmjs.com/package/@gotgenes/pi-subagents) [![CI](https://img.shields.io/github/actions/workflow/status/gotgenes/pi-packages/ci.yml?style=flat&logo=github&label=CI)](https://github.com/gotgenes/pi-packages/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat)](https://opensource.org/licenses/MIT) [![TypeScript](https://img.shields.io/badge/TypeScript-6.x-3178C6?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![pnpm](https://img.shields.io/badge/pnpm-%3E%3D11-F69220?style=flat&logo=pnpm&logoColor=white)](https://pnpm.io/) [![Pi Package](https://img.shields.io/badge/Pi-Package-6366F1?style=flat)](https://pi.mariozechner.at/)
4
4
 
5
- A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-agents** to pi.
5
+ A [pi](https://pi.dev) extension that gives pi **a focused, in-process sub-agent core** autonomous agents that run inside the same pi runtime (no spawned subprocesses), plus a typed API and lifecycle events other extensions build on.
6
6
  Spawn specialized agents that run in isolated sessions — each with its own tools, system prompt, model, and thinking level.
7
7
  Run them in foreground or background, steer them mid-run, resume completed sessions, and define your own custom agent types.
8
8
 
@@ -15,7 +15,7 @@ Run them in foreground or background, steer them mid-run, resume completed sessi
15
15
 
16
16
  ## Features
17
17
 
18
- - **Claude Code look & feel** — same tool names, calling conventions, and UI patterns (`subagent`, `get_subagent_result`, `steer_subagent`) — feels native
18
+ - **In-process & native** — agents run inside the same pi runtime (no spawned subprocesses), sharing tool names, calling conventions, and UI patterns (`subagent`, `get_subagent_result`, `steer_subagent`) — feels native
19
19
  - **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 4) and individual completion notifications
20
20
  - **Live widget UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, and colored status icons
21
21
  - **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause)
@@ -27,9 +27,6 @@ Run them in foreground or background, steer them mid-run, resume completed sessi
27
27
  Unknown types fall back to general-purpose with a note
28
28
  - **Fuzzy model selection** — specify models by name (`"haiku"`, `"sonnet"`) instead of full IDs, with automatic filtering to only available/configured models
29
29
  - **Context inheritance** — optionally fork the parent conversation into a sub-agent so it knows what's been discussed
30
- - **Persistent agent memory** — three scopes (project, local, user) with automatic read-only fallback for agents without write tools
31
- - **Git worktree isolation** — run agents in isolated repo copies; changes auto-committed to branches on completion
32
- - **Skill preloading** — inject named skills into agent system prompts, discovered from `.pi/skills/`, `.agents/skills/`, and global locations (Pi-standard `<name>/SKILL.md` directory layout supported)
33
30
  - **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML.
34
31
  Expandable to show full output
35
32
  - **Event bus** — lifecycle events (`subagents:created`, `started`, `completed`, `failed`, `steered`, `compacted`) emitted via `pi.events`, enabling other extensions to react to sub-agent activity
@@ -84,7 +81,7 @@ The token field is annotated with two optional signals inside parens:
84
81
  - **`↻N`** — number of times the session has compacted, when > 0.
85
82
  Stays dim; the percent's color carries urgency.
86
83
 
87
- Individual agent results render Claude Code-style in the conversation:
84
+ Individual agent results render inline in the conversation:
88
85
 
89
86
  | State | Example |
90
87
  | -------------- | ---------------------------------------------------------------------------------------- |
@@ -174,21 +171,16 @@ All fields are optional — sensible defaults for everything.
174
171
  | `description` | filename | Agent description shown in tool listings |
175
172
  | `display_name` | — | Display name for UI (e.g. widget, agent list) |
176
173
  | `tools` | all 7 | Comma-separated built-in tools: read, bash, edit, write, grep, find, ls. `none` for no tools |
177
- | `extensions` | `true` | `true` to inherit all MCP/extension tools, `false` to disable |
178
- | `skills` | `true` | Inherit skills from parent. Can be a comma-separated list of skill names to preload (see [Skill Preloading](#skill-preloading) for discovery locations) |
179
- | `memory` | — | Persistent agent memory scope: `project`, `local`, or `user`. Auto-detects read-only agents |
180
- | `isolation` | — | Set to `worktree` to run in an isolated git worktree |
181
174
  | `model` | inherit parent | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`) |
182
175
  | `thinking` | inherit | off, minimal, low, medium, high, xhigh |
183
176
  | `max_turns` | unlimited | Max agentic turns before graceful shutdown. `0` or omit for unlimited |
184
177
  | `prompt_mode` | `append` | `replace`: parent prompt is the cacheable base; body is appended last with full control (no `<sub_agent_context>` bridge, no `<agent_instructions>` wrapper). `append`: parent prompt is the base; body is wrapped in `<agent_instructions>` and a sub-agent context bridge is injected (agent acts as a "parent twin") |
185
178
  | `inherit_context` | `false` | Fork parent conversation into agent |
186
179
  | `run_in_background` | `false` | Run in background by default |
187
- | `isolated` | `false` | No extension/MCP tools, only built-in |
188
180
  | `enabled` | `true` | Set to `false` to disable an agent (useful for hiding a default agent per-project) |
189
181
 
190
182
  Frontmatter is authoritative.
191
- If an agent file sets `model`, `thinking`, `max_turns`, `inherit_context`, `run_in_background`, `isolated`, or `isolation`, those values are locked for that agent.
183
+ If an agent file sets `model`, `thinking`, `max_turns`, `inherit_context`, or `run_in_background`, those values are locked for that agent.
192
184
  `subagent` tool parameters only fill fields the agent config leaves unspecified.
193
185
 
194
186
  ## Tools
@@ -207,8 +199,6 @@ Launch a sub-agent.
207
199
  | `max_turns` | number | no | Max agentic turns. Omit for unlimited (default) |
208
200
  | `run_in_background` | boolean | no | Run without blocking |
209
201
  | `resume` | string | no | Agent ID to resume a previous session |
210
- | `isolated` | boolean | no | No extension/MCP tools |
211
- | `isolation` | `"worktree"` | no | Run in an isolated git worktree |
212
202
  | `inherit_context` | boolean | no | Fork parent conversation into agent |
213
203
 
214
204
  ### `get_subagent_result`
@@ -331,77 +321,16 @@ Agent lifecycle events are emitted via `pi.events.emit()` so other extensions ca
331
321
  `cacheRead` is excluded — each turn's `cacheRead` is the cumulative cached prefix re-read on that one API call, so summing per-message would over-count it.
332
322
  Use `contextUsage.percent` (surfaced as `(NN%)` in the widget) for current context size.
333
323
 
334
- ## Persistent Agent Memory
335
-
336
- Agents can have persistent memory across sessions.
337
- Set `memory` in frontmatter to enable:
338
-
339
- ```yaml
340
- ---
341
- memory: project # project | local | user
342
- ---
343
- ```
344
-
345
- | Scope | Location | Use case |
346
- | --------- | -------------------------------- | ---------------------------------- |
347
- | `project` | `.pi/agent-memory/<name>/` | Shared across the team (committed) |
348
- | `local` | `.pi/agent-memory-local/<name>/` | Machine-specific (gitignored) |
349
- | `user` | `~/.pi/agent-memory/<name>/` | Global personal memory |
350
-
351
- Memory uses a `MEMORY.md` index file and individual memory files with frontmatter.
352
- Agents with write tools get full read-write access.
353
- **Read-only agents** (no `write`/`edit` tools) automatically get read-only memory — they can consume memories written by other agents but cannot modify them.
354
- This prevents unintended tool escalation.
355
-
356
324
  ## Worktree Isolation
357
325
 
358
- Set `isolation: worktree` to run an agent in a temporary git worktree:
359
-
360
- ```text
361
- subagent({ subagent_type: "refactor", prompt: "...", isolation: "worktree" })
362
- ```
363
-
364
- The agent gets a full, isolated copy of the repository.
365
- On completion:
366
-
367
- - **No changes:** worktree is cleaned up automatically
368
- - **Changes made:** changes are committed to a new branch (`pi-agent-<id>`) and returned in the result
369
-
370
- If the worktree cannot be created (not a git repo, no commits, or `git worktree add` fails), the `subagent` tool returns a clear error instead of running unisolated — `isolation: "worktree"` is a strict guarantee, not a hint.
371
- Initialize git and commit at least once, or omit `isolation`.
372
-
373
- ## Skill Preloading
374
-
375
- Skills can be preloaded by name and injected into the agent's system prompt:
376
-
377
- ```yaml
378
- ---
379
- skills: api-conventions, error-handling
380
- ---
381
- ```
382
-
383
- **Discovery roots** (checked in this order, first match wins):
384
-
385
- | Scope | Path | Source |
386
- | ------- | -------------------------------------------------------------- | ------------------------------------------------------------ |
387
- | Project | `<cwd>/.pi/skills/` | Pi-standard |
388
- | Project | `<cwd>/.agents/skills/` | [Agent Skills spec](https://agentskills.io/integrate-skills) |
389
- | User | `$PI_CODING_AGENT_DIR/skills/` (default `~/.pi/agent/skills/`) | Pi-standard |
390
- | User | `~/.agents/skills/` | [Agent Skills spec](https://agentskills.io/integrate-skills) |
391
- | User | `~/.pi/skills/` | Legacy (pre-Pi) |
392
-
393
- **Per root, a skill named `foo` resolves to the first of:**
394
-
395
- - `<root>/foo.md` — flat file at the top level
396
- - `<root>/foo/SKILL.md` — directory skill (top-level)
397
- - `<root>/*/.../foo/SKILL.md` — directory skill, found by recursive descent
326
+ Worktree isolation lives in a companion package, not this core.
327
+ Install [`@gotgenes/pi-subagents-worktrees`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-subagents-worktrees) and list the agent types you want isolated in its `worktreeAgents` config — opted-in agents run in a temporary git worktree, and their changes are saved to a branch on completion.
328
+ The earlier `isolation: "worktree"` spawn flag and `isolation:` frontmatter key were removed from the core.
398
329
 
399
- Recursion skips dotfile directories and `node_modules`.
400
- A directory that itself contains a `SKILL.md` is treated as a single skill — we don't descend into it.
401
- Traversal is byte-order sorted for deterministic resolution across filesystems.
330
+ ## Removed: agent memory and skill preloading
402
331
 
403
- **Security:** symlinks are rejected at every layer (root, flat file, skill directory, `SKILL.md` inside a skill directory) intentional deviation from Pi, which follows symlinks.
404
- Skill names with path-traversal characters (`..`, `/`, `\`, spaces, leading dot, >128 chars) are rejected.
332
+ Persistent agent memory (the `memory:` frontmatter key) and skill preloading (the `skills:` frontmatter key) were removed when the core was slimmed down.
333
+ Children now always inherit the parent's skills and extensions, so the `isolated`, `extensions`, and `skills` frontmatter keys no longer exist.
405
334
 
406
335
  ## Migrating from `disallowed_tools`
407
336
 
@@ -989,12 +989,18 @@ Priority = Impact × (6 − Risk).
989
989
  - Outcome: `index.ts` < 170 lines; the observer's three concerns unit-tested directly without booting the extension.
990
990
  - Landed: `src/observation/subagent-events-observer.ts` (new, 97 LOC); `index.ts` 226 → 177 lines; 60 → 61 source files; 994 → 1009 tests (+15 covering all four observer methods).
991
991
 
992
- #### Step 6 — Split widget delegation out of SubagentRuntime ([#377])
992
+ #### Step 6 — Split widget delegation out of SubagentRuntime ([#377]) ✅ Complete
993
993
 
994
- - Targets: `src/runtime.ts`, `src/tools/agent-tool.ts` (`AgentToolRuntime`), `src/tools/foreground-runner.ts`, `src/tools/background-spawner.ts`, `src/observation/notification.ts` (`NotificationManager` constructor), `src/index.ts`.
994
+ - Targets: `src/runtime.ts`, `src/tools/agent-tool.ts` (`AgentToolRuntime`), `src/handlers/tool-start.ts` (`ToolStartRuntime`), `src/observation/notification.ts` (`NotificationManager` constructor), `src/ui/agent-widget.ts`, `src/index.ts`.
995
995
  - Smell: Category C — relay-only dependency (five delegation methods that only forward to `widget`) and a post-construction `runtime.widget =` write violating principle 8.
996
- - Change: pass the existing `WidgetLike` handle directly to the consumers that need it (tool deps, `NotificationManager`) and construct the widget before them; remove the `widget` field and the five relay methods from `SubagentRuntime`.
997
- - Outcome: `SubagentRuntime` has zero widget knowledge; no post-construction field writes in `index.ts`; tool fixtures stub a 5-method `WidgetLike` instead of widget methods on the runtime mock.
996
+ - Constraint discovered in planning: "pass the handle directly to the consumers" is infeasible for `NotificationManager`, which is a _transitive dependency_ of the widget (`NotificationManager widget manager observer NotificationManager`).
997
+ The `runtime.widget` lazy field exists to break exactly this cycle; removing it forces the one late seam to move, and the operator's principles (no setters, instantiate ready-to-work) rule out relocating it to a setter or forward-ref `let`.
998
+ - Change (tidy-first): first dissolve the cycle by giving `AgentWidget.update()` self-seeding of `finishedTurnAge` for finished agents it observes via `listAgents()`, then drop the `markFinished`/`updateWidget` callbacks from `NotificationManager` (it keeps `agentActivity.delete` + nudge scheduling).
999
+ With the cycle gone, the widget is constructed as a `const` after the manager and injected directly into `AgentTool` and `ToolStartHandler`; the `widget` field, the five relay methods, and the post-construction write delete cleanly, and `AgentToolRuntime` narrows to its context-query slice.
1000
+ - Outcome: `SubagentRuntime` has zero widget knowledge; no post-construction field writes in `index.ts`; the construction graph is a cycle-free DAG (`notifications → observer → manager → widget → {AgentTool, ToolStartHandler}`).
1001
+ - Landed: `AgentWidget.seedFinishedAgents` owns completion detection; `NotificationManager` has no widget dependency (2-arg constructor); `AgentToolWidget` (in `agent-tool.ts`) and `ToolStartWidget` (in `tool-start.ts`) are narrow per-consumer interfaces the real widget satisfies structurally; tool fixtures stub a `widget` separate from the narrowed `runtime` mock.
1002
+ Behavior-preserving: the widget timer runs through every background completion, so self-seeding lands ≤80ms later within the same turn (linger is turn-based).
1003
+ Test count: 1009 → 1005 (+3 widget self-seed tests, −7 removed relay/field tests).
998
1004
 
999
1005
  #### Step 7 — Consolidate lifecycle test fixtures ([#378])
1000
1006
 
@@ -1,7 +1,7 @@
1
1
  # Comparison with upstream
2
2
 
3
3
  `@gotgenes/pi-subagents` began as a fork of [`tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents) by [@tintinweb](https://github.com/tintinweb).
4
- The original design — Claude Code-style subagent dispatch, the live widget, the conversation viewer, custom agent types — is the foundation everything here builds on.
4
+ The original design — autonomous subagent dispatch, the live widget, the conversation viewer, custom agent types — is the foundation everything here builds on.
5
5
 
6
6
  It has since become an independently maintained hard fork.
7
7
  It follows its own architecture, does not track upstream as a merge target, and cherry-picks upstream fixes only when they fit its scope.
@@ -26,10 +26,9 @@ The peer-dep migration that prompted the original fork has since landed upstream
26
26
 
27
27
  Both extensions provide the same core experience:
28
28
 
29
- - Claude Code-style foreground/background subagents with a live above-editor widget and a conversation viewer.
29
+ - Foreground/background subagents with a live above-editor widget and a conversation viewer.
30
30
  - Custom agent types defined in `.pi/agents/<name>.md` with YAML frontmatter (system prompt, model, thinking, tools).
31
- - Persistent agent memory (project / local / user scopes) with read-only fallback.
32
- - Skill preloading, fuzzy model selection, context inheritance, mid-run steering, session resume, and graceful turn limits.
31
+ - Fuzzy model selection, context inheritance, mid-run steering, session resume, and graceful turn limits.
33
32
  - A `pi.events` lifecycle bus (`subagents:created`, `started`, `completed`, `failed`, `steered`, `compacted`).
34
33
 
35
34
  ## What upstream has that this fork does not
@@ -41,6 +40,8 @@ It keeps several subsystems built in that this fork deliberately removed or dele
41
40
  | ----------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
42
41
  | Tool restrictions | `disallowed_tools` frontmatter (denylist) | Delegated — `permission:` via [`@gotgenes/pi-permission-system`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system) |
43
42
  | Worktree isolation | Built-in | Delegated — [`@gotgenes/pi-subagents-worktrees`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-subagents-worktrees) |
43
+ | Persistent agent memory | `memory:` frontmatter (project / local / user) | Removed |
44
+ | Skill preloading | `skills:` frontmatter (preload named skills) | Removed — children always inherit the parent's skills |
44
45
  | Scheduling | Cron / interval / one-shot subagents (`schedule`) | Removed |
45
46
  | Cross-extension control | `subagents:rpc:*` event RPC | Replaced by a typed service (below) |
46
47
  | Model-scope enforcement | `enabledModels` allowlist validation | Not included |
@@ -0,0 +1,254 @@
1
+ ---
2
+ issue: 377
3
+ issue_title: "Split widget delegation out of SubagentRuntime"
4
+ ---
5
+
6
+ # Split widget delegation out of SubagentRuntime
7
+
8
+ ## Problem Statement
9
+
10
+ `SubagentRuntime.widget` is assigned after construction (`runtime.widget = new AgentWidget(...)` in `index.ts`), violating construct-complete (principle 8).
11
+ The runtime then carries five relay-only delegation methods — `setUICtx`, `onTurnStart`, `markFinished`, `update`, `ensureTimer` — that do nothing but forward to `this.widget?`.
12
+ That is a relay-only dependency (design-review check 5): the runtime holds the widget purely to hand it to others, and the coupling leaks into tests, where the `AgentToolRuntime` fixture stubs all five widget methods on the runtime mock.
13
+
14
+ The issue's "Proposed change" — construct the widget before its consumers and pass the `WidgetLike` handle directly to the tool deps and `NotificationManager` — is the right intent but is **not directly feasible** as stated.
15
+ `NotificationManager` is not just a consumer of the widget; it is a transitive *dependency* of the widget.
16
+ The construction graph contains a genuine cycle:
17
+
18
+ ```text
19
+ NotificationManager → widget → manager → observer (SubagentEventsObserver) → NotificationManager
20
+ ```
21
+
22
+ The widget needs the manager (`listAgents()`); the manager needs the observer; the observer needs the `NotificationSystem`; and `NotificationManager` needs the widget (`markFinished`/`update`).
23
+ The current `runtime.widget` lazy field exists precisely to break this cycle.
24
+ Removing it forces the single unavoidable late seam to move — and the operator's design principles (no setters, instantiate ready-to-work, constructor DI) rule out relocating it to a setter or a forward-referenced `let`.
25
+
26
+ The cycle has exactly one weak edge: `NotificationManager → widget`.
27
+ Dissolving that edge collapses the cycle entirely, after which the remaining relay removal is mechanical.
28
+
29
+ ## Goals
30
+
31
+ - Remove the `widget` field and the five relay methods (`setUICtx`, `onTurnStart`, `markFinished`, `update`, `ensureTimer`) from `SubagentRuntime`.
32
+ - Eliminate the post-construction `runtime.widget =` write from `index.ts`.
33
+ - Inject the `AgentWidget` handle directly into its real consumers (`AgentTool`, `ToolStartHandler`) via constructor DI.
34
+ - Dissolve `NotificationManager`'s widget dependency so the construction graph is a clean linear DAG (`notifications → observer → manager → widget → tool/handler`) with no cycle, no setter, and no forward-referenced `let`.
35
+ - Narrow `AgentToolRuntime` to drop the four widget methods it currently declares.
36
+ - Behavior-preserving: no observable change to widget rendering, the finished-agent linger countdown, or completion notifications.
37
+
38
+ This change is **not breaking** — it is an internal refactor.
39
+ `SubagentRuntime` is not part of the published service surface (`src/service/service.ts`); no exported API, event channel, or config default changes.
40
+
41
+ ## Non-Goals
42
+
43
+ - Reworking how the widget is rendered, the `/agents` menu, the conversation viewer, or any first-principles UI reconsideration — that is Phase 18.
44
+ - Replacing the widget's poll-based refresh with an event-subscription model — heavier machinery, deferred.
45
+ - Changing `NotificationManager`'s nudge scheduling, `sendMessage` wiring, or `agentActivity` ownership.
46
+ - Touching the foreground-runner / background-spawner direct widget calls (they are already clean consumers that receive the widget from `AgentTool`) beyond the type of the handle they accept.
47
+ - Phase 17 Steps 7–9 (test-fixture consolidation, settings-loader duplication).
48
+
49
+ ## Background
50
+
51
+ Relevant modules and their current widget coupling:
52
+
53
+ | Module | Current widget coupling | Disposition |
54
+ | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
55
+ | `src/runtime.ts` | `WidgetLike` interface, `widget` field, 5 relay methods | Remove all widget knowledge |
56
+ | `src/index.ts` | `runtime.widget = new AgentWidget(...)` post-construction; wires `runtime.markFinished`/`runtime.update` into `NotificationManager` | Construct widget as a `const`; inject directly; reorder |
57
+ | `src/observation/notification.ts` | `NotificationManager` ctor takes `markFinished` + `updateWidget` callbacks; `sendCompletion`/`cleanupCompleted` call them | Drop both callbacks; keep `agentActivity.delete` + nudge |
58
+ | `src/ui/agent-widget.ts` | `AgentWidget(manager, agentActivity, registry)`; `markFinished(id)` seeds `finishedTurnAge`; `update()` renders | Self-seed `finishedTurnAge` from `listAgents()` during `update()` |
59
+ | `src/tools/agent-tool.ts` | `AgentToolRuntime` declares `setUICtx`/`ensureTimer`/`update`/`markFinished`; `execute` calls `this.runtime.setUICtx`; passes `this.runtime` as the widget to runner/spawner | Add a `widget` ctor param; narrow `AgentToolRuntime` |
60
+ | `src/handlers/tool-start.ts` | `ToolStartRuntime` declares `setUICtx`/`onTurnStart`; calls them on `this.runtime` | Accept a widget instead of the runtime |
61
+ | `src/tools/foreground-runner.ts` | `ForegroundWidgetDeps { ensureTimer, markFinished }` | Unchanged interface; receives the real widget |
62
+ | `src/tools/background-spawner.ts` | `BackgroundWidgetDeps { ensureTimer, update }` | Unchanged interface; receives the real widget |
63
+
64
+ Key facts establishing behavior preservation for the dissolve:
65
+
66
+ - The widget timer (`ensureTimer()` → `setInterval(update, 80)`) runs while any agent is active or lingering, and is cleared only on `update()`'s idle path (`!hasActive && !hasFinished`).
67
+ A background agent is **active at the moment it completes**, so the timer is always running then, and `hasFinished` keeps it running through the linger window.
68
+ - The linger countdown is **turn-based**: `markFinished` seeds `finishedTurnAge[id] = 0`, `onTurnStart` ages every entry by 1, and `shouldShowFinished` hides an agent once its age reaches `maxAge` (1 for completed, 2 for error/aborted).
69
+ Seeding ≤80ms later (on the next timer tick) lands in the same turn, so the expiry behavior is identical.
70
+ - `NotificationManager.sendCompletion`/`cleanupCompleted` only deletes the live `AgentActivityTracker` (running-display state) — finished agents render from `listAgents()` + `finishedTurnAge`, so the deletion is orthogonal to linger.
71
+
72
+ AGENTS.md / architecture constraints that apply:
73
+
74
+ - Principle 8 (construct complete; no post-construction field writes from external code) is the motivating rule — the fix must not relocate the smell to a setter or forward-ref `let` (operator steer).
75
+ - Phase 17 Step 1 deleted a `prefer-const` eslint-disable dance; reintroducing a forward-referenced `let widget` (which trips `prefer-const`, per the `code-design` skill) would regress that and is explicitly avoided.
76
+ - `src/runtime.ts` is **not** in the rolled public type bundle (`exports` points at `src/service/service.ts`), so this change does not require `pnpm run verify:public-types`.
77
+
78
+ `AgentWidget`, `NotificationManager`, and the runtime are all internal; the published `SubagentsService` (via `service-adapter.ts`, which consumes the runtime's `currentCtx`/`buildSnapshot` only) is untouched.
79
+
80
+ ## Design Overview
81
+
82
+ ### Decision: dissolve the cycle, don't relocate the seam
83
+
84
+ Three candidate seam placements were considered (recorded for the retro):
85
+
86
+ 1. **Late-bind notifications into the observer via a setter** — rejected: violates the no-setters principle; leaves a post-construction write.
87
+ 2. **Forward-referenced `let widget` + closures into `NotificationManager`** — rejected: trips `prefer-const` → eslint-disable, reintroducing the exact smell Phase 17 Step 1 removed; not "ready-to-work."
88
+ 3. **Dissolve `NotificationManager`'s widget dependency (chosen)** — the widget owns its own finished-agent detection; `NotificationManager` no longer references the widget; the cycle disappears and every object is constructible ready-to-work in a single linear pass.
89
+
90
+ ### Tidy-first sequencing
91
+
92
+ Per "make the change that makes the change easy, then make the easy change," the work splits into a hard preparatory refactor and a mechanical follow-up:
93
+
94
+ - **Prep (Step 1, the hard part):** make the widget self-sufficient and remove the widget callbacks from `NotificationManager`.
95
+ This breaks the cycle while `SubagentRuntime` still carries its relay methods (so `AgentTool`/`ToolStartHandler` are untouched and the repo stays green).
96
+ - **Easy (Step 2):** with the cycle gone, the widget becomes a `const` injected directly into `AgentTool` and `ToolStartHandler`; the five relay methods, the `widget` field, and the post-construction write delete cleanly.
97
+
98
+ ### Widget self-seeding
99
+
100
+ Add a private helper invoked at the top of `update()`, before state assembly:
101
+
102
+ ```ts
103
+ /** Seed linger tracking for any newly-observed finished agent (replaces external markFinished). */
104
+ private seedFinishedAgents(agents: readonly AgentSummary[]): void {
105
+ for (const a of agents) {
106
+ if (a.completedAt && !this.finishedTurnAge.has(a.id)) {
107
+ this.finishedTurnAge.set(a.id, 0);
108
+ }
109
+ }
110
+ }
111
+ ```
112
+
113
+ `update()` calls `this.seedFinishedAgents(allAgents)` immediately after `const allAgents = this.manager.listAgents()`.
114
+ The existing public `markFinished(id)` stays (foreground-runner still calls it directly for immediacy, and it remains idempotent — `seedFinishedAgents` only seeds when absent).
115
+
116
+ ### NotificationManager after the dissolve
117
+
118
+ Constructor drops the last two params:
119
+
120
+ ```ts
121
+ constructor(
122
+ private sendMessage: (msg: {...}, opts?: {...}) => void,
123
+ private agentActivity: Map<string, AgentActivityTracker>,
124
+ ) {}
125
+ ```
126
+
127
+ `sendCompletion(record)` → `agentActivity.delete(record.id)`; `scheduleNudge(...)` (drop `markFinished` + `updateWidget`).
128
+ `cleanupCompleted(id)` → `agentActivity.delete(id)` (drop `markFinished` + `updateWidget`).
129
+ The `NotificationSystem` interface is unchanged (method signatures identical).
130
+
131
+ ### Consumer interfaces after the easy change (ISP — narrow per consumer)
132
+
133
+ Drop the shared 5-method `WidgetLike` from `runtime.ts`.
134
+ Each consumer declares only what it uses; `AgentWidget` satisfies all structurally:
135
+
136
+ ```ts
137
+ // agent-tool.ts — the slice AgentTool + its runner/spawner need
138
+ export interface AgentToolWidget {
139
+ setUICtx(ctx: UICtx): void;
140
+ ensureTimer(): void;
141
+ markFinished(id: string): void;
142
+ update(): void;
143
+ }
144
+
145
+ // tool-start.ts — the slice the turn handler needs
146
+ export interface ToolStartWidget {
147
+ setUICtx(ctx: unknown): void;
148
+ onTurnStart(): void;
149
+ }
150
+ ```
151
+
152
+ `AgentTool` gains a `widget: AgentToolWidget` constructor param; `execute` calls `this.widget.setUICtx(...)` and passes `this.widget` to `runForeground`/`spawnBackground` (which already accept the narrow `ForegroundWidgetDeps`/`BackgroundWidgetDeps`).
153
+ `ToolStartHandler` accepts a `ToolStartWidget` instead of `ToolStartRuntime` (rename).
154
+ `AgentToolRuntime` loses `setUICtx`, `ensureTimer`, `update`, `markFinished` — keeping `agentActivity`, `buildSnapshot`, `getModelInfo`, `getSessionInfo`.
155
+
156
+ ### Final construction order in `index.ts`
157
+
158
+ ```text
159
+ registry → runtime → notifications(sendMessage, agentActivity)
160
+ → settings → observer(notifications) → limiter → manager(observer)
161
+ → service → lifecycle(runtime) → session handlers
162
+ → const widget = new AgentWidget(manager, agentActivity, registry)
163
+ → toolStart = new ToolStartHandler(widget)
164
+ → AgentTool(manager, runtime, widget, settings, registry, agentDir)
165
+ → GetResultTool / SteerTool / agentsMenu
166
+ ```
167
+
168
+ No object is mutated after construction; the widget is a `const`.
169
+ `service-adapter.ts` and `lifecycle.ts` keep consuming `runtime` for `currentCtx`/`buildSnapshot`/`setSessionContext` — none of which involve the widget.
170
+
171
+ ## Module-Level Changes
172
+
173
+ - `src/observation/notification.ts` — remove `markFinished` and `updateWidget` constructor params; drop their calls in `sendCompletion`/`cleanupCompleted`.
174
+ `NotificationSystem` interface unchanged.
175
+ - `src/ui/agent-widget.ts` — add private `seedFinishedAgents(agents)`; call it at the top of `update()` after `listAgents()`.
176
+ `markFinished` retained (idempotent).
177
+ - `src/runtime.ts` — delete the `WidgetLike` interface, the `widget` field, and the five relay methods (`setUICtx`, `onTurnStart`, `markFinished`, `update`, `ensureTimer`); remove the now-unused `UICtx` import.
178
+ Update the class/file doc comment to drop "Persistent widget reference" / "Widget delegation methods."
179
+ - `src/tools/agent-tool.ts` — add `AgentToolWidget` interface; add `widget` constructor param; `execute` uses `this.widget` for `setUICtx` and passes it to runner/spawner; narrow `AgentToolRuntime` (drop 4 widget methods); remove now-unused `WidgetLike`-related imports if any.
180
+ - `src/handlers/tool-start.ts` — rename `ToolStartRuntime` → `ToolStartWidget`; constructor takes the widget; `handleToolExecutionStart` calls `this.widget.setUICtx`/`this.widget.onTurnStart`.
181
+ - `src/index.ts` — construct `NotificationManager` with two args; construct `const widget = new AgentWidget(...)` after the manager; pass `widget` to `new ToolStartHandler(...)` and `new AgentTool(...)`; remove the `runtime.widget =` line and the stale comment block above the `NotificationManager` construction.
182
+ - `test/observation/notification.test.ts` — drop the `markFinished`/`updateWidget` stub fields and the two `toHaveBeenCalled` assertions; assert `agentActivity.delete` + nudge behavior instead.
183
+ - `test/ui/agent-widget.test.ts` — add coverage: `update()` self-seeds `finishedTurnAge` for a completed agent in `listAgents()`; the agent then expires after `maxAge` turns via `onTurnStart`; an already-seeded agent is not re-seeded.
184
+ - `test/runtime.test.ts` — remove the "widget delegation methods" describe block, the "widget field accepts a `WidgetLike` stub" test, the `runtime.widget` default assertion, the `WidgetLike` import, and the `createWidgetStub` helper.
185
+ - `test/helpers/make-deps.ts` — add a `widget` field (stub satisfying `AgentToolWidget`) to `AgentToolFixture`; narrow the `runtime` stub (drop `setUICtx`/`ensureTimer`/`update`/`markFinished`); update the doc comment that claims the runtime "also satisfies `BackgroundWidgetDeps`/`ForegroundWidgetDeps`."
186
+ - `test/tools/agent-tool.test.ts` — `makeTool` passes `deps.widget`; the "sets UI context on runtime" test asserts on `deps.widget.setUICtx`.
187
+ - `test/handlers/tool-start.test.ts` — construct `ToolStartHandler` with a widget stub; assert on `widget.setUICtx`/`widget.onTurnStart`.
188
+ - `docs/architecture/architecture.md` — rewrite the Step 6 `- Change:` / `- Outcome:` bullets to the dissolve approach; add a `- Landed:` bullet and mark the heading `✅ Complete`.
189
+ The findings-summary line (~915) describing the smell stays accurate and is left unchanged.
190
+
191
+ No `.pi/skills/package-pi-subagents/SKILL.md` references to `runtime.widget`, `WidgetLike`, or the relay methods exist (grep-verified), so no skill update is required.
192
+
193
+ ## Test Impact Analysis
194
+
195
+ 1. **New tests enabled by the extraction.**
196
+ Widget self-seeding (`seedFinishedAgents`) becomes directly unit-testable on `AgentWidget` via a stub manager whose `listAgents()` returns a completed record — previously the seeding path was only reachable through `NotificationManager.markFinished`.
197
+ `NotificationManager` becomes testable without any widget stub at all.
198
+ 2. **Tests simplified / made redundant.**
199
+ The two `NotificationManager` assertions on `markFinished`/`updateWidget` are deleted (the behavior moved to the widget).
200
+ The entire `SubagentRuntime` "widget delegation methods" describe block (5 trivial pass-through tests) and the `WidgetLike` stub plumbing are removed — pure relay tests with no remaining subject.
201
+ 3. **Tests that must stay as-is.**
202
+ The widget's render/linger/timer tests in `agent-widget.test.ts` (turn-aging via `onTurnStart`, `shouldShowFinished` thresholds, idle-path `clearWidget`) genuinely exercise the layer and are unchanged — the new self-seed test sits alongside them.
203
+ `NotificationManager`'s nudge-scheduling and `sendMessage` tests are unchanged.
204
+
205
+ ## Invariants at risk
206
+
207
+ This step touches surfaces refactored by earlier Phase 17 steps; their documented outcomes must not regress:
208
+
209
+ - **Step 1 (`#381`) — "every spawned agent has a `promise` at spawn; no forward-ref `prefer-const` eslint-disable."**
210
+ Pinned by the limiter/manager tests in `test/lifecycle/`.
211
+ Reintroducing a forward-ref `let widget` would regress this spirit — the chosen dissolve approach specifically avoids it.
212
+ - **Step 5 (`#376`) — "`index.ts` < 170 lines; observer's three concerns unit-tested directly."**
213
+ This step removes lines from `index.ts` (relay wiring + `runtime.widget =`), so the line budget is preserved or improved; `SubagentEventsObserver` is untouched.
214
+ No new behavior is added to `index.ts`.
215
+ - **Construct-complete (principle 8), the roadmap-wide Category-B/C invariant.**
216
+ Pinned after this change by the absence of any `runtime.widget =` (grep-verifiable) and by `test/runtime.test.ts` no longer constructing a widget — add the grep check to the acceptance criteria.
217
+
218
+ ## TDD Order
219
+
220
+ 1. **Widget self-detects finished agents (prep — make it easy).**
221
+ - Red: in `test/ui/agent-widget.test.ts`, assert `update()` seeds `finishedTurnAge` for a completed agent returned by `listAgents()` (rendered as finished), that it expires after `maxAge` turns via `onTurnStart`, and that an already-tracked agent is not re-seeded.
222
+ - Green: add `private seedFinishedAgents(agents)` and call it at the top of `update()`.
223
+ - Commit: `test:` then `feat:` — or a single `feat: self-seed finished agents in AgentWidget.update (#377)`.
224
+ 2. **Drop widget callbacks from NotificationManager (prep — breaks the cycle).**
225
+ - Red/Green: update `test/observation/notification.test.ts` to construct `NotificationManager` with two args and assert `agentActivity.delete` + nudge (drop the `markFinished`/`updateWidget` assertions); remove the two callbacks from the constructor and from `sendCompletion`/`cleanupCompleted`; update the `index.ts` construction site in the same commit (sole call site — the type checker requires it atomic).
226
+ - Commit: `refactor: dissolve NotificationManager widget dependency (#377)`.
227
+ - Outcome: the construction cycle is gone; `SubagentRuntime` still carries its relay methods (repo green).
228
+ 3. **Inject the widget directly; remove relay methods and the widget field from SubagentRuntime (the easy change).**
229
+ - This is one atomic commit: removing the `widget` field, the five relay methods, and narrowing `AgentToolRuntime` breaks `AgentTool`, `ToolStartHandler`, `index.ts`, and their tests at the type level simultaneously (export/field removal — per the workflow rule, fold all consumer and consumer-test updates into one step).
230
+ - Changes: add `AgentToolWidget` + `widget` param to `AgentTool`; rename `ToolStartRuntime` → `ToolStartWidget` and accept the widget in `ToolStartHandler`; delete `WidgetLike` + `widget` + 5 relay methods from `runtime.ts`; construct `const widget` and reorder `index.ts` (remove the `runtime.widget =` write); update `make-deps.ts` (add `widget` stub, narrow `runtime`), `agent-tool.test.ts`, `tool-start.test.ts`, and `runtime.test.ts` (delete the delegation describe block + `WidgetLike` plumbing).
231
+ - Commit: `refactor: inject widget directly, remove relay methods from SubagentRuntime (#377)`.
232
+ - Acceptance: `rg 'runtime\.widget|\.widget =' src/` returns nothing; `rg 'WidgetLike' src/ test/` returns nothing; `pnpm run check && pnpm run lint && pnpm -r run test && pnpm fallow dead-code` clean.
233
+ 4. **Update the architecture roadmap.**
234
+ - Rewrite Step 6 `- Change:`/`- Outcome:` to the dissolve approach, add `- Landed:`, mark `✅ Complete`.
235
+ - Commit: `docs: record Phase 17 Step 6 widget-delegation split (#377)`.
236
+
237
+ ## Risks and Mitigations
238
+
239
+ - **Risk: self-seed timing differs from the old immediate `markFinished`/`update`.**
240
+ Mitigation: the timer is always running at a background completion (the agent was active); the seed lands ≤80ms later within the same turn; linger expiry is turn-based, so the rendered outcome is identical.
241
+ The new widget test pins the seed-then-expire behavior.
242
+ - **Risk: a completed agent never expires if it is never seeded.**
243
+ Mitigation: `seedFinishedAgents` runs on every `update()` tick while the widget is live; the idle-path `clearWidget` only fires once nothing is active or finished, so a finished agent is always seeded before the widget can go idle.
244
+ - **Risk: removing exports/fields breaks consumers mid-refactor.**
245
+ Mitigation: Step 2 breaks the cycle while leaving the relay methods in place (repo green); Step 3 folds the export/field removal and all consumers + tests into one atomic commit.
246
+ - **Risk: regressing an earlier Phase 17 invariant (forward-ref dance, `index.ts` budget).**
247
+ Mitigation: the dissolve approach introduces no `let widget` and only removes lines from `index.ts`; the Invariants-at-risk grep checks are in the Step 3 acceptance criteria.
248
+
249
+ ## Open Questions
250
+
251
+ - Should `AgentTool` receive one `AgentToolWidget` and forward it, or should it receive the narrower `ForegroundWidgetDeps`/`BackgroundWidgetDeps` separately?
252
+ Plan assumes a single `AgentToolWidget` superset (it also calls `setUICtx` itself) forwarded to the runner/spawner — revisit only if ISP pressure appears during implementation.
253
+ - Whether foreground-runner's explicit `markFinished(fgId)` is now redundant given widget self-seeding.
254
+ Plan keeps it (immediacy; idempotent) to hold scope tight; a follow-up could remove it if the self-seed proves sufficient.
@@ -38,3 +38,44 @@ Pre-completion reviewer: PASS.
38
38
  - The autoformatter (`pi-autoformat`) ran after writing `index.ts`, so the import block was reflowed; re-reading before further edits would be required in any follow-up session.
39
39
  - Architecture doc: Step 4's header was already missing its ✅ Complete marker (it had a Landed note); the reviewer noted this was corrected correctly, not a regression.
40
40
  - All five previously-untested observer-behavior invariants are now pinned by tests for the first time.
41
+
42
+ ## Stage: Final Retrospective (2026-06-15T22:55:15Z)
43
+
44
+ ### Session summary
45
+
46
+ Phase 17 Step 5 ran cleanly end-to-end across planning, TDD, ship, and retro: one `refactor:` extraction commit plus a `docs:` roadmap update, +15 tests, `index.ts` 226 → 177 lines, released as `pi-subagents-v16.2.2`.
47
+ The plan correctly anticipated the class/`index.ts` coupling (one commit) and the `unbound-method` trap (arrow callbacks), so implementation followed the plan with a single minor test-assertion adjustment.
48
+ The pre-completion reviewer (on `claude-sonnet-4-6`) returned PASS and incidentally flagged a stale Step 4 checkmark that was corrected.
49
+
50
+ ### Observations
51
+
52
+ #### What went well
53
+
54
+ - The lint feedback loop caught the only defect before it was committed: typed `vi.fn` made `mock.calls[0]!` an unnecessary assertion, surfaced by `pnpm run lint` between green and commit, fixed by switching to `toHaveBeenCalledWith(...)`.
55
+ No follow-up commit was needed — verification ran incrementally (lint + test before each commit, `fallow dead-code` before push).
56
+ - The `pre-completion-reviewer` subagent earned its dispatch: beyond confirming the deterministic gates, it caught that Phase 17 Step 4's header was missing its `✅ Complete` marker (a prior-session oversight) and validated all six Mermaid diagrams.
57
+ - The plan's pre-work paid off — the coupling note (class + consumer + import removal in one commit to satisfy `fallow`) and the arrow-callback note for `emit`/`appendEntry` meant zero rework at those two known-risk points.
58
+
59
+ #### What caused friction (agent side)
60
+
61
+ - `missing-context` (self-identified) — the TDD test was written with `mock.calls[0]!` non-null assertions; typed `vi.fn<(channel: string, data: unknown) => void>()` makes the call tuple non-optional, so `@typescript-eslint/no-unnecessary-type-assertion` fired on every `!`.
62
+ Impact: ~5 tool calls (turns 1–6) to diagnose and rewrite four assertions to `toHaveBeenCalledWith(...)`; caught by lint before commit, no rework.
63
+ - `missing-context` (self-identified) — the ship stage used `grep -oP` (Perl regex) to extract issue numbers; macOS BSD `grep` has no `-P`.
64
+ Impact: one wasted tool call (turn 38), self-recovered with `sed` on turn 39.
65
+
66
+ #### What caused friction (user side)
67
+
68
+ - None.
69
+ The single `ask_user` gate (release batching) was the appropriate strategic checkpoint and the user answered "close now"; no mechanical oversight was required.
70
+
71
+ ### Diagnostic details
72
+
73
+ - **Model-performance correlation** — TDD ran on `claude-sonnet-4-6` (appropriate for judgment + code authoring); the `pre-completion-reviewer` subagent ran on `claude-sonnet-4-6` per its agent frontmatter (appropriate for review).
74
+ The ship stage ran on `opencode-go/deepseek-v4-flash` — a cheaper model on a mostly-mechanical workflow (git, CI watch, issue close).
75
+ It handled the one judgment call (release batching) correctly by asking the user and read the release-PR body for sibling bumps, but the `grep -oP` slip is the kind of environment mistake a weaker model is likelier to make; net match was acceptable.
76
+ - **Escalation-delay tracking** — no `rabbit-hole`; the longest single-issue run was the ~5-call `mock.calls[0]!` diagnosis, which was methodical (read → read → edit → lint → test), not thrashing.
77
+ - **Feedback-loop gap analysis** — no gap: `pnpm run lint` and `vitest run` ran before each commit and `fallow dead-code` ran from the repo root before push, exactly as the templates prescribe.
78
+
79
+ ### Changes made
80
+
81
+ 1. `.pi/skills/testing/SKILL.md` — added a § Test assertions bullet: assert mock calls with `toHaveBeenCalledWith(...)` rather than `fn.mock.calls[0]![0]`, since a typed `vi.fn` makes the call tuple non-optional and the `!` trips `@typescript-eslint/no-unnecessary-type-assertion`.
@@ -0,0 +1,105 @@
1
+ ---
2
+ issue: 377
3
+ issue_title: "Split widget delegation out of SubagentRuntime"
4
+ ---
5
+
6
+ # Retro: #377 — Split widget delegation out of SubagentRuntime
7
+
8
+ ## Stage: Planning (2026-06-15T23:16:29Z)
9
+
10
+ ### Session summary
11
+
12
+ Produced the implementation plan for removing the `widget` field and five relay methods from `SubagentRuntime`.
13
+ Investigation surfaced that the issue's stated approach ("construct the widget before its consumers, pass the handle to `NotificationManager`") is infeasible as written — `NotificationManager` is a transitive *dependency* of the widget, forming a genuine construction cycle (`NotificationManager → widget → manager → observer → NotificationManager`).
14
+ The plan dissolves the cycle instead of relocating its late seam.
15
+
16
+ ### Observations
17
+
18
+ - **Operator steer #1 (seam placement):** rejected both a setter on the observer and a forward-referenced `let widget` closure, citing "no setters, instantiate ready-to-work, constructor DI" (principle 8).
19
+ The forward-ref option would also have trip `prefer-const` and reintroduced the exact eslint-disable smell Phase 17 Step 1 deleted.
20
+ - **Operator steer #2 (tidy-first, Kent Beck):** prompted the prep/easy decomposition.
21
+ The hard, cycle-breaking work (dissolve `NotificationManager`'s widget dependency by giving `AgentWidget` self-seeding of `finishedTurnAge`) lands first as a behavior-preserving commit; the relay-method removal then becomes a mechanical "easy change."
22
+ - **Behavior-preservation argument:** the widget's 80ms timer is always running at a background completion (the agent was active), and linger expiry is turn-based, so seeding ≤80ms later lands in the same turn — rendered outcome is identical.
23
+ This is the load-bearing claim; the new `agent-widget.test.ts` self-seed test pins it.
24
+ - **Three seam options recorded** in the plan's Design Overview for traceability: late-observer setter (rejected), forward-ref closure (rejected), dissolve (chosen).
25
+ - **Scope guard:** kept foreground-runner's explicit `markFinished` (idempotent) rather than removing it, and deferred the event-subscription widget model to Phase 18.
26
+ Two open questions logged (single `AgentToolWidget` vs. split deps; redundancy of foreground `markFinished`).
27
+ - **Not breaking / not public:** `runtime.ts` is internal (not in the rolled `service.ts` type bundle), so `verify:public-types` is not required.
28
+ - **Invariants at risk** flagged against Phase 17 Steps 1 and 5 (forward-ref dance, `index.ts` line budget); grep acceptance checks fold into the final implementation step.
29
+
30
+ ## Stage: Implementation — TDD (2026-06-15T20:19:00Z)
31
+
32
+ ### Session summary
33
+
34
+ Executed all four planned steps in order: widget self-seeding (`feat`), `NotificationManager` widget-dependency dissolve (`refactor`), direct widget injection + relay-method removal (`refactor`), and the architecture-roadmap update (`docs`).
35
+ Test count went 1009 → 1005 (+3 widget self-seed tests, −7 removed relay/field tests).
36
+ All deterministic checks green; pre-completion reviewer returned WARN (non-blocking).
37
+
38
+ ### Observations
39
+
40
+ - The tidy-first sequencing held up exactly as planned: Step 2 broke the cycle while the runtime relay methods were still in place (repo stayed green), making Step 3's export/field removal a clean atomic change.
41
+ - The cycle dissolve produced two **stale fallow suppressions** on `AgentWidget.setUICtx`/`onTurnStart` — they were `unused-class-member`-suppressed because the methods were previously reached only through the runtime relay; direct injection made them visibly used.
42
+ Removed both; amended into the Step 3 refactor commit.
43
+ - Widget-class testing required constructing `AgentWidget` with a cast manager stub (`as unknown as SubagentManager`) and a recording `UICtx`; observability of the private `finishedTurnAge` is via the `setWidget` clear-vs-register signal, which cleanly distinguishes seeded-then-aged-out from never-seeded.
44
+ - `sed` was needed for the runner/spawner test files because the widget arg appeared both inline and as standalone multiline-call lines; a `^\s*runtime,$` line match safely retargeted only the widget positional arg (never `runtime.agentActivity`).
45
+ - Two commit-hygiene corrections: an `index.ts` comment-trim fixup was first amended into the `docs` commit by mistake, then moved into the Step 3 `refactor` commit via `reset --soft` + selective re-stage (fixups must not land in `docs:` commits).
46
+ - **Reviewer verdict: WARN.**
47
+ Sole finding: `index.ts` is 177 lines (Step 5's aspirational "<170" was already overshot at its own landing, 177); the comment trim kept Step 6 net-neutral.
48
+ Cosmetic, prose-pinned only, non-blocking.
49
+ - Cross-step invariants verified by grep: no `let widget` / `prefer-const` forward-ref (Step 1), no `runtime.widget` / `.widget =` / `WidgetLike` anywhere (the issue's core outcome).
50
+
51
+ ## Stage: Final Retrospective (2026-06-16T00:30:08Z)
52
+
53
+ ### Session summary
54
+
55
+ The issue shipped cleanly across plan → TDD → ship as `pi-subagents-v16.3.0`, closing #377 and resolving Phase 17 Step 6.
56
+ The design arc was the highlight: an operator-driven pivot from the issue's stated approach (relocate the late seam) to dissolving the construction cycle entirely, sequenced tidy-first so the hard cycle-break landed before the mechanical relay removal.
57
+ The friction was concentrated in mechanical `Edit`-tool execution, not in design or verification.
58
+
59
+ ### Observations
60
+
61
+ #### What went well
62
+
63
+ - **Operator design steers compounded into the right architecture.**
64
+ The planning `ask_user` surfaced the construction cycle; the operator's "no setters, instantiate ready-to-work" steer eliminated two of three seam options, and the Kent Beck "make the change easy" prompt (turn 21) produced the tidy-first decomposition.
65
+ This is the pattern working as intended: the agent finds the constraint, the operator picks the principle, the agent verifies feasibility before committing.
66
+ - **Behavior-preservation was argued before it was coded.**
67
+ The ≤80ms-within-same-turn linger argument was established in planning and pinned by three new `agent-widget.test.ts` tests in TDD — no trial-and-error on the load-bearing claim.
68
+ - **Tidy-first sequencing kept the repo green at every commit.**
69
+ Step 2 broke the cycle while the relay methods still existed, making Step 3's export removal a clean atomic change.
70
+ - **Cheap model handled ship correctly.**
71
+ Ship ran on `opencode-go/deepseek-v4-flash` and executed the full mechanical sequence (push, CI watch, release-now `ask_user`, the `UNSTABLE` merge-state edge case via `gh pr merge`) with zero errors — an appropriate model-task match, not a mismatch.
72
+
73
+ #### What caused friction (agent side)
74
+
75
+ - `other` (tool friction) — `Edit` on `runtime.ts` failed twice (turns 45, 47) trying to delete the widget-delegation block bounded by a decorative `// ── Widget delegation methods ────` comment rule; the long run of `─` could not be reproduced exactly.
76
+ Fell back to `sed -i '' '75,104d'`, which over-deleted the class-closing brace and the next doc-comment opener (turn 51), needing a repair edit (turn 52).
77
+ Impact: ~5 extra tool calls and a near-miss structural breakage on a single block deletion.
78
+ - `instruction-violation` (user-caught) — the operator interjected "Use unicode literal characters, not escapes, per system prompt" (turn 49).
79
+ The system prompt already carries an "Edit Tool and Unicode Characters" section; the box-drawing match failures read to the operator as escape misuse.
80
+ Impact: one user correction; reinforced the same `Edit`-matching friction above.
81
+ - `other` (malformed payload) — the first `architecture.md` Step 6 edit was rejected for an invalid `oldText_unused` property I invented (turn 87), followed by empty assistant turns and two operator "Try again" nudges (turns 89, 94) before a clean single edit landed (turn 95).
82
+ Impact: ~4 turns + 2 user nudges on one prose edit.
83
+ - `other` (commit hygiene) — the `index.ts` comment-trim fixup was first amended into the `docs` commit (turn 108), then moved into the `refactor` commit via `reset --soft` (turn 109); separately, the stale-suppression fixup required the same maneuver during TDD.
84
+ Both self-identified and corrected.
85
+ Impact: two extra `reset --soft` + re-stage cycles; no rework to shipped code.
86
+
87
+ #### What caused friction (user side)
88
+
89
+ - The "no setters, instantiate ready-to-work" principle that drove the entire design only surfaced when the planning `ask_user` forced it.
90
+ Stated in the issue body or a standing design note, it would have let planning reach the dissolve approach without the round-trip — though the round-trip was cheap and the outcome correct.
91
+
92
+ ### Diagnostic details
93
+
94
+ - **Model-performance correlation** — Planning and TDD ran on `anthropic/claude-opus-4-8` (judgment-heavy design + implementation; appropriate).
95
+ The pre-completion-reviewer subagent ran on `anthropic/claude-sonnet-4-6` per its frontmatter (appropriate for review).
96
+ Ship ran on `opencode-go/deepseek-v4-flash` — a cheap model on mechanical orchestration, completed without error.
97
+ No mismatch found.
98
+ - **Escalation-delay tracking** — no `rabbit-hole` exceeded the 5-consecutive-call threshold; the `Edit`/`sed` friction was 2–3 calls per location and self-resolved.
99
+ - **Unused-tool detection** — nothing notable; the friction points were mechanical edits, not missing context, so no Explore/`colgrep` dispatch would have helped.
100
+ - **Feedback-loop gap analysis** — verification was incremental and exemplary: `pnpm run check` after the shared-interface change in Step 2 (turn 43) and again mid-Step-3 (turns 69, 75, 78, 81); per-file `vitest run` after each Red and Green; `fallow dead-code` from repo root before ship.
101
+ No end-only verification anti-pattern.
102
+
103
+ ### Changes made
104
+
105
+ 1. `AGENTS.md` — appended two sentences to the "Edit tool batches" subsection: anchor edits on adjacent unique code lines rather than decorative `─`/`═` comment rules, and re-read after a `sed` line-number deletion to confirm an enclosing brace survived.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "16.2.2",
3
+ "version": "16.3.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -21,7 +21,7 @@
21
21
  "#src/*": "./src/*",
22
22
  "#test/*": "./test/*"
23
23
  },
24
- "description": "A pi extension that brings Claude Code-style autonomous sub-agents to pi. Friendly fork of @tintinweb/pi-subagents.",
24
+ "description": "A focused, in-process sub-agent core for pi autonomous agents plus a typed API and lifecycle events other extensions build on. Friendly fork of @tintinweb/pi-subagents.",
25
25
  "author": {
26
26
  "name": "Chris Lasher"
27
27
  },
@@ -5,8 +5,8 @@
5
5
  * with a mocked narrow runtime interface.
6
6
  */
7
7
 
8
- /** Narrow runtime interface — only the widget-delegation methods the handler calls. */
9
- export interface ToolStartRuntime {
8
+ /** Narrow widget interface — only the methods the handler calls. */
9
+ export interface ToolStartWidget {
10
10
  setUICtx(ctx: unknown): void;
11
11
  onTurnStart(): void;
12
12
  }
@@ -23,10 +23,10 @@ interface ToolStartCtx {
23
23
  * and signals the widget to clear lingering state.
24
24
  */
25
25
  export class ToolStartHandler {
26
- constructor(private readonly runtime: ToolStartRuntime) {}
26
+ constructor(private readonly widget: ToolStartWidget) {}
27
27
 
28
28
  handleToolExecutionStart(_event: unknown, ctx: ToolStartCtx): void {
29
- this.runtime.setUICtx(ctx.ui);
30
- this.runtime.onTurnStart();
29
+ this.widget.setUICtx(ctx.ui);
30
+ this.widget.onTurnStart();
31
31
  }
32
32
  }
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-unsafe-argument -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */
2
2
  /**
3
- * pi-agents — A pi extension providing Claude Code-style autonomous sub-agents.
3
+ * pi-agents — A pi extension providing focused, in-process autonomous sub-agents.
4
4
  *
5
5
  * Tools:
6
6
  * Agent — LLM-callable: spawn a sub-agent
@@ -57,13 +57,12 @@ export default function (pi: ExtensionAPI) {
57
57
  const runtime = createSubagentRuntime();
58
58
 
59
59
  // ---- Notification system ----
60
- // runtime.widget is assigned after SubagentManager construction; arrow closures
61
- // capture `runtime` by reference so they always read the current value.
60
+ // Owns completion nudges and live-activity cleanup. The widget detects finished
61
+ // agents itself (AgentWidget.update self-seeds), so NotificationManager has no
62
+ // widget dependency — keeping the construction graph a cycle-free DAG.
62
63
  const notifications = new NotificationManager(
63
64
  (msg, opts) => pi.sendMessage(msg, opts),
64
65
  runtime.agentActivity,
65
- (id) => runtime.markFinished(id),
66
- () => runtime.update(),
67
66
  );
68
67
 
69
68
  // Settings: owns all three in-memory values and handles load/save/emit.
@@ -129,11 +128,12 @@ export default function (pi: ExtensionAPI) {
129
128
  pi.on("session_before_switch", () => lifecycle.handleSessionBeforeSwitch());
130
129
  pi.on("session_shutdown", () => lifecycle.handleSessionShutdown());
131
130
 
132
- // Live widget: show running agents above editor
133
- runtime.widget = new AgentWidget(manager, runtime.agentActivity, registry);
131
+ // Live widget: constructed after the manager (it polls listAgents()) and
132
+ // injected directly into its consumers — no post-construction field write.
133
+ const widget = new AgentWidget(manager, runtime.agentActivity, registry);
134
134
 
135
135
  // Grab UI context from first tool execution + clear lingering widget on new turn
136
- const toolStart = new ToolStartHandler(runtime);
136
+ const toolStart = new ToolStartHandler(widget);
137
137
  pi.on("tool_execution_start", (event, ctx) => toolStart.handleToolExecutionStart(event, ctx));
138
138
 
139
139
  // Abort all subagents when the parent agent loop is interrupted (ESC).
@@ -142,7 +142,7 @@ export default function (pi: ExtensionAPI) {
142
142
 
143
143
  // ---- Agent tool ----
144
144
 
145
- pi.registerTool(new AgentTool(manager, runtime, settings, registry, getAgentDir()).toToolDefinition());
145
+ pi.registerTool(new AgentTool(manager, runtime, widget, settings, registry, getAgentDir()).toToolDefinition());
146
146
 
147
147
  // ---- get_subagent_result tool ----
148
148
 
@@ -41,7 +41,7 @@ export function getStatusLabel(status: string, error?: string): string {
41
41
  }
42
42
  }
43
43
 
44
- /** Format a structured task notification matching Claude Code's <task-notification> XML. */
44
+ /** Format a structured <task-notification> XML block for the parent agent to parse. */
45
45
  export function formatTaskNotification(record: Subagent, resultMaxLen: number): string {
46
46
  const status = getStatusLabel(record.status, record.error);
47
47
  const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
@@ -142,8 +142,6 @@ export class NotificationManager implements NotificationSystem {
142
142
  opts?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
143
143
  ) => void,
144
144
  private agentActivity: Map<string, AgentActivityTracker>,
145
- private markFinished: (id: string) => void,
146
- private updateWidget: () => void,
147
145
  ) {}
148
146
 
149
147
  cancelNudge(key: string): void {
@@ -156,15 +154,11 @@ export class NotificationManager implements NotificationSystem {
156
154
 
157
155
  sendCompletion(record: Subagent): void {
158
156
  this.agentActivity.delete(record.id);
159
- this.markFinished(record.id);
160
157
  this.scheduleNudge(record.id, () => this.emitIndividualNudge(record));
161
- this.updateWidget();
162
158
  }
163
159
 
164
160
  cleanupCompleted(id: string): void {
165
161
  this.agentActivity.delete(id);
166
- this.markFinished(id);
167
- this.updateWidget();
168
162
  }
169
163
 
170
164
  dispose(): void {
package/src/runtime.ts CHANGED
@@ -10,19 +10,6 @@ import { buildParentSnapshot, type ParentSnapshot } from "#src/lifecycle/parent-
10
10
  import type { ModelInfo } from "#src/tools/spawn-config";
11
11
  import type { SessionContext } from "#src/types";
12
12
  import type { AgentActivityTracker } from "#src/ui/agent-activity-tracker";
13
- import type { UICtx } from "#src/ui/agent-widget";
14
-
15
- /**
16
- * Narrow widget interface consumed by SubagentRuntime delegation methods.
17
- * AgentWidget satisfies this structurally; tests use plain stubs.
18
- */
19
- export interface WidgetLike {
20
- setUICtx(ctx: UICtx): void;
21
- onTurnStart(): void;
22
- markFinished(id: string): void;
23
- update(): void;
24
- ensureTimer(): void;
25
- }
26
13
 
27
14
  /**
28
15
  * Narrow config subset read by Agent when driving the turn loop (defaultMaxTurns, graceTurns).
@@ -48,11 +35,6 @@ export class SubagentRuntime {
48
35
  * widget, and tool handlers. The Map itself is never replaced.
49
36
  */
50
37
  readonly agentActivity: Map<string, AgentActivityTracker> = new Map();
51
- /**
52
- * Persistent widget reference. Null until constructed after SubagentManager.
53
- * Delegation methods use optional chaining so callers never need `widget!`.
54
- */
55
- widget: WidgetLike | null = null;
56
38
 
57
39
  // ── Session-context methods ──────────────────────────────────────────────
58
40
 
@@ -90,33 +72,6 @@ export class SubagentRuntime {
90
72
  parentSessionId: this.currentCtx?.sessionManager.getSessionId() ?? "",
91
73
  };
92
74
  }
93
-
94
- // ── Widget delegation methods ─────────────────────────────────────────────
95
-
96
- /** Delegate to widget.setUICtx — no-op when widget is null. */
97
- setUICtx(ctx: UICtx): void {
98
- this.widget?.setUICtx(ctx);
99
- }
100
-
101
- /** Delegate to widget.onTurnStart — no-op when widget is null. */
102
- onTurnStart(): void {
103
- this.widget?.onTurnStart();
104
- }
105
-
106
- /** Delegate to widget.markFinished — no-op when widget is null. */
107
- markFinished(id: string): void {
108
- this.widget?.markFinished(id);
109
- }
110
-
111
- /** Delegate to widget.update — no-op when widget is null. */
112
- update(): void {
113
- this.widget?.update();
114
- }
115
-
116
- /** Delegate to widget.ensureTimer — no-op when widget is null. */
117
- ensureTimer(): void {
118
- this.widget?.ensureTimer();
119
- }
120
75
  }
121
76
 
122
77
  /**
@@ -41,13 +41,21 @@ export interface AgentToolManager {
41
41
  /** Narrow runtime interface — the Agent tool's slice of SubagentRuntime. */
42
42
  export interface AgentToolRuntime {
43
43
  readonly agentActivity: AgentActivityAccess;
44
+ buildSnapshot(inheritContext: boolean): ParentSnapshot;
45
+ getModelInfo(): ModelInfo;
46
+ getSessionInfo(): { parentSessionFile: string; parentSessionId: string };
47
+ }
48
+
49
+ /**
50
+ * Narrow widget interface the Agent tool drives directly.
51
+ * Superset of the runner/spawner widget deps plus `setUICtx`.
52
+ * AgentWidget satisfies it structurally.
53
+ */
54
+ export interface AgentToolWidget {
44
55
  setUICtx(ctx: UICtx): void;
45
56
  ensureTimer(): void;
46
57
  update(): void;
47
58
  markFinished(id: string): void;
48
- buildSnapshot(inheritContext: boolean): ParentSnapshot;
49
- getModelInfo(): ModelInfo;
50
- getSessionInfo(): { parentSessionFile: string; parentSessionId: string };
51
59
  }
52
60
 
53
61
  /** Narrow settings accessor — only the fields the Agent tool reads. */
@@ -65,6 +73,7 @@ export class AgentTool {
65
73
  constructor(
66
74
  private readonly manager: AgentToolManager,
67
75
  private readonly runtime: AgentToolRuntime,
76
+ private readonly widget: AgentToolWidget,
68
77
  private readonly settings: AgentToolSettings,
69
78
  private readonly registry: AgentTypeRegistry,
70
79
  private readonly agentDir: string,
@@ -81,7 +90,7 @@ export class AgentTool {
81
90
  ctx: any,
82
91
  ) {
83
92
  // Ensure we have UI context for widget rendering
84
- this.runtime.setUICtx(ctx.ui as UICtx);
93
+ this.widget.setUICtx(ctx.ui as UICtx);
85
94
 
86
95
  // Reload custom agents so new .pi/agents/*.md files are picked up without restart
87
96
  this.registry.reload();
@@ -131,7 +140,7 @@ export class AgentTool {
131
140
  if (config.execution.runInBackground) {
132
141
  return spawnBackground(
133
142
  this.manager,
134
- this.runtime,
143
+ this.widget,
135
144
  this.runtime.agentActivity,
136
145
  { config, snapshot, parentSession, settings: this.settings },
137
146
  );
@@ -140,7 +149,7 @@ export class AgentTool {
140
149
  // ---- Foreground execution — stream progress via onUpdate ----
141
150
  return runForeground(
142
151
  this.manager,
143
- this.runtime,
152
+ this.widget,
144
153
  this.runtime.agentActivity,
145
154
  { config, snapshot, parentSession },
146
155
  signal,
@@ -227,7 +236,7 @@ Guidelines:
227
236
  ),
228
237
  }),
229
238
 
230
- // ---- Custom rendering: Claude Code style ----
239
+ // ---- Custom rendering: inline subagent results ----
231
240
 
232
241
  renderCall(args: Record<string, unknown>, theme: any) {
233
242
  const displayName = args.subagent_type
@@ -84,7 +84,6 @@ export class AgentWidget {
84
84
  ) {}
85
85
 
86
86
  /** Set the UI context (grabbed from first tool execution). */
87
- // fallow-ignore-next-line unused-class-member
88
87
  setUICtx(ctx: UICtx) {
89
88
  if (ctx !== this.uiCtx) {
90
89
  // UICtx changed — the widget registered on the old context is gone.
@@ -100,7 +99,6 @@ export class AgentWidget {
100
99
  * Called on each new turn (tool_execution_start).
101
100
  * Ages finished agents and clears those that have lingered long enough.
102
101
  */
103
- // fallow-ignore-next-line unused-class-member
104
102
  onTurnStart() {
105
103
  // Age all finished agents
106
104
  for (const [id, age] of this.finishedTurnAge) {
@@ -184,11 +182,27 @@ export class AgentWidget {
184
182
  }
185
183
  }
186
184
 
185
+ /**
186
+ * Seed linger tracking for any newly-observed finished agent.
187
+ * Replaces the external `markFinished` call NotificationManager used to make:
188
+ * the widget owns detection of completions it sees via `listAgents()`.
189
+ * Idempotent — only seeds when an entry is absent, so repeated updates within
190
+ * a turn neither reset nor advance the age.
191
+ */
192
+ private seedFinishedAgents(agents: readonly AgentSummary[]): void {
193
+ for (const a of agents) {
194
+ if (a.completedAt && !this.finishedTurnAge.has(a.id)) {
195
+ this.finishedTurnAge.set(a.id, 0);
196
+ }
197
+ }
198
+ }
199
+
187
200
  /** Force an immediate widget update. */
188
201
  update() {
189
202
  if (!this.uiCtx) return;
190
203
 
191
204
  const allAgents = this.manager.listAgents();
205
+ this.seedFinishedAgents(allAgents);
192
206
  const state = assembleWidgetState(allAgents, (id, status) => this.shouldShowFinished(id, status));
193
207
 
194
208
  if (!state.hasActive && !state.hasFinished) {