@gotgenes/pi-subagents 6.15.0 → 6.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ ---
2
+ issue: 144
3
+ issue_title: "Consolidate observation model (Phase 9, Step L)"
4
+ ---
5
+
6
+ # Retro: #144 — Consolidate observation model
7
+
8
+ ## Final Retrospective (2026-05-23)
9
+
10
+ ### Session summary
11
+
12
+ Planned and implemented the Phase 9 Step L observation model consolidation.
13
+ Removed dual `_toolUses`/`_lifetimeUsage` counting from `AgentActivityTracker`, added `session`/`outputFile` convenience getters to `AgentRecord`, migrated 14 callsites, and dissolved `NotificationDeps` into plain constructor parameters.
14
+ Released as `pi-subagents-v6.16.0`.
15
+
16
+ ### Observations
17
+
18
+ #### What went well
19
+
20
+ - The plan correctly anticipated that TDD steps 4 (remove tracker stats) and 5 (migrate UI consumers) would be type-coupled and need merging.
21
+ This played out exactly as predicted — no surprise rework.
22
+ - Step 2's grep sweep during `execution?.` migration found two callsites (`agent-tool.ts:315`, `agent-manager.ts:353`) that the plan's file list missed.
23
+ Systematic grep at migration time caught them before commit.
24
+
25
+ #### What caused friction (agent side)
26
+
27
+ - `instruction-violation` — Did not load the `colgrep` skill during the planning phase despite two explicit instructions: AGENTS.md ("Use `colgrep` for intent-based codebase exploration") and the `/plan-issue` prompt ("load the `code-design` skill and the `colgrep` skill for convention discovery").
28
+ Loaded 4 other skills but skipped colgrep.
29
+ User-caught ("I noticed you didn't load or use `colgrep`").
30
+ Impact: one extra round-trip with the user; no rework since the plan hadn't been committed yet.
31
+ The colgrep searches proved useful once run — highest-scoring hit for "dependency bag converted to plain constructor parameters" was `notification.ts`, directly confirming the target.
32
+ - `wrong-abstraction` — When editing `src/ui/ui-observer.ts` to remove the `message_end` accumulation block, the replacement text closed the `session.subscribe(...)` callback but also added the function's closing brace, producing a duplicate `}`.
33
+ Autoformat caught the parse error immediately.
34
+ Impact: one follow-up edit, no downstream rework.
35
+
36
+ #### What caused friction (user side)
37
+
38
+ - None observed.
39
+ The user's intervention on colgrep was timely — caught before plan commit, not after.
@@ -0,0 +1,56 @@
1
+ ---
2
+ issue: 145
3
+ issue_title: "Decompose execute and push ExtensionContext to the boundary (Phase 9, Step M)"
4
+ ---
5
+
6
+ # Retro: #145 — Decompose execute and push ExtensionContext to the boundary
7
+
8
+ ## Final Retrospective (2026-05-23)
9
+
10
+ ### Session summary
11
+
12
+ Extracted config resolution into a pure `resolveSpawnConfig` function, injected three collaborators (`buildSnapshot`, `getModelInfo`, `getSessionInfo`) into `createAgentTool` to eliminate `ctx` reads from `execute`, pushed `ParentSnapshot` to `AgentManager`'s public API, and dissolved three small dependency bags (`ForegroundDeps`, `BackgroundDeps`, `AdapterDeps`) into plain parameters.
13
+ Released as `pi-subagents-v6.15.0`.
14
+
15
+ ### Observations
16
+
17
+ #### What went well
18
+
19
+ - User's two escalating questions ("Are there any other missing collaborators?"
20
+ → "Hiding dependencies in an object bag still counts as dependencies!") caught a `premature-convergence` before it landed as committed code.
21
+ The reverted partial step 3 attempt was ~4 files of changes that would have needed rework.
22
+ The resulting design (injected collaborators) is meaningfully better than the original plan's mechanical relocation.
23
+ - Folding tightly-coupled TDD steps (ctx elimination + params shrinking + deps dissolution) into fewer commits avoided intermediate states with broken types.
24
+ The plan's 12-step sequence would have required lift-and-shift gymnastics; the actual 7-commit sequence was cleaner.
25
+
26
+ #### What caused friction (agent side)
27
+
28
+ - `premature-convergence` — the original plan relocated `buildParentSnapshot` calls to `execute` without questioning whether `execute` should read `ctx` at all.
29
+ The existing `code-design` skill has DIP and parameter-relay rules that should have flagged this.
30
+ The `service-adapter.ts` module already demonstrated the getter-injection pattern (`getCtx`, `getModelRegistry`), but I didn't search for it during plan writing.
31
+ Impact: one plan rewrite commit (76bb57b), one reverted partial implementation (~15 minutes of rework).
32
+ User-caught.
33
+
34
+ - `missing-context` — didn't use `colgrep` during initial plan writing to discover the established getter-injection convention in `service-adapter.ts`.
35
+ Used `grep` exclusively for exact symbol matching.
36
+ When prompted by the user to use `colgrep`, the results were confirmatory rather than revelatory because I'd already read the relevant files by that point.
37
+ The miss was not using it *earlier* for intent-based exploration ("how do existing modules inject session-scoped state?").
38
+ Impact: added friction but no rework — the user's questions surfaced the pattern before code was committed.
39
+ User-caught.
40
+
41
+ - `instruction-violation` — wrote an inline `import()` type assertion (`session.ctx as import("@earendil-works/pi-coding-agent").ExtensionContext`) in `service-adapter.ts`.
42
+ AGENTS.md says "Use standard top-level imports only."
43
+ Impact: one extra edit round, caught before committing.
44
+ User-caught.
45
+
46
+ #### What caused friction (user side)
47
+
48
+ - The user's redirecting questions were well-timed and effective.
49
+ The escalation from "Are there any other missing collaborators?"
50
+ to the more pointed "Hiding dependencies in an object bag still counts as dependencies!"
51
+ was the right amount of pressure.
52
+ No friction observed on the user side.
53
+
54
+ ### Changes made
55
+
56
+ 1. `.pi/prompts/plan-issue.md` — added `colgrep` skill loading to the "Load skills" section for code-change plans, so convention discovery happens during exploration rather than after committing to a design.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "6.15.0",
3
+ "version": "6.16.1",
4
4
  "exports": {
5
5
  ".": "./src/service.ts"
6
6
  },
@@ -350,7 +350,7 @@ export class AgentManager {
350
350
  signal?: AbortSignal,
351
351
  ): Promise<AgentRecord | undefined> {
352
352
  const record = this.agents.get(id);
353
- const session = record?.execution?.session;
353
+ const session = record?.session;
354
354
  if (!session) return undefined;
355
355
 
356
356
  record.resetForResume(Date.now());
@@ -402,7 +402,7 @@ export class AgentManager {
402
402
 
403
403
  /** Dispose a record's session and remove it from the map. */
404
404
  private removeRecord(id: string, record: AgentRecord): void {
405
- record.execution?.session?.dispose?.();
405
+ record.session?.dispose?.();
406
406
  this.agents.delete(id);
407
407
  this.pendingSteers.delete(id);
408
408
  }
@@ -480,7 +480,7 @@ export class AgentManager {
480
480
  // Clear queue
481
481
  this.queue = [];
482
482
  for (const record of this.agents.values()) {
483
- record.execution?.session?.dispose();
483
+ record.session?.dispose();
484
484
  }
485
485
  this.agents.clear();
486
486
  // Prune any orphaned git worktrees (crash recovery)
@@ -12,6 +12,7 @@
12
12
  * after construction as lifecycle information becomes available.
13
13
  */
14
14
 
15
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
15
16
  import type { ExecutionState } from "./execution-state.js";
16
17
  import type { NotificationState } from "./notification-state.js";
17
18
  import type { AgentInvocation, SubagentType } from "./types.js";
@@ -85,6 +86,16 @@ export class AgentRecord {
85
86
  worktreeState?: WorktreeState;
86
87
  notification?: NotificationState;
87
88
 
89
+ /** The active agent session, or undefined before the session is created. */
90
+ get session(): AgentSession | undefined {
91
+ return this.execution?.session;
92
+ }
93
+
94
+ /** Path to the agent's session JSONL file, or undefined if not yet available. */
95
+ get outputFile(): string | undefined {
96
+ return this.execution?.outputFile;
97
+ }
98
+
88
99
  constructor(init: AgentRecordInit) {
89
100
  this.id = init.id;
90
101
  this.type = init.type;
package/src/index.ts CHANGED
@@ -62,12 +62,12 @@ export default function (pi: ExtensionAPI) {
62
62
  // ---- Notification system ----
63
63
  // runtime.widget is assigned after AgentManager construction; arrow closures
64
64
  // capture `runtime` by reference so they always read the current value.
65
- const notifications = new NotificationManager({
66
- sendMessage: (msg, opts) => pi.sendMessage(msg, opts),
67
- agentActivity: runtime.agentActivity,
68
- markFinished: (id) => runtime.markFinished(id),
69
- updateWidget: () => runtime.updateWidget(),
70
- });
65
+ const notifications = new NotificationManager(
66
+ (msg, opts) => pi.sendMessage(msg, opts),
67
+ runtime.agentActivity,
68
+ (id) => runtime.markFinished(id),
69
+ () => runtime.updateWidget(),
70
+ );
71
71
 
72
72
  // Settings: owns all three in-memory values and handles load/save/emit.
73
73
  // onMaxConcurrentChanged is wired after manager is constructed (closure captures by reference).
@@ -46,7 +46,7 @@ export function formatTaskNotification(record: AgentRecord, resultMaxLen: number
46
46
  const status = getStatusLabel(record.status, record.error);
47
47
  const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
48
48
  const totalTokens = getLifetimeTotal(record.lifetimeUsage);
49
- const contextPercent = getSessionContextPercent(record.execution?.session);
49
+ const contextPercent = getSessionContextPercent(record.session);
50
50
  const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
51
51
  const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
52
52
 
@@ -57,7 +57,7 @@ export function formatTaskNotification(record: AgentRecord, resultMaxLen: number
57
57
  : "No output.";
58
58
 
59
59
  const toolCallId = record.notification?.toolCallId;
60
- const outputFile = record.execution?.outputFile;
60
+ const outputFile = record.outputFile;
61
61
  return [
62
62
  "<task-notification>",
63
63
  `<task-id>${record.id}</task-id>`,
@@ -90,7 +90,7 @@ export function buildNotificationDetails(
90
90
  maxTurns: activity?.maxTurns,
91
91
  totalTokens,
92
92
  durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
93
- outputFile: record.execution?.outputFile,
93
+ outputFile: record.outputFile,
94
94
  error: record.error,
95
95
  resultPreview: record.result
96
96
  ? record.result.length > resultMaxLen
@@ -124,17 +124,6 @@ export function buildEventData(record: AgentRecord) {
124
124
 
125
125
  // ---- Notification system factory ----
126
126
 
127
- /** Narrow deps for the notification system — only the methods it actually calls. */
128
- export interface NotificationDeps {
129
- sendMessage: (
130
- msg: { customType: string; content: string; display: boolean; details?: unknown },
131
- opts?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
132
- ) => void;
133
- agentActivity: Map<string, AgentActivityTracker>;
134
- markFinished: (id: string) => void;
135
- updateWidget: () => void;
136
- }
137
-
138
127
  export interface NotificationSystem {
139
128
  cancelNudge: (key: string) => void;
140
129
  sendCompletion: (record: AgentRecord) => void;
@@ -147,7 +136,15 @@ const NUDGE_HOLD_MS = 200;
147
136
  export class NotificationManager implements NotificationSystem {
148
137
  private pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
149
138
 
150
- constructor(private deps: NotificationDeps) {}
139
+ constructor(
140
+ private sendMessage: (
141
+ msg: { customType: string; content: string; display: boolean; details?: unknown },
142
+ opts?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
143
+ ) => void,
144
+ private agentActivity: Map<string, AgentActivityTracker>,
145
+ private markFinished: (id: string) => void,
146
+ private updateWidget: () => void,
147
+ ) {}
151
148
 
152
149
  cancelNudge(key: string): void {
153
150
  const timer = this.pendingNudges.get(key);
@@ -158,16 +155,16 @@ export class NotificationManager implements NotificationSystem {
158
155
  }
159
156
 
160
157
  sendCompletion(record: AgentRecord): void {
161
- this.deps.agentActivity.delete(record.id);
162
- this.deps.markFinished(record.id);
158
+ this.agentActivity.delete(record.id);
159
+ this.markFinished(record.id);
163
160
  this.scheduleNudge(record.id, () => this.emitIndividualNudge(record));
164
- this.deps.updateWidget();
161
+ this.updateWidget();
165
162
  }
166
163
 
167
164
  cleanupCompleted(id: string): void {
168
- this.deps.agentActivity.delete(id);
169
- this.deps.markFinished(id);
170
- this.deps.updateWidget();
165
+ this.agentActivity.delete(id);
166
+ this.markFinished(id);
167
+ this.updateWidget();
171
168
  }
172
169
 
173
170
  dispose(): void {
@@ -194,15 +191,15 @@ export class NotificationManager implements NotificationSystem {
194
191
  if (record.notification?.resultConsumed) return;
195
192
 
196
193
  const notification = formatTaskNotification(record, 500);
197
- const outputFile = record.execution?.outputFile;
194
+ const outputFile = record.outputFile;
198
195
  const footer = outputFile ? `\nFull transcript available at: ${outputFile}` : "";
199
196
 
200
- this.deps.sendMessage(
197
+ this.sendMessage(
201
198
  {
202
199
  customType: "subagent-notification",
203
200
  content: notification + footer,
204
201
  display: true,
205
- details: buildNotificationDetails(record, 500, this.deps.agentActivity.get(record.id)),
202
+ details: buildNotificationDetails(record, 500, this.agentActivity.get(record.id)),
206
203
  },
207
204
  { deliverAs: "followUp", triggerTurn: true },
208
205
  );
@@ -88,7 +88,7 @@ export function createSubagentsService(
88
88
  if (!record || record.status !== "running") {
89
89
  return false;
90
90
  }
91
- const session = record.execution?.session;
91
+ const session = record.session;
92
92
  if (!session) {
93
93
  // Session not ready yet — queue via manager for delivery once initialized
94
94
  return manager.queueSteer(id, message);
@@ -312,7 +312,7 @@ Guidelines:
312
312
  `Agent not found: "${params.resume}". It may have been cleaned up.`,
313
313
  );
314
314
  }
315
- if (!existing.execution?.session) {
315
+ if (!existing.session) {
316
316
  return textResult(
317
317
  `Agent "${params.resume}" has no active session to resume.`,
318
318
  );
@@ -79,7 +79,7 @@ export function spawnBackground(
79
79
  `Agent ID: ${id}\n` +
80
80
  `Type: ${config.displayName}\n` +
81
81
  `Description: ${config.description}\n` +
82
- (record?.execution?.outputFile ? `Output file: ${record.execution.outputFile}\n` : "") +
82
+ (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
83
83
  (isQueued
84
84
  ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n`
85
85
  : "") +
@@ -63,12 +63,14 @@ export async function runForeground(
63
63
 
64
64
  const fgState = new AgentActivityTracker(config.effectiveMaxTurns);
65
65
  let unsubUI: (() => void) | undefined;
66
+ let recordRef: AgentRecord | undefined;
66
67
 
67
68
  const streamUpdate = () => {
69
+ const toolUses = recordRef?.toolUses ?? 0;
68
70
  const details: AgentDetails = {
69
71
  ...config.detailBase,
70
- toolUses: fgState.toolUses,
71
- tokens: formatLifetimeTokens(fgState),
72
+ toolUses,
73
+ tokens: recordRef ? formatLifetimeTokens(recordRef) : "",
72
74
  turnCount: fgState.turnCount,
73
75
  maxTurns: fgState.maxTurns,
74
76
  durationMs: Date.now() - startedAt,
@@ -77,7 +79,7 @@ export async function runForeground(
77
79
  spinnerFrame: spinnerFrame % SPINNER.length,
78
80
  };
79
81
  onUpdate?.({
80
- content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
82
+ content: [{ type: "text", text: `${toolUses} tool uses...` }],
81
83
  details: details as any,
82
84
  });
83
85
  };
@@ -110,6 +112,7 @@ export async function runForeground(
110
112
  parentSessionId: params.parentSessionId,
111
113
  onSessionCreated: (session, record) => {
112
114
  fgState.setSession(session);
115
+ recordRef = record;
113
116
  unsubUI = subscribeUIObserver(session, fgState, streamUpdate);
114
117
  fgId = record.id;
115
118
  agentActivity.set(record.id, fgState);
@@ -132,7 +135,7 @@ export async function runForeground(
132
135
  widget.markFinished(fgId);
133
136
  }
134
137
 
135
- const tokenText = formatLifetimeTokens(fgState);
138
+ const tokenText = formatLifetimeTokens(record);
136
139
  const details = buildDetails(config.detailBase, record, fgState, { tokens: tokenText });
137
140
 
138
141
  const fallbackNote = config.fellBack
@@ -65,7 +65,7 @@ export function createGetResultTool(deps: GetResultDeps) {
65
65
  const displayName = getDisplayName(record.type, deps.registry);
66
66
  const duration = formatDuration(record.startedAt, record.completedAt);
67
67
  const tokens = formatLifetimeTokens(record);
68
- const contextPercent = getSessionContextPercent(record.execution?.session);
68
+ const contextPercent = getSessionContextPercent(record.session);
69
69
  const statsParts = [`Tool uses: ${record.toolUses}`];
70
70
  if (tokens) statsParts.push(tokens);
71
71
  if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
@@ -92,8 +92,8 @@ export function createGetResultTool(deps: GetResultDeps) {
92
92
  }
93
93
 
94
94
  // Verbose: include full conversation
95
- if (params.verbose && record.execution?.session) {
96
- const conversation = deps.getConversation(record.execution.session);
95
+ if (params.verbose && record.session) {
96
+ const conversation = deps.getConversation(record.session);
97
97
  if (conversation) {
98
98
  output += `\n\n--- Agent Conversation ---\n${conversation}`;
99
99
  }
@@ -49,7 +49,7 @@ export function createSteerTool(deps: SteerToolDeps) {
49
49
  `Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`,
50
50
  );
51
51
  }
52
- const session = record.execution?.session;
52
+ const session = record.session;
53
53
  if (!session) {
54
54
  // Session not ready yet — queue via manager for delivery once initialized
55
55
  deps.queueSteer(record.id, params.message);
@@ -5,24 +5,15 @@
5
5
  * in `ui-observer.ts`. Callers use named transition methods; readers use read-only accessors.
6
6
  */
7
7
 
8
- import { addUsage, type LifetimeUsage, type SessionLike } from "../usage.js";
9
-
10
- /** Usage delta accepted by onUsageUpdate — matches the LifetimeUsage accumulator shape. */
11
- export interface UsageDelta {
12
- input: number;
13
- output: number;
14
- cacheWrite: number;
15
- }
8
+ import type { SessionLike } from "../usage.js";
16
9
 
17
10
  /** Per-agent live activity state with explicit transition methods and read-only accessors. */
18
11
  export class AgentActivityTracker {
19
12
  private _activeTools = new Map<string, string>();
20
13
  private _toolKeySeq = 0;
21
- private _toolUses = 0;
22
14
  private _responseText = "";
23
15
  private _session: SessionLike | undefined = undefined;
24
16
  private _turnCount = 1;
25
- private _lifetimeUsage: LifetimeUsage = { input: 0, output: 0, cacheWrite: 0 };
26
17
 
27
18
  constructor(private readonly _maxTurns?: number) {}
28
19
 
@@ -33,12 +24,11 @@ export class AgentActivityTracker {
33
24
  this._activeTools.set(toolName + "_" + (++this._toolKeySeq), toolName);
34
25
  }
35
26
 
36
- /** Record that a tool has finished executing; increments toolUses. No-op when no matching tool is active. */
37
- onToolEnd(toolName: string): void {
27
+ /** Remove a tool from active tools (called when tool execution ends). No-op when no matching tool is active. */
28
+ onToolDone(toolName: string): void {
38
29
  for (const [key, name] of this._activeTools) {
39
30
  if (name === toolName) {
40
31
  this._activeTools.delete(key);
41
- this._toolUses++;
42
32
  break;
43
33
  }
44
34
  }
@@ -59,11 +49,6 @@ export class AgentActivityTracker {
59
49
  this._turnCount++;
60
50
  }
61
51
 
62
- /** Accumulate a usage delta into the lifetime usage totals. */
63
- onUsageUpdate(delta: UsageDelta): void {
64
- addUsage(this._lifetimeUsage, delta);
65
- }
66
-
67
52
  /** Bind the session reference (called once when the agent session is created). */
68
53
  setSession(session: SessionLike): void {
69
54
  this._session = session;
@@ -76,11 +61,6 @@ export class AgentActivityTracker {
76
61
  return this._activeTools;
77
62
  }
78
63
 
79
- /** Total completed tool invocations. */
80
- get toolUses(): number {
81
- return this._toolUses;
82
- }
83
-
84
64
  /** The agent's latest partial response text (reset at each message start). */
85
65
  get responseText(): string {
86
66
  return this._responseText;
@@ -101,8 +81,4 @@ export class AgentActivityTracker {
101
81
  return this._maxTurns;
102
82
  }
103
83
 
104
- /** Accumulated lifetime token usage (survives compaction). */
105
- get lifetimeUsage(): Readonly<LifetimeUsage> {
106
- return this._lifetimeUsage;
107
- }
108
84
  }
@@ -207,7 +207,7 @@ export function createAgentsMenuHandler(deps: AgentMenuDeps) {
207
207
  }
208
208
 
209
209
  async function viewAgentConversation(ctx: ExtensionContext, record: AgentRecord) {
210
- const session = record.execution?.session;
210
+ const session = record.session;
211
211
  if (!session) {
212
212
  ctx.ui.notify(
213
213
  `Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`,