@gotgenes/pi-subagents 16.2.2 → 16.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +7 -78
- package/docs/architecture/architecture.md +10 -4
- package/docs/comparison-with-upstream.md +3 -2
- package/docs/plans/0377-split-widget-delegation-from-runtime.md +254 -0
- package/docs/retro/0376-extract-manager-observer.md +41 -0
- package/docs/retro/0377-split-widget-delegation-from-runtime.md +49 -0
- package/package.json +1 -1
- package/src/handlers/tool-start.ts +5 -5
- package/src/index.ts +8 -8
- package/src/observation/notification.ts +0 -6
- package/src/runtime.ts +0 -45
- package/src/tools/agent-tool.ts +15 -6
- package/src/ui/agent-widget.ts +16 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ 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.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.2.2...pi-subagents-v16.3.0) (2026-06-16)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* 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))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* **pi-subagents:** drop removed memory, skills, isolation surfaces ([7cf20ee](https://github.com/gotgenes/pi-packages/commit/7cf20eebf109c979a864000cd8b9d84a13d0df90))
|
|
19
|
+
|
|
8
20
|
## [16.2.2](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.2.1...pi-subagents-v16.2.2) (2026-06-15)
|
|
9
21
|
|
|
10
22
|
|
package/README.md
CHANGED
|
@@ -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
|
|
@@ -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`,
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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/
|
|
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
|
-
-
|
|
997
|
-
|
|
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
|
|
|
@@ -28,8 +28,7 @@ Both extensions provide the same core experience:
|
|
|
28
28
|
|
|
29
29
|
- Claude Code-style 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
|
-
-
|
|
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,49 @@
|
|
|
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).
|
package/package.json
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* with a mocked narrow runtime interface.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
/** Narrow
|
|
9
|
-
export interface
|
|
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
|
|
26
|
+
constructor(private readonly widget: ToolStartWidget) {}
|
|
27
27
|
|
|
28
28
|
handleToolExecutionStart(_event: unknown, ctx: ToolStartCtx): void {
|
|
29
|
-
this.
|
|
30
|
-
this.
|
|
29
|
+
this.widget.setUICtx(ctx.ui);
|
|
30
|
+
this.widget.onTurnStart();
|
|
31
31
|
}
|
|
32
32
|
}
|
package/src/index.ts
CHANGED
|
@@ -57,13 +57,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
57
57
|
const runtime = createSubagentRuntime();
|
|
58
58
|
|
|
59
59
|
// ---- Notification system ----
|
|
60
|
-
//
|
|
61
|
-
//
|
|
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:
|
|
133
|
-
|
|
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(
|
|
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
|
|
|
@@ -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
|
/**
|
package/src/tools/agent-tool.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
152
|
+
this.widget,
|
|
144
153
|
this.runtime.agentActivity,
|
|
145
154
|
{ config, snapshot, parentSession },
|
|
146
155
|
signal,
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -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) {
|