@tintinweb/pi-subagents 0.10.3 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.4] - 2026-06-23
11
+
12
+ ### Fixed
13
+ - **Background agent records lost before result is read** ([#108](https://github.com/tintinweb/pi-subagents/issues/108) — thanks [@philipmw](https://github.com/philipmw)). On session switch or `/new`/`/resume`, `clearCompleted()` removed completed agent records regardless of whether the LLM had retrieved the result, causing `get_subagent_result` to return "Agent not found" for agents that had finished but hadn't been checked yet. `clearCompleted()` now accepts a `skipUnconsumed` flag; session event handlers pass `true`, so records with `resultConsumed=false` are preserved across session transitions. The 10-minute cleanup timer handles eventual eviction. Note: a full session shutdown (`session_shutdown`) calls `dispose()` which clears all records unconditionally — that path is not affected by this fix.
14
+
15
+ ### Added
16
+ - **Foreground agent lifecycle completion and conversation logging** ([#105](https://github.com/tintinweb/pi-subagents/pull/105) — thanks [@benrhodeland](https://github.com/benrhodeland)). Two gaps closed: (1) **`onComplete` now fires for foreground agents**, emitting `subagents:completed` / `subagents:failed` lifecycle events and writing a `subagents:record` entry to the parent JSONL — previously only background agents emitted these, leaving cross-extension observers with an orphaned `subagents:started` event and no matching completion. `resultConsumed` is pre-set so the callback skips notifications (the result is returned inline); no change to the tool's return value. (2) **Foreground agent conversations are now streamed to `.output` files** (same `.pi/output/agent-<id>.jsonl` path as background agents) — inline subagent transcripts were previously permanently lost after `spawnAndWait` returned.
17
+
10
18
  ## [0.10.3] - 2026-06-12
11
19
 
12
20
  ### Added
package/README.md CHANGED
@@ -124,7 +124,7 @@ Individual agent results render Claude Code-style in the conversation:
124
124
 
125
125
  Completed results can be expanded (ctrl+o in pi) to show the full agent output inline.
126
126
 
127
- Background agent completion notifications render as styled boxes:
127
+ Both foreground and background agents stream their full conversation to a `.pi/output/agent-<id>.jsonl` transcript file. Background agent completion notifications render as styled boxes:
128
128
 
129
129
  ```
130
130
  ✓ Find auth files completed
@@ -418,8 +418,8 @@ Agent lifecycle events are emitted via `pi.events.emit()` so other extensions ca
418
418
  |-------|------|------------|
419
419
  | `subagents:created` | Background agent registered | `id`, `type`, `description`, `isBackground` |
420
420
  | `subagents:started` | Agent transitions to running (including queued→running) | `id`, `type`, `description` |
421
- | `subagents:completed` | Agent finished successfully | `id`, `type`, `durationMs`, `tokens` (lifetime `{ input, output, total }`), `toolUses`, `result` |
422
- | `subagents:failed` | Agent errored, stopped, or aborted | same as completed + `error`, `status` |
421
+ | `subagents:completed` | Agent finished successfully (background and foreground) | `id`, `type`, `durationMs`, `tokens` (lifetime `{ input, output, total }`), `toolUses`, `result` |
422
+ | `subagents:failed` | Agent errored, stopped, or aborted (background and foreground) | same as completed + `error`, `status` |
423
423
  | `subagents:steered` | Steering message sent | `id`, `message` |
424
424
  | `subagents:compacted` | Agent's session successfully compacted | `id`, `type`, `description`, `reason` (`"manual"` / `"threshold"` / `"overflow"`), `tokensBefore`, `compactionCount` |
425
425
  | `subagents:scheduled` | Schedule lifecycle change | `{ type: "added" \| "removed" \| "updated" \| "fired" \| "error", … }` (job/agentId/error fields per type) |
@@ -89,11 +89,24 @@ export declare class AgentManager {
89
89
  private startAgent;
90
90
  /** Start queued agents up to the concurrency limit. */
91
91
  private drainQueue;
92
+ /**
93
+ * Called synchronously right after spawn, before onSessionCreated fires.
94
+ * Lets the caller set up the output file path on the record.
95
+ * The record is guaranteed to be in this.agents at this point.
96
+ */
97
+ private onSpawned?;
92
98
  /**
93
99
  * Spawn an agent and wait for completion (foreground use).
94
100
  * Foreground agents bypass the concurrency queue.
101
+ * Returns { id, record } so callers can access the agent ID.
102
+ *
103
+ * @param onSpawned - Called synchronously after spawn(), before onSessionCreated fires.
104
+ * Use this to set record.outputFile so streamToOutputFile can pick it up.
95
105
  */
96
- spawnAndWait(pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: Omit<SpawnOptions, "isBackground">): Promise<AgentRecord>;
106
+ spawnAndWait(pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: Omit<SpawnOptions, "isBackground">, onSpawned?: (id: string) => void): Promise<{
107
+ id: string;
108
+ record: AgentRecord;
109
+ }>;
97
110
  /**
98
111
  * Resume an existing agent session with a new prompt.
99
112
  */
@@ -107,8 +120,10 @@ export declare class AgentManager {
107
120
  /**
108
121
  * Remove all completed/stopped/errored records immediately.
109
122
  * Called on session start/switch so tasks from a prior session don't persist.
123
+ * Pass skipUnconsumed=true to preserve records the LLM hasn't read yet
124
+ * (resultConsumed=false) — they will be evicted by the 10-minute cleanup timer instead.
110
125
  */
111
- clearCompleted(): void;
126
+ clearCompleted(skipUnconsumed?: boolean): void;
112
127
  /** Whether any agents are still running or queued. */
113
128
  hasRunning(): boolean;
114
129
  /** Abort all running and queued agents immediately. */
@@ -225,7 +225,16 @@ export class AgentManager {
225
225
  `\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`;
226
226
  }
227
227
  }
228
- if (options.isBackground) {
228
+ // Fire onComplete for foreground agents too — lifecycle symmetry.
229
+ // Mark resultConsumed so the callback skips notifications (result returned inline).
230
+ if (!options.isBackground) {
231
+ record.resultConsumed = true;
232
+ try {
233
+ this.onComplete?.(record);
234
+ }
235
+ catch { /* ignore completion side-effect errors */ }
236
+ }
237
+ else {
229
238
  this.runningBackground--;
230
239
  try {
231
240
  this.onComplete?.(record);
@@ -259,7 +268,13 @@ export class AgentManager {
259
268
  }
260
269
  catch { /* ignore cleanup errors */ }
261
270
  }
262
- if (options.isBackground) {
271
+ // Fire onComplete for foreground agents too — lifecycle symmetry.
272
+ // Mark resultConsumed so the callback skips notifications (result returned inline).
273
+ if (!options.isBackground) {
274
+ record.resultConsumed = true;
275
+ this.onComplete?.(record);
276
+ }
277
+ else {
263
278
  this.runningBackground--;
264
279
  this.onComplete?.(record);
265
280
  this.drainQueue();
@@ -267,6 +282,10 @@ export class AgentManager {
267
282
  return "";
268
283
  });
269
284
  record.promise = promise;
285
+ // Notify caller that spawn is complete (record is in the map, promise is set).
286
+ // Called synchronously — onSessionCreated fires asynchronously inside runAgent.
287
+ // Used by spawnAndWait to let the caller set up output files before streaming starts.
288
+ this.onSpawned?.(id);
270
289
  }
271
290
  /** Start queued agents up to the concurrency limit. */
272
291
  drainQueue() {
@@ -288,15 +307,33 @@ export class AgentManager {
288
307
  }
289
308
  }
290
309
  }
310
+ /**
311
+ * Called synchronously right after spawn, before onSessionCreated fires.
312
+ * Lets the caller set up the output file path on the record.
313
+ * The record is guaranteed to be in this.agents at this point.
314
+ */
315
+ onSpawned;
291
316
  /**
292
317
  * Spawn an agent and wait for completion (foreground use).
293
318
  * Foreground agents bypass the concurrency queue.
319
+ * Returns { id, record } so callers can access the agent ID.
320
+ *
321
+ * @param onSpawned - Called synchronously after spawn(), before onSessionCreated fires.
322
+ * Use this to set record.outputFile so streamToOutputFile can pick it up.
294
323
  */
295
- async spawnAndWait(pi, ctx, type, prompt, options) {
296
- const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
297
- const record = this.agents.get(id);
298
- await record.promise;
299
- return record;
324
+ async spawnAndWait(pi, ctx, type, prompt, options, onSpawned) {
325
+ // Temporarily register the onSpawned hook so startAgent can call it.
326
+ const prevOnSpawned = this.onSpawned;
327
+ this.onSpawned = onSpawned;
328
+ try {
329
+ const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
330
+ const record = this.agents.get(id);
331
+ await record.promise;
332
+ return { id, record };
333
+ }
334
+ finally {
335
+ this.onSpawned = prevOnSpawned;
336
+ }
300
337
  }
301
338
  /**
302
339
  * Resume an existing agent session with a new prompt.
@@ -379,11 +416,15 @@ export class AgentManager {
379
416
  /**
380
417
  * Remove all completed/stopped/errored records immediately.
381
418
  * Called on session start/switch so tasks from a prior session don't persist.
419
+ * Pass skipUnconsumed=true to preserve records the LLM hasn't read yet
420
+ * (resultConsumed=false) — they will be evicted by the 10-minute cleanup timer instead.
382
421
  */
383
- clearCompleted() {
422
+ clearCompleted(skipUnconsumed = false) {
384
423
  for (const [id, record] of this.agents) {
385
424
  if (record.status === "running" || record.status === "queued")
386
425
  continue;
426
+ if (skipUnconsumed && !record.resultConsumed)
427
+ continue;
387
428
  this.removeRecord(id, record);
388
429
  }
389
430
  }
package/dist/index.js CHANGED
@@ -406,12 +406,12 @@ export default function (pi) {
406
406
  // Capture ctx from session_start for RPC spawn handler + start the scheduler.
407
407
  pi.on("session_start", async (_event, ctx) => {
408
408
  currentCtx = ctx;
409
- manager.clearCompleted();
409
+ manager.clearCompleted(true);
410
410
  if (isSchedulingEnabled() && !scheduler.isActive())
411
411
  startScheduler(ctx);
412
412
  });
413
413
  pi.on("session_before_switch", () => {
414
- manager.clearCompleted();
414
+ manager.clearCompleted(true);
415
415
  scheduler.stop();
416
416
  });
417
417
  const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc, unsubStop: unsubStopRpc } = registerRpcHandlers({
@@ -1065,7 +1065,9 @@ Terse command-style prompts produce shallow, generic work.
1065
1065
  });
1066
1066
  };
1067
1067
  const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate);
1068
- // Wire session creation to register in widget
1068
+ // Wire session creation: register in widget + stream to output file.
1069
+ // The output file path is set synchronously after spawn (below),
1070
+ // before onSessionCreated fires — same pattern as background agents.
1069
1071
  const origOnSession = fgCallbacks.onSessionCreated;
1070
1072
  fgCallbacks.onSessionCreated = (session) => {
1071
1073
  origOnSession(session);
@@ -1077,6 +1079,13 @@ Terse command-style prompts produce shallow, generic work.
1077
1079
  break;
1078
1080
  }
1079
1081
  }
1082
+ // Stream conversation to output file (foreground agent logging)
1083
+ if (fgId) {
1084
+ const rec = manager.getRecord(fgId);
1085
+ if (rec?.outputFile) {
1086
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd);
1087
+ }
1088
+ }
1080
1089
  };
1081
1090
  // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
1082
1091
  const spinnerInterval = setInterval(() => {
@@ -1086,7 +1095,7 @@ Terse command-style prompts produce shallow, generic work.
1086
1095
  streamUpdate();
1087
1096
  let record;
1088
1097
  try {
1089
- record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1098
+ const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1090
1099
  description: params.description,
1091
1100
  model,
1092
1101
  maxTurns: effectiveMaxTurns,
@@ -1097,7 +1106,16 @@ Terse command-style prompts produce shallow, generic work.
1097
1106
  invocation: agentInvocation,
1098
1107
  signal,
1099
1108
  ...fgCallbacks,
1109
+ }, (fgAgentId) => {
1110
+ // onSpawned: called synchronously after spawn, before onSessionCreated fires.
1111
+ // Set up the output file so streamToOutputFile can pick it up.
1112
+ const fgRec = manager.getRecord(fgAgentId);
1113
+ if (fgRec) {
1114
+ fgRec.outputFile = createOutputFilePath(ctx.cwd, fgAgentId, ctx.sessionManager.getSessionId());
1115
+ writeInitialEntry(fgRec.outputFile, fgAgentId, params.prompt, ctx.cwd);
1116
+ }
1100
1117
  });
1118
+ record = fgResult.record;
1101
1119
  }
1102
1120
  catch (err) {
1103
1121
  clearInterval(spinnerInterval);
@@ -1671,7 +1689,7 @@ Guidelines for choosing settings:
1671
1689
  - Only include frontmatter fields that differ from defaults — omit fields where the default is fine
1672
1690
 
1673
1691
  Write the file using the write tool. Only write the file, nothing else.`;
1674
- const record = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
1692
+ const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
1675
1693
  description: `Generate ${name} agent`,
1676
1694
  maxTurns: 5,
1677
1695
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.10.3",
3
+ "version": "0.10.4",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -311,7 +311,12 @@ export class AgentManager {
311
311
  }
312
312
  }
313
313
 
314
- if (options.isBackground) {
314
+ // Fire onComplete for foreground agents too — lifecycle symmetry.
315
+ // Mark resultConsumed so the callback skips notifications (result returned inline).
316
+ if (!options.isBackground) {
317
+ record.resultConsumed = true;
318
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
319
+ } else {
315
320
  this.runningBackground--;
316
321
  try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
317
322
  this.drainQueue();
@@ -342,7 +347,12 @@ export class AgentManager {
342
347
  } catch { /* ignore cleanup errors */ }
343
348
  }
344
349
 
345
- if (options.isBackground) {
350
+ // Fire onComplete for foreground agents too — lifecycle symmetry.
351
+ // Mark resultConsumed so the callback skips notifications (result returned inline).
352
+ if (!options.isBackground) {
353
+ record.resultConsumed = true;
354
+ this.onComplete?.(record);
355
+ } else {
346
356
  this.runningBackground--;
347
357
  this.onComplete?.(record);
348
358
  this.drainQueue();
@@ -351,6 +361,11 @@ export class AgentManager {
351
361
  });
352
362
 
353
363
  record.promise = promise;
364
+
365
+ // Notify caller that spawn is complete (record is in the map, promise is set).
366
+ // Called synchronously — onSessionCreated fires asynchronously inside runAgent.
367
+ // Used by spawnAndWait to let the caller set up output files before streaming starts.
368
+ this.onSpawned?.(id);
354
369
  }
355
370
 
356
371
  /** Start queued agents up to the concurrency limit. */
@@ -372,9 +387,20 @@ export class AgentManager {
372
387
  }
373
388
  }
374
389
 
390
+ /**
391
+ * Called synchronously right after spawn, before onSessionCreated fires.
392
+ * Lets the caller set up the output file path on the record.
393
+ * The record is guaranteed to be in this.agents at this point.
394
+ */
395
+ private onSpawned?: (id: string) => void;
396
+
375
397
  /**
376
398
  * Spawn an agent and wait for completion (foreground use).
377
399
  * Foreground agents bypass the concurrency queue.
400
+ * Returns { id, record } so callers can access the agent ID.
401
+ *
402
+ * @param onSpawned - Called synchronously after spawn(), before onSessionCreated fires.
403
+ * Use this to set record.outputFile so streamToOutputFile can pick it up.
378
404
  */
379
405
  async spawnAndWait(
380
406
  pi: ExtensionAPI,
@@ -382,11 +408,19 @@ export class AgentManager {
382
408
  type: SubagentType,
383
409
  prompt: string,
384
410
  options: Omit<SpawnOptions, "isBackground">,
385
- ): Promise<AgentRecord> {
386
- const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
387
- const record = this.agents.get(id)!;
388
- await record.promise;
389
- return record;
411
+ onSpawned?: (id: string) => void,
412
+ ): Promise<{ id: string; record: AgentRecord }> {
413
+ // Temporarily register the onSpawned hook so startAgent can call it.
414
+ const prevOnSpawned = this.onSpawned;
415
+ this.onSpawned = onSpawned;
416
+ try {
417
+ const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
418
+ const record = this.agents.get(id)!;
419
+ await record.promise;
420
+ return { id, record };
421
+ } finally {
422
+ this.onSpawned = prevOnSpawned;
423
+ }
390
424
  }
391
425
 
392
426
  /**
@@ -480,10 +514,13 @@ export class AgentManager {
480
514
  /**
481
515
  * Remove all completed/stopped/errored records immediately.
482
516
  * Called on session start/switch so tasks from a prior session don't persist.
517
+ * Pass skipUnconsumed=true to preserve records the LLM hasn't read yet
518
+ * (resultConsumed=false) — they will be evicted by the 10-minute cleanup timer instead.
483
519
  */
484
- clearCompleted(): void {
520
+ clearCompleted(skipUnconsumed = false): void {
485
521
  for (const [id, record] of this.agents) {
486
522
  if (record.status === "running" || record.status === "queued") continue;
523
+ if (skipUnconsumed && !record.resultConsumed) continue;
487
524
  this.removeRecord(id, record);
488
525
  }
489
526
  }
package/src/index.ts CHANGED
@@ -458,12 +458,12 @@ export default function (pi: ExtensionAPI) {
458
458
  // Capture ctx from session_start for RPC spawn handler + start the scheduler.
459
459
  pi.on("session_start", async (_event, ctx) => {
460
460
  currentCtx = ctx;
461
- manager.clearCompleted();
461
+ manager.clearCompleted(true);
462
462
  if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx);
463
463
  });
464
464
 
465
465
  pi.on("session_before_switch", () => {
466
- manager.clearCompleted();
466
+ manager.clearCompleted(true);
467
467
  scheduler.stop();
468
468
  });
469
469
 
@@ -1200,7 +1200,9 @@ Terse command-style prompts produce shallow, generic work.
1200
1200
 
1201
1201
  const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate);
1202
1202
 
1203
- // Wire session creation to register in widget
1203
+ // Wire session creation: register in widget + stream to output file.
1204
+ // The output file path is set synchronously after spawn (below),
1205
+ // before onSessionCreated fires — same pattern as background agents.
1204
1206
  const origOnSession = fgCallbacks.onSessionCreated;
1205
1207
  fgCallbacks.onSessionCreated = (session: any) => {
1206
1208
  origOnSession(session);
@@ -1212,6 +1214,13 @@ Terse command-style prompts produce shallow, generic work.
1212
1214
  break;
1213
1215
  }
1214
1216
  }
1217
+ // Stream conversation to output file (foreground agent logging)
1218
+ if (fgId) {
1219
+ const rec = manager.getRecord(fgId);
1220
+ if (rec?.outputFile) {
1221
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd);
1222
+ }
1223
+ }
1215
1224
  };
1216
1225
 
1217
1226
  // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
@@ -1224,7 +1233,7 @@ Terse command-style prompts produce shallow, generic work.
1224
1233
 
1225
1234
  let record: AgentRecord;
1226
1235
  try {
1227
- record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1236
+ const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1228
1237
  description: params.description,
1229
1238
  model,
1230
1239
  maxTurns: effectiveMaxTurns,
@@ -1235,7 +1244,16 @@ Terse command-style prompts produce shallow, generic work.
1235
1244
  invocation: agentInvocation,
1236
1245
  signal,
1237
1246
  ...fgCallbacks,
1247
+ }, (fgAgentId) => {
1248
+ // onSpawned: called synchronously after spawn, before onSessionCreated fires.
1249
+ // Set up the output file so streamToOutputFile can pick it up.
1250
+ const fgRec = manager.getRecord(fgAgentId);
1251
+ if (fgRec) {
1252
+ fgRec.outputFile = createOutputFilePath(ctx.cwd, fgAgentId, ctx.sessionManager.getSessionId());
1253
+ writeInitialEntry(fgRec.outputFile, fgAgentId, params.prompt, ctx.cwd);
1254
+ }
1238
1255
  });
1256
+ record = fgResult.record;
1239
1257
  } catch (err) {
1240
1258
  clearInterval(spinnerInterval);
1241
1259
  return textResult(err instanceof Error ? err.message : String(err));
@@ -1838,7 +1856,7 @@ Guidelines for choosing settings:
1838
1856
 
1839
1857
  Write the file using the write tool. Only write the file, nothing else.`;
1840
1858
 
1841
- const record = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
1859
+ const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
1842
1860
  description: `Generate ${name} agent`,
1843
1861
  maxTurns: 5,
1844
1862
  });