@gotgenes/pi-subagents 16.2.1 → 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 +19 -0
- package/README.md +16 -145
- package/docs/architecture/architecture.md +15 -8
- package/docs/comparison-with-upstream.md +77 -0
- package/docs/plans/0376-extract-manager-observer.md +232 -0
- package/docs/plans/0377-split-widget-delegation-from-runtime.md +254 -0
- package/docs/retro/0375-extract-run-listener-workspace-bracket.md +53 -0
- package/docs/retro/0376-extract-manager-observer.md +81 -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 +16 -65
- package/src/observation/notification.ts +0 -6
- package/src/observation/subagent-events-observer.ts +97 -0
- package/src/runtime.ts +0 -45
- package/src/tools/agent-tool.ts +15 -6
- package/src/ui/agent-widget.ts +16 -2
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 376
|
|
3
|
+
issue_title: "Extract the manager observer from index.ts into a class"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #376 — Extract the manager observer from index.ts into a class
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-06-15T00:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Planned Phase 17 Step 5: extracting the inline `SubagentManagerObserver` literal from `index.ts` into a `SubagentEventsObserver` class under `src/observation/`, constructed with narrow `emit` / `appendEntry` / `NotificationSystem` deps.
|
|
13
|
+
The plan is a single red→green→commit extraction (class + tests + `index.ts` wiring in one commit) plus a docs commit marking the step complete.
|
|
14
|
+
Classified as a non-breaking, pure internal extraction with no observable behavior change.
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
- The issue is the operator's own (author `gotgenes` matches the gh user) and the architecture doc already specifies Step 5 precisely, so the `ask-user` gate was skipped.
|
|
19
|
+
- The class + `index.ts` swap must land in one commit: `index.ts` is the sole call site of the literal being replaced, and the new class needs a consumer to satisfy `pnpm fallow dead-code`.
|
|
20
|
+
- Kept `buildEventData` in `notification.ts` (it is tested there) and imported it into the new module — avoids churning `notification.test.ts`.
|
|
21
|
+
- Used `refactor:` for the extraction commit, matching the precedent of Phase 17 Step 4 (#375); `refactor` is hidden from the release-please changelog.
|
|
22
|
+
- Two structural smells were noted as out of scope: the `record.notification?.resultConsumed` Law-of-Demeter chain (track-and-watch) and narrowing `NotificationSystem` to a two-method `CompletionNotifier` per ISP (the issue prescribes passing `NotificationSystem`).
|
|
23
|
+
- Wiring `pi.events.emit` / `pi.appendEntry` as arrow callbacks in `index.ts` avoids the `@typescript-eslint/unbound-method` trap; mirrors the existing `SettingsManager` emit pattern.
|
|
24
|
+
- Step 6 (#377) depends on this step; the plan pins the previously-untested event/notification dispatch invariants so Step 6 cannot regress them.
|
|
25
|
+
|
|
26
|
+
## Stage: Implementation — TDD (2026-06-15T18:40:00Z)
|
|
27
|
+
|
|
28
|
+
### Session summary
|
|
29
|
+
|
|
30
|
+
Completed 2 TDD cycles: (1) extracted `SubagentEventsObserver` into `src/observation/subagent-events-observer.ts`, added 15 tests covering all four observer methods, and updated `src/index.ts` to replace the inline literal — all in one coupled commit; (2) marked Phase 17 Step 5 ✅ Complete in the architecture doc with a Landed note.
|
|
31
|
+
Test delta: 994 → 1009 (+15); file count 63 → 64 test files; `index.ts` 226 → 177 lines.
|
|
32
|
+
Pre-completion reviewer: PASS.
|
|
33
|
+
|
|
34
|
+
### Observations
|
|
35
|
+
|
|
36
|
+
- The typed `vi.fn<(channel: string, data: unknown) => void>()` mock triggered `@typescript-eslint/no-unnecessary-type-assertion` on `mock.calls[0]!` indexing — fixed by switching the error-status assertions to `toHaveBeenCalledWith("subagents:failed", expect.anything())` and the success-status assertion to `toHaveBeenCalledWith("subagents:completed", buildEventData(record))`.
|
|
37
|
+
This eliminated all raw `mock.calls[0]` indexing from the file, which is cleaner.
|
|
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
|
+
- 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
|
+
- 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
|
@@ -27,9 +27,10 @@ import { createChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
|
|
|
27
27
|
import { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
|
|
28
28
|
import { createSubagentSession, type SubagentSessionDeps } from "#src/lifecycle/create-subagent-session";
|
|
29
29
|
import { buildParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
30
|
-
import { SubagentManager
|
|
31
|
-
import {
|
|
30
|
+
import { SubagentManager } from "#src/lifecycle/subagent-manager";
|
|
31
|
+
import { type NotificationDetails, NotificationManager } from "#src/observation/notification";
|
|
32
32
|
import { createNotificationRenderer } from "#src/observation/renderer";
|
|
33
|
+
import { SubagentEventsObserver } from "#src/observation/subagent-events-observer";
|
|
33
34
|
import { createSubagentRuntime } from "#src/runtime";
|
|
34
35
|
import { publishSubagentsService, unpublishSubagentsService } from "#src/service/service";
|
|
35
36
|
import { SubagentsServiceAdapter } from "#src/service/service-adapter";
|
|
@@ -56,13 +57,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
56
57
|
const runtime = createSubagentRuntime();
|
|
57
58
|
|
|
58
59
|
// ---- Notification system ----
|
|
59
|
-
//
|
|
60
|
-
//
|
|
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.
|
|
61
63
|
const notifications = new NotificationManager(
|
|
62
64
|
(msg, opts) => pi.sendMessage(msg, opts),
|
|
63
65
|
runtime.agentActivity,
|
|
64
|
-
(id) => runtime.markFinished(id),
|
|
65
|
-
() => runtime.update(),
|
|
66
66
|
);
|
|
67
67
|
|
|
68
68
|
// Settings: owns all three in-memory values and handles load/save/emit.
|
|
@@ -76,61 +76,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
76
76
|
settings.load();
|
|
77
77
|
|
|
78
78
|
// Observer: receives agent lifecycle notifications and dispatches events/notifications.
|
|
79
|
-
const observer
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
type: record.type,
|
|
85
|
-
description: record.description,
|
|
86
|
-
});
|
|
87
|
-
},
|
|
88
|
-
onSubagentCompleted(record) {
|
|
89
|
-
// Emit lifecycle event based on terminal status.
|
|
90
|
-
const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
|
|
91
|
-
const eventData = buildEventData(record);
|
|
92
|
-
if (isError) {
|
|
93
|
-
pi.events.emit("subagents:failed", eventData);
|
|
94
|
-
} else {
|
|
95
|
-
pi.events.emit("subagents:completed", eventData);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Persist final record for cross-extension history reconstruction.
|
|
99
|
-
pi.appendEntry("subagents:record", {
|
|
100
|
-
id: record.id, type: record.type, description: record.description,
|
|
101
|
-
status: record.status, result: record.result, error: record.error,
|
|
102
|
-
startedAt: record.startedAt, completedAt: record.completedAt,
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
// Skip notification if result was already consumed via get_subagent_result.
|
|
106
|
-
if (record.notification?.resultConsumed) {
|
|
107
|
-
notifications.cleanupCompleted(record.id);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
notifications.sendCompletion(record);
|
|
112
|
-
},
|
|
113
|
-
onSubagentCompacted(record, info) {
|
|
114
|
-
// Emit compacted event when agent's session compacts (preserves count on record).
|
|
115
|
-
pi.events.emit("subagents:compacted", {
|
|
116
|
-
id: record.id,
|
|
117
|
-
type: record.type,
|
|
118
|
-
description: record.description,
|
|
119
|
-
reason: info.reason,
|
|
120
|
-
tokensBefore: info.tokensBefore,
|
|
121
|
-
compactionCount: record.compactionCount,
|
|
122
|
-
});
|
|
123
|
-
},
|
|
124
|
-
onSubagentCreated(record) {
|
|
125
|
-
// Emit created event for background agents (before limiter admission).
|
|
126
|
-
pi.events.emit("subagents:created", {
|
|
127
|
-
id: record.id,
|
|
128
|
-
type: record.type,
|
|
129
|
-
description: record.description,
|
|
130
|
-
isBackground: true,
|
|
131
|
-
});
|
|
132
|
-
},
|
|
133
|
-
};
|
|
79
|
+
const observer = new SubagentEventsObserver({
|
|
80
|
+
emit: (channel, data) => pi.events.emit(channel, data),
|
|
81
|
+
appendEntry: (customType, data) => pi.appendEntry(customType, data),
|
|
82
|
+
notifications,
|
|
83
|
+
});
|
|
134
84
|
|
|
135
85
|
const subagentSessionDeps: SubagentSessionDeps = {
|
|
136
86
|
io: {
|
|
@@ -178,11 +128,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
178
128
|
pi.on("session_before_switch", () => lifecycle.handleSessionBeforeSwitch());
|
|
179
129
|
pi.on("session_shutdown", () => lifecycle.handleSessionShutdown());
|
|
180
130
|
|
|
181
|
-
// Live widget:
|
|
182
|
-
|
|
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);
|
|
183
134
|
|
|
184
135
|
// Grab UI context from first tool execution + clear lingering widget on new turn
|
|
185
|
-
const toolStart = new ToolStartHandler(
|
|
136
|
+
const toolStart = new ToolStartHandler(widget);
|
|
186
137
|
pi.on("tool_execution_start", (event, ctx) => toolStart.handleToolExecutionStart(event, ctx));
|
|
187
138
|
|
|
188
139
|
// Abort all subagents when the parent agent loop is interrupted (ESC).
|
|
@@ -191,7 +142,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
191
142
|
|
|
192
143
|
// ---- Agent tool ----
|
|
193
144
|
|
|
194
|
-
pi.registerTool(new AgentTool(manager, runtime, settings, registry, getAgentDir()).toToolDefinition());
|
|
145
|
+
pi.registerTool(new AgentTool(manager, runtime, widget, settings, registry, getAgentDir()).toToolDefinition());
|
|
195
146
|
|
|
196
147
|
// ---- get_subagent_result tool ----
|
|
197
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 {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { SubagentManagerObserver } from "#src/lifecycle/subagent-manager";
|
|
2
|
+
import { buildEventData, type NotificationSystem } from "#src/observation/notification";
|
|
3
|
+
import type { CompactionInfo, Subagent } from "#src/types";
|
|
4
|
+
|
|
5
|
+
/** Emit callback — a subset of `pi.events.emit`. */
|
|
6
|
+
export type EventEmit = (channel: string, data: unknown) => void;
|
|
7
|
+
|
|
8
|
+
/** Append callback — a subset of `pi.appendEntry`. */
|
|
9
|
+
export type AppendEntry = (customType: string, data: unknown) => void;
|
|
10
|
+
|
|
11
|
+
export interface SubagentEventsObserverDeps {
|
|
12
|
+
emit: EventEmit;
|
|
13
|
+
appendEntry: AppendEntry;
|
|
14
|
+
notifications: NotificationSystem;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Receives agent lifecycle notifications from SubagentManager and dispatches
|
|
19
|
+
* them to three concerns: pi.events lifecycle events, session-entry persistence,
|
|
20
|
+
* and completion notifications.
|
|
21
|
+
*
|
|
22
|
+
* Constructed with narrow deps (emit, appendEntry, NotificationSystem) so all
|
|
23
|
+
* three concerns are unit-testable without booting the extension.
|
|
24
|
+
*/
|
|
25
|
+
export class SubagentEventsObserver implements SubagentManagerObserver {
|
|
26
|
+
private readonly emit: EventEmit;
|
|
27
|
+
private readonly appendEntry: AppendEntry;
|
|
28
|
+
private readonly notifications: NotificationSystem;
|
|
29
|
+
|
|
30
|
+
constructor(deps: SubagentEventsObserverDeps) {
|
|
31
|
+
this.emit = deps.emit;
|
|
32
|
+
this.appendEntry = deps.appendEntry;
|
|
33
|
+
this.notifications = deps.notifications;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
onSubagentStarted(record: Subagent): void {
|
|
37
|
+
// Emit started event when agent transitions to running (including from queue).
|
|
38
|
+
this.emit("subagents:started", {
|
|
39
|
+
id: record.id,
|
|
40
|
+
type: record.type,
|
|
41
|
+
description: record.description,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onSubagentCompleted(record: Subagent): void {
|
|
46
|
+
// Emit lifecycle event based on terminal status.
|
|
47
|
+
const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
|
|
48
|
+
const eventData = buildEventData(record);
|
|
49
|
+
if (isError) {
|
|
50
|
+
this.emit("subagents:failed", eventData);
|
|
51
|
+
} else {
|
|
52
|
+
this.emit("subagents:completed", eventData);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Persist final record for cross-extension history reconstruction.
|
|
56
|
+
this.appendEntry("subagents:record", {
|
|
57
|
+
id: record.id,
|
|
58
|
+
type: record.type,
|
|
59
|
+
description: record.description,
|
|
60
|
+
status: record.status,
|
|
61
|
+
result: record.result,
|
|
62
|
+
error: record.error,
|
|
63
|
+
startedAt: record.startedAt,
|
|
64
|
+
completedAt: record.completedAt,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Skip notification if result was already consumed via get_subagent_result.
|
|
68
|
+
if (record.notification?.resultConsumed) {
|
|
69
|
+
this.notifications.cleanupCompleted(record.id);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.notifications.sendCompletion(record);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
onSubagentCompacted(record: Subagent, info: CompactionInfo): void {
|
|
77
|
+
// Emit compacted event when agent's session compacts (preserves count on record).
|
|
78
|
+
this.emit("subagents:compacted", {
|
|
79
|
+
id: record.id,
|
|
80
|
+
type: record.type,
|
|
81
|
+
description: record.description,
|
|
82
|
+
reason: info.reason,
|
|
83
|
+
tokensBefore: info.tokensBefore,
|
|
84
|
+
compactionCount: record.compactionCount,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
onSubagentCreated(record: Subagent): void {
|
|
89
|
+
// Emit created event for background agents (before limiter admission).
|
|
90
|
+
this.emit("subagents:created", {
|
|
91
|
+
id: record.id,
|
|
92
|
+
type: record.type,
|
|
93
|
+
description: record.description,
|
|
94
|
+
isBackground: true,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
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) {
|