@tintinweb/pi-subagents 0.4.6 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ 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
+ ## [0.4.9] - 2026-03-18
9
+
10
+ ### Fixed
11
+ - **Conversation viewer crash in narrow terminals** ([#7](https://github.com/tintinweb/pi-subagents/issues/7)) — `buildContentLines()` in the live conversation viewer could return lines wider than the terminal when `wrapTextWithAnsi()` misjudged visible width on ANSI-heavy input (e.g. tool output with embedded escape codes, long URLs, wide tables). All content lines are now clamped with `truncateToWidth()` before returning. Same class of bug as the widget fix in v0.2.7, different component.
12
+
13
+ ### Added
14
+ - **Conversation viewer width-safety tests** — 17 tests covering `render()` and `buildContentLines()` across varied content (plain text, ANSI codes, unicode, tables, long URLs, narrow terminals). Includes mock-based regression tests that simulate upstream `wrapTextWithAnsi` returning overwidth lines, ensuring the safety net catches them.
15
+
16
+ ## [0.4.8] - 2026-03-18
17
+
18
+ ### Added
19
+ - **Cross-extension RPC** — other pi extensions can spawn subagents via `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`). Emits `subagents:ready` on load.
20
+ - **Session persistence for agent records** — completed agent records are persisted via `pi.appendEntry("subagents:record", ...)` for cross-extension history reconstruction.
21
+
22
+ ### Fixed
23
+ - **Background agent notification race condition** — `pi.sendMessage()` is fire-and-forget, so completion notifications sent eagerly from `onComplete` could not be retracted when `get_subagent_result` was called in the same turn. Notifications are now held behind a 200ms cancellable timer; `get_subagent_result` cancels the pending timer before it fires, eliminating duplicate notifications. Group notifications also re-check `resultConsumed` at send time so consumed agents are filtered out.
24
+
25
+ ## [0.4.7] - 2026-03-17
26
+
27
+ ### Added
28
+ - **Custom notification renderer** — background agent completion notifications now render as styled, themed boxes instead of raw XML. Uses `pi.registerMessageRenderer()` with the `"subagent-notification"` custom message type. The LLM continues to receive `<task-notification>` XML via `content`; only the user-facing display changes.
29
+ - **Group notification rendering** — group completions render each agent as its own styled block (icon, description, stats, result preview) instead of showing only the first agent.
30
+ - **Output file streaming for background agents** — background agents now get the same output file transcript as foreground agents, with `onSessionCreated` wiring and proper cleanup on completion/error.
31
+ - `NotificationDetails` type in `types.ts` — structured details for the notification renderer, with optional `others` array for group notifications.
32
+ - `buildNotificationDetails()` helper — extracts renderer-facing details from an `AgentRecord`.
33
+
34
+ ### Changed
35
+ - **Notification delivery** — `sendIndividualNudge` and group notification now use `pi.sendMessage()` (custom message) instead of `pi.sendUserMessage()` (plain text), enabling renderer-controlled display.
36
+ - **Steered status rendering** — steered agents show "completed (steered)" in the notification box instead of plain "completed".
37
+
38
+ ### Fixed
39
+ - **Output file cleanup on completion** — `agent-manager.ts` now calls `record.outputCleanup()` in both the success and error paths of agent completion, ensuring the streaming subscription is flushed and released.
40
+
8
41
  ## [0.4.6] - 2026-03-16
9
42
 
10
43
  ### Fixed
@@ -274,6 +307,9 @@ Initial release.
274
307
  - **Thinking level** — per-agent extended thinking control
275
308
  - **`/agent` and `/agents` commands**
276
309
 
310
+ [0.4.9]: https://github.com/tintinweb/pi-subagents/compare/v0.4.8...v0.4.9
311
+ [0.4.8]: https://github.com/tintinweb/pi-subagents/compare/v0.4.7...v0.4.8
312
+ [0.4.7]: https://github.com/tintinweb/pi-subagents/compare/v0.4.6...v0.4.7
277
313
  [0.4.6]: https://github.com/tintinweb/pi-subagents/compare/v0.4.5...v0.4.6
278
314
  [0.4.5]: https://github.com/tintinweb/pi-subagents/compare/v0.4.4...v0.4.5
279
315
  [0.4.4]: https://github.com/tintinweb/pi-subagents/compare/v0.4.3...v0.4.4
package/README.md CHANGED
@@ -27,7 +27,9 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
27
27
  - **Git worktree isolation** — run agents in isolated repo copies; changes auto-committed to branches on completion
28
28
  - **Skill preloading** — inject named skill files from `.pi/skills/` into agent system prompts
29
29
  - **Tool denylist** — block specific tools via `disallowed_tools` frontmatter
30
+ - **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expandable to show full output. Group completions render each agent individually
30
31
  - **Event bus** — lifecycle events (`subagents:created`, `started`, `completed`, `failed`, `steered`) emitted via `pi.events`, enabling other extensions to react to sub-agent activity
32
+ - **Cross-extension RPC** — other pi extensions can spawn subagents via the `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`). Emits `subagents:ready` on load
31
33
 
32
34
  ## Install
33
35
 
@@ -82,6 +84,17 @@ Individual agent results render Claude Code-style in the conversation:
82
84
 
83
85
  Completed results can be expanded (ctrl+o in pi) to show the full agent output inline.
84
86
 
87
+ Background agent completion notifications render as styled boxes:
88
+
89
+ ```
90
+ ✓ Find auth files completed
91
+ 3 tool uses · 12.4k token · 4.1s
92
+ ⎿ Found 5 files related to authentication...
93
+ transcript: .pi/output/agent-abc123.jsonl
94
+ ```
95
+
96
+ Group completions render each agent as a separate block. The LLM receives structured `<task-notification>` XML for parsing, while the user sees the themed visual.
97
+
85
98
  ## Default Agent Types
86
99
 
87
100
  | Type | Tools | Model | Prompt Mode | Description |
@@ -272,6 +285,58 @@ Agent lifecycle events are emitted via `pi.events.emit()` so other extensions ca
272
285
  | `subagents:completed` | Agent finished successfully | `id`, `type`, `durationMs`, `tokens`, `toolUses`, `result` |
273
286
  | `subagents:failed` | Agent errored, stopped, or aborted | same as completed + `error`, `status` |
274
287
  | `subagents:steered` | Steering message sent | `id`, `message` |
288
+ | `subagents:ready` | Extension loaded and RPC handlers registered | — |
289
+
290
+ ## Cross-Extension RPC
291
+
292
+ Other pi extensions can spawn subagents programmatically via the `pi.events` event bus, without importing this package directly.
293
+
294
+ ### Discovery
295
+
296
+ Listen for `subagents:ready` to know when RPC handlers are available:
297
+
298
+ ```typescript
299
+ pi.events.on("subagents:ready", () => {
300
+ // RPC handlers are registered — safe to call ping/spawn
301
+ });
302
+ ```
303
+
304
+ ### Ping
305
+
306
+ Check if the subagents extension is loaded:
307
+
308
+ ```typescript
309
+ const requestId = crypto.randomUUID();
310
+ const unsub = pi.events.on(`subagents:rpc:ping:reply:${requestId}`, () => {
311
+ unsub();
312
+ // Extension is alive
313
+ });
314
+ pi.events.emit("subagents:rpc:ping", { requestId });
315
+ ```
316
+
317
+ ### Spawn
318
+
319
+ Spawn a subagent and receive its ID:
320
+
321
+ ```typescript
322
+ const requestId = crypto.randomUUID();
323
+ const unsub = pi.events.on(`subagents:rpc:spawn:reply:${requestId}`, (reply) => {
324
+ unsub();
325
+ if (reply.error) {
326
+ console.error("Spawn failed:", reply.error);
327
+ } else {
328
+ console.log("Agent ID:", reply.id);
329
+ }
330
+ });
331
+ pi.events.emit("subagents:rpc:spawn", {
332
+ requestId,
333
+ type: "general-purpose",
334
+ prompt: "Do something useful",
335
+ options: { description: "My task", run_in_background: true },
336
+ });
337
+ ```
338
+
339
+ Reply channels are scoped per `requestId`, so concurrent requests don't interfere.
275
340
 
276
341
  ## Persistent Agent Memory
277
342
 
@@ -342,10 +407,12 @@ src/
342
407
  agent-types.ts # Unified agent registry (defaults + user), tool factories
343
408
  agent-runner.ts # Session creation, execution, graceful max_turns, steer/resume
344
409
  agent-manager.ts # Agent lifecycle, concurrency queue, completion notifications
410
+ cross-extension-rpc.ts # RPC handlers for cross-extension spawn/ping via pi.events
345
411
  group-join.ts # Group join manager: batched completion notifications with timeout
346
412
  custom-agents.ts # Load user-defined agents from .pi/agents/*.md
347
413
  memory.ts # Persistent agent memory (resolve, read, build prompt blocks)
348
414
  skill-loader.ts # Preload skill files from .pi/skills/
415
+ output-file.ts # Streaming output file transcripts for agent sessions
349
416
  worktree.ts # Git worktree isolation (create, cleanup, prune)
350
417
  prompts.ts # Config-driven system prompt builder
351
418
  context.ts # Parent conversation context for inherit_context
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.4.6",
3
+ "version": "0.4.9",
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",
@@ -21,9 +21,9 @@
21
21
  "autonomous"
22
22
  ],
23
23
  "dependencies": {
24
- "@mariozechner/pi-ai": "^0.57.1",
25
- "@mariozechner/pi-coding-agent": "^0.57.1",
26
- "@mariozechner/pi-tui": "^0.57.1",
24
+ "@mariozechner/pi-ai": "^0.60.0",
25
+ "@mariozechner/pi-coding-agent": "^0.60.0",
26
+ "@mariozechner/pi-tui": "^0.60.0",
27
27
  "@sinclair/typebox": "latest"
28
28
  },
29
29
  "scripts": {
@@ -171,6 +171,12 @@ export class AgentManager {
171
171
  record.session = session;
172
172
  record.completedAt ??= Date.now();
173
173
 
174
+ // Final flush of streaming output file
175
+ if (record.outputCleanup) {
176
+ try { record.outputCleanup(); } catch { /* ignore */ }
177
+ record.outputCleanup = undefined;
178
+ }
179
+
174
180
  // Clean up worktree if used
175
181
  if (record.worktree) {
176
182
  const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
@@ -196,6 +202,12 @@ export class AgentManager {
196
202
  record.error = err instanceof Error ? err.message : String(err);
197
203
  record.completedAt ??= Date.now();
198
204
 
205
+ // Final flush of streaming output file on error
206
+ if (record.outputCleanup) {
207
+ try { record.outputCleanup(); } catch { /* ignore */ }
208
+ record.outputCleanup = undefined;
209
+ }
210
+
199
211
  // Best-effort worktree cleanup on error
200
212
  if (record.worktree) {
201
213
  try {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Cross-extension RPC handlers for the subagents extension.
3
+ *
4
+ * Exposes ping and spawn RPCs over the pi.events event bus,
5
+ * using per-request scoped reply channels.
6
+ */
7
+
8
+ /** Minimal event bus interface needed by the RPC handlers. */
9
+ export interface EventBus {
10
+ on(event: string, handler: (data: unknown) => void): () => void;
11
+ emit(event: string, data: unknown): void;
12
+ }
13
+
14
+ /** Minimal AgentManager interface needed by the spawn RPC. */
15
+ export interface SpawnCapable {
16
+ spawn(pi: unknown, ctx: unknown, type: string, prompt: string, options: any): string;
17
+ }
18
+
19
+ export interface RpcDeps {
20
+ events: EventBus;
21
+ pi: unknown; // passed through to manager.spawn
22
+ getCtx: () => unknown | undefined; // returns current ExtensionContext
23
+ manager: SpawnCapable;
24
+ }
25
+
26
+ export interface RpcHandle {
27
+ unsubPing: () => void;
28
+ unsubSpawn: () => void;
29
+ }
30
+
31
+ /**
32
+ * Register ping and spawn RPC handlers on the event bus.
33
+ * Returns unsub functions for cleanup.
34
+ */
35
+ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
36
+ const { events, pi, getCtx, manager } = deps;
37
+
38
+ const unsubPing = events.on("subagents:rpc:ping", (raw: unknown) => {
39
+ const { requestId } = raw as { requestId: string };
40
+ events.emit(`subagents:rpc:ping:reply:${requestId}`, {});
41
+ });
42
+
43
+ const unsubSpawn = events.on("subagents:rpc:spawn", async (raw: unknown) => {
44
+ const { requestId, type, prompt, options } = raw as {
45
+ requestId: string; type: string; prompt: string; options?: any;
46
+ };
47
+ const ctx = getCtx();
48
+ if (!ctx) {
49
+ events.emit(`subagents:rpc:spawn:reply:${requestId}`, { error: "No active session" });
50
+ return;
51
+ }
52
+ try {
53
+ const id = manager.spawn(pi, ctx, type, prompt, options ?? {});
54
+ events.emit(`subagents:rpc:spawn:reply:${requestId}`, { id });
55
+ } catch (err: any) {
56
+ events.emit(`subagents:rpc:spawn:reply:${requestId}`, { error: err.message });
57
+ }
58
+ });
59
+
60
+ return { unsubPing, unsubSpawn };
61
+ }
package/src/index.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  * /agents — Interactive agent management menu
11
11
  */
12
12
 
13
- import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
13
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
14
+ import { registerRpcHandlers } from "./cross-extension-rpc.js";
14
15
  import { existsSync, mkdirSync, unlinkSync, readFileSync } from "node:fs";
15
16
  import { join } from "node:path";
16
17
  import { homedir } from "node:os";
@@ -18,11 +19,12 @@ import { Text } from "@mariozechner/pi-tui";
18
19
  import { Type } from "@sinclair/typebox";
19
20
  import { AgentManager } from "./agent-manager.js";
20
21
  import { steerAgent, getAgentConversation, getDefaultMaxTurns, setDefaultMaxTurns, getGraceTurns, setGraceTurns } from "./agent-runner.js";
21
- import { DEFAULT_AGENT_NAMES, type SubagentType, type ThinkingLevel, type AgentConfig, type JoinMode, type AgentRecord } from "./types.js";
22
+ import { DEFAULT_AGENT_NAMES, type SubagentType, type ThinkingLevel, type AgentConfig, type JoinMode, type AgentRecord, type NotificationDetails } from "./types.js";
22
23
  import { GroupJoinManager } from "./group-join.js";
23
24
  import { getAvailableTypes, getAllTypes, getDefaultAgentNames, getUserAgentNames, getAgentConfig, resolveType, registerAgents, BUILTIN_TOOL_NAMES } from "./agent-types.js";
24
25
  import { loadCustomAgents } from "./custom-agents.js";
25
26
  import { resolveModel, type ModelRegistry } from "./model-resolver.js";
27
+ import { createOutputFilePath, writeInitialEntry, streamToOutputFile } from "./output-file.js";
26
28
  import {
27
29
  AgentWidget,
28
30
  SPINNER,
@@ -103,6 +105,42 @@ function getStatusNote(status: string): string {
103
105
  }
104
106
  }
105
107
 
108
+ /** Escape XML special characters to prevent injection in structured notifications. */
109
+ function escapeXml(s: string): string {
110
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
111
+ }
112
+
113
+ /** Format a structured task notification matching Claude Code's <task-notification> XML. */
114
+ function formatTaskNotification(record: AgentRecord, resultMaxLen: number): string {
115
+ const status = getStatusLabel(record.status, record.error);
116
+ const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
117
+ let totalTokens = 0;
118
+ try {
119
+ if (record.session) {
120
+ const stats = record.session.getSessionStats();
121
+ totalTokens = stats.tokens?.total ?? 0;
122
+ }
123
+ } catch { /* session stats unavailable */ }
124
+
125
+ const resultPreview = record.result
126
+ ? record.result.length > resultMaxLen
127
+ ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
128
+ : record.result
129
+ : "No output.";
130
+
131
+ return [
132
+ `<task-notification>`,
133
+ `<task-id>${record.id}</task-id>`,
134
+ record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
135
+ record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
136
+ `<status>${escapeXml(status)}</status>`,
137
+ `<summary>Agent "${escapeXml(record.description)}" ${record.status}</summary>`,
138
+ `<result>${escapeXml(resultPreview)}</result>`,
139
+ `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses><duration_ms>${durationMs}</duration_ms></usage>`,
140
+ `</task-notification>`,
141
+ ].filter(Boolean).join('\n');
142
+ }
143
+
106
144
  /** Build AgentDetails from a base + record-specific fields. */
107
145
  function buildDetails(
108
146
  base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
@@ -121,7 +159,79 @@ function buildDetails(
121
159
  };
122
160
  }
123
161
 
162
+ /** Build notification details for the custom message renderer. */
163
+ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number): NotificationDetails {
164
+ let totalTokens = 0;
165
+ try {
166
+ if (record.session) totalTokens = record.session.getSessionStats().tokens?.total ?? 0;
167
+ } catch {}
168
+
169
+ return {
170
+ id: record.id,
171
+ description: record.description,
172
+ status: record.status,
173
+ toolUses: record.toolUses,
174
+ totalTokens,
175
+ durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
176
+ outputFile: record.outputFile,
177
+ error: record.error,
178
+ resultPreview: record.result
179
+ ? record.result.length > resultMaxLen
180
+ ? record.result.slice(0, resultMaxLen) + "…"
181
+ : record.result
182
+ : "No output.",
183
+ };
184
+ }
185
+
124
186
  export default function (pi: ExtensionAPI) {
187
+ // ---- Register custom notification renderer ----
188
+ pi.registerMessageRenderer<NotificationDetails>(
189
+ "subagent-notification",
190
+ (message, { expanded }, theme) => {
191
+ const d = message.details;
192
+ if (!d) return undefined;
193
+
194
+ function renderOne(d: NotificationDetails): string {
195
+ const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
196
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
197
+ const statusText = isError ? d.status
198
+ : d.status === "steered" ? "completed (steered)"
199
+ : "completed";
200
+
201
+ // Line 1: icon + agent description + status
202
+ let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
203
+
204
+ // Line 2: stats
205
+ const parts: string[] = [];
206
+ if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
207
+ if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
208
+ if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
209
+ if (parts.length) {
210
+ line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
211
+ }
212
+
213
+ // Line 3: result preview (collapsed) or full (expanded)
214
+ if (expanded) {
215
+ const lines = d.resultPreview.split("\n").slice(0, 30);
216
+ for (const l of lines) line += "\n" + theme.fg("dim", ` ${l}`);
217
+ } else {
218
+ const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
219
+ line += "\n " + theme.fg("dim", `⎿ ${preview}`);
220
+ }
221
+
222
+ // Line 4: output file link (if present)
223
+ if (d.outputFile) {
224
+ line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`);
225
+ }
226
+
227
+ return line;
228
+ }
229
+
230
+ const all = [d, ...(d.others ?? [])];
231
+ return new Text(all.map(renderOne).join("\n"), 0, 0);
232
+ }
233
+ );
234
+
125
235
  /** Reload agents from .pi/agents/*.md and merge with defaults (called on init and each Agent invocation). */
126
236
  const reloadCustomAgents = () => {
127
237
  const userAgents = loadCustomAgents(process.cwd());
@@ -134,71 +244,79 @@ export default function (pi: ExtensionAPI) {
134
244
  // ---- Agent activity tracking + widget ----
135
245
  const agentActivity = new Map<string, AgentActivity>();
136
246
 
247
+ // ---- Cancellable pending notifications ----
248
+ // Holds notifications briefly so get_subagent_result can cancel them
249
+ // before they reach pi.sendMessage (fire-and-forget).
250
+ const pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
251
+ const NUDGE_HOLD_MS = 200;
252
+
253
+ function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) {
254
+ cancelNudge(key);
255
+ pendingNudges.set(key, setTimeout(() => {
256
+ pendingNudges.delete(key);
257
+ send();
258
+ }, delay));
259
+ }
260
+
261
+ function cancelNudge(key: string) {
262
+ const timer = pendingNudges.get(key);
263
+ if (timer != null) {
264
+ clearTimeout(timer);
265
+ pendingNudges.delete(key);
266
+ }
267
+ }
268
+
137
269
  // ---- Individual nudge helper (async join mode) ----
138
- function sendIndividualNudge(record: AgentRecord) {
139
- const displayName = getDisplayName(record.type);
140
- const duration = formatDuration(record.startedAt, record.completedAt);
141
- const status = getStatusLabel(record.status, record.error);
142
- const resultPreview = record.result
143
- ? record.result.length > 500
144
- ? record.result.slice(0, 500) + "\n...(truncated, use get_subagent_result for full output)"
145
- : record.result
146
- : "No output.";
270
+ function emitIndividualNudge(record: AgentRecord) {
271
+ if (record.resultConsumed) return; // re-check at send time
272
+
273
+ const notification = formatTaskNotification(record, 500);
274
+ const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
275
+
276
+ pi.sendMessage<NotificationDetails>({
277
+ customType: "subagent-notification",
278
+ content: notification + footer,
279
+ display: true,
280
+ details: buildNotificationDetails(record, 500),
281
+ }, { deliverAs: "followUp" });
282
+ }
147
283
 
284
+ function sendIndividualNudge(record: AgentRecord) {
148
285
  agentActivity.delete(record.id);
149
286
  widget.markFinished(record.id);
150
-
151
- const tokens = safeFormatTokens(record.session);
152
- const toolStats = tokens ? `Tool uses: ${record.toolUses} | ${tokens}` : `Tool uses: ${record.toolUses}`;
153
- pi.sendUserMessage(
154
- `Background agent completed: ${displayName} (${record.description})\n` +
155
- `Agent ID: ${record.id} | Status: ${status} | ${toolStats} | Duration: ${duration}\n\n` +
156
- resultPreview,
157
- { deliverAs: "followUp" },
158
- );
287
+ scheduleNudge(record.id, () => emitIndividualNudge(record));
159
288
  widget.update();
160
289
  }
161
290
 
162
- /** Format a single agent's summary for grouped notification. */
163
- function formatAgentSummary(record: AgentRecord): string {
164
- const displayName = getDisplayName(record.type);
165
- const duration = formatDuration(record.startedAt, record.completedAt);
166
- const status = getStatusLabel(record.status, record.error);
167
- const resultPreview = record.result
168
- ? record.result.length > 300
169
- ? record.result.slice(0, 300) + "\n...(truncated)"
170
- : record.result
171
- : "No output.";
172
- const tokens = safeFormatTokens(record.session);
173
- const toolStats = tokens ? `Tools: ${record.toolUses} | ${tokens}` : `Tools: ${record.toolUses}`;
174
- return `- ${displayName} (${record.description})\n ID: ${record.id} | Status: ${status} | ${toolStats} | Duration: ${duration}\n ${resultPreview}`;
175
- }
176
-
177
291
  // ---- Group join manager ----
178
292
  const groupJoin = new GroupJoinManager(
179
293
  (records, partial) => {
180
- // Filter out agents whose results were already consumed via get_subagent_result
181
- const unconsumed = records.filter(r => !r.resultConsumed);
182
-
183
- for (const r of records) {
184
- agentActivity.delete(r.id);
185
- widget.markFinished(r.id);
186
- }
187
-
188
- // If all results were already consumed, skip the notification entirely
189
- if (unconsumed.length === 0) {
190
- widget.update();
191
- return;
192
- }
193
-
194
- const total = unconsumed.length;
195
- const label = partial ? `${total} agent(s) finished (partial — others still running)` : `${total} agent(s) finished`;
196
- const summary = unconsumed.map(r => formatAgentSummary(r)).join("\n\n");
294
+ for (const r of records) { agentActivity.delete(r.id); widget.markFinished(r.id); }
295
+
296
+ const groupKey = `group:${records.map(r => r.id).join(",")}`;
297
+ scheduleNudge(groupKey, () => {
298
+ // Re-check at send time
299
+ const unconsumed = records.filter(r => !r.resultConsumed);
300
+ if (unconsumed.length === 0) { widget.update(); return; }
301
+
302
+ const notifications = unconsumed.map(r => formatTaskNotification(r, 300)).join('\n\n');
303
+ const label = partial
304
+ ? `${unconsumed.length} agent(s) finished (partial — others still running)`
305
+ : `${unconsumed.length} agent(s) finished`;
306
+
307
+ const [first, ...rest] = unconsumed;
308
+ const details = buildNotificationDetails(first, 300);
309
+ if (rest.length > 0) {
310
+ details.others = rest.map(r => buildNotificationDetails(r, 300));
311
+ }
197
312
 
198
- pi.sendUserMessage(
199
- `Background agent group completed: ${label}\n\n${summary}\n\nUse get_subagent_result for full output.`,
200
- { deliverAs: "followUp" },
201
- );
313
+ pi.sendMessage<NotificationDetails>({
314
+ customType: "subagent-notification",
315
+ content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
316
+ display: true,
317
+ details,
318
+ }, { deliverAs: "followUp" });
319
+ });
202
320
  widget.update();
203
321
  },
204
322
  30_000,
@@ -242,6 +360,13 @@ export default function (pi: ExtensionAPI) {
242
360
  pi.events.emit("subagents:completed", eventData);
243
361
  }
244
362
 
363
+ // Persist final record for cross-extension history reconstruction
364
+ pi.appendEntry("subagents:record", {
365
+ id: record.id, type: record.type, description: record.description,
366
+ status: record.status, result: record.result, error: record.error,
367
+ startedAt: record.startedAt, completedAt: record.completedAt,
368
+ });
369
+
245
370
  // Skip notification if result was already consumed via get_subagent_result
246
371
  if (record.resultConsumed) {
247
372
  agentActivity.delete(record.id);
@@ -284,15 +409,37 @@ export default function (pi: ExtensionAPI) {
284
409
  getRecord: (id: string) => manager.getRecord(id),
285
410
  };
286
411
 
287
- // Clear completed tasks when a new session starts (e.g. /new) so stale records don't persist
288
- pi.on("session_start", () => { manager.clearCompleted(); });
412
+ // --- Cross-extension RPC via pi.events ---
413
+ let currentCtx: ExtensionContext | undefined;
414
+
415
+ // Capture ctx from session_start for RPC spawn handler
416
+ pi.on("session_start", async (_event, ctx) => {
417
+ currentCtx = ctx;
418
+ manager.clearCompleted(); // preserve existing behavior
419
+ });
420
+
289
421
  pi.on("session_switch", () => { manager.clearCompleted(); });
290
422
 
423
+ const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc } = registerRpcHandlers({
424
+ events: pi.events,
425
+ pi,
426
+ getCtx: () => currentCtx,
427
+ manager,
428
+ });
429
+
430
+ // Broadcast readiness so extensions loaded after us can discover us
431
+ pi.events.emit("subagents:ready", {});
432
+
291
433
  // On shutdown, abort all agents immediately and clean up.
292
434
  // If the session is going down, there's nothing left to consume agent results.
293
435
  pi.on("session_shutdown", async () => {
436
+ unsubSpawnRpc();
437
+ unsubPingRpc();
438
+ currentCtx = undefined;
294
439
  delete (globalThis as any)[MANAGER_KEY];
295
440
  manager.abortAll();
441
+ for (const timer of pendingNudges.values()) clearTimeout(timer);
442
+ pendingNudges.clear();
296
443
  manager.dispose();
297
444
  });
298
445
 
@@ -564,7 +711,7 @@ Guidelines:
564
711
 
565
712
  // ---- Execute ----
566
713
 
567
- execute: async (_toolCallId, params, signal, onUpdate, ctx) => {
714
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
568
715
  // Ensure we have UI context for widget rendering
569
716
  widget.setUICtx(ctx.ui as UICtx);
570
717
 
@@ -647,7 +794,20 @@ Guidelines:
647
794
  if (runInBackground) {
648
795
  const { state: bgState, callbacks: bgCallbacks } = createActivityTracker();
649
796
 
650
- const id = manager.spawn(pi, ctx, subagentType, params.prompt, {
797
+ // Wrap onSessionCreated to wire output file streaming.
798
+ // The callback lazily reads record.outputFile (set right after spawn)
799
+ // rather than closing over a value that doesn't exist yet.
800
+ let id: string;
801
+ const origBgOnSession = bgCallbacks.onSessionCreated;
802
+ bgCallbacks.onSessionCreated = (session: any) => {
803
+ origBgOnSession(session);
804
+ const rec = manager.getRecord(id);
805
+ if (rec?.outputFile) {
806
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd);
807
+ }
808
+ };
809
+
810
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
651
811
  description: params.description,
652
812
  model,
653
813
  maxTurns: params.max_turns,
@@ -659,10 +819,16 @@ Guidelines:
659
819
  ...bgCallbacks,
660
820
  });
661
821
 
662
- // Determine join mode and track for batching
822
+ // Set output file + join mode synchronously after spawn, before the
823
+ // event loop yields — onSessionCreated is async so this is safe.
663
824
  const joinMode: JoinMode = params.join_mode ?? defaultJoinMode;
664
825
  const record = manager.getRecord(id);
665
- if (record) record.joinMode = joinMode;
826
+ if (record) {
827
+ record.joinMode = joinMode;
828
+ record.toolCallId = toolCallId;
829
+ record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
830
+ writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd);
831
+ }
666
832
 
667
833
  if (joinMode === 'async') {
668
834
  // Explicit async — not part of any batch
@@ -693,6 +859,7 @@ Guidelines:
693
859
  `Agent ID: ${id}\n` +
694
860
  `Type: ${displayName}\n` +
695
861
  `Description: ${params.description}\n` +
862
+ (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
696
863
  (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
697
864
  `\nYou will be notified when this agent completes.\n` +
698
865
  `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
@@ -823,6 +990,7 @@ Guidelines:
823
990
  // Setting the flag here prevents a redundant follow-up notification.
824
991
  if (params.wait && record.status === "running" && record.promise) {
825
992
  record.resultConsumed = true;
993
+ cancelNudge(params.agent_id);
826
994
  await record.promise;
827
995
  }
828
996
 
@@ -847,6 +1015,7 @@ Guidelines:
847
1015
  // Mark result as consumed — suppresses the completion notification
848
1016
  if (record.status !== "running" && record.status !== "queued") {
849
1017
  record.resultConsumed = true;
1018
+ cancelNudge(params.agent_id);
850
1019
  }
851
1020
 
852
1021
  // Verbose: include full conversation
@@ -0,0 +1,77 @@
1
+ /**
2
+ * output-file.ts — Streaming JSONL output file for agent transcripts.
3
+ *
4
+ * Creates a per-agent output file that streams conversation turns as JSONL,
5
+ * matching Claude Code's task output file format.
6
+ */
7
+
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { mkdirSync, chmodSync, appendFileSync, writeFileSync } from "node:fs";
11
+ import type { AgentSession, AgentSessionEvent } from "@mariozechner/pi-coding-agent";
12
+
13
+ /** Create the output file path, ensuring the directory exists.
14
+ * Mirrors Claude Code's layout: /tmp/{prefix}-{uid}/{encoded-cwd}/{sessionId}/tasks/{agentId}.output */
15
+ export function createOutputFilePath(cwd: string, agentId: string, sessionId: string): string {
16
+ const encoded = cwd.replace(/\//g, "-").replace(/^-/, "");
17
+ const root = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`);
18
+ mkdirSync(root, { recursive: true, mode: 0o700 });
19
+ chmodSync(root, 0o700);
20
+ const dir = join(root, encoded, sessionId, "tasks");
21
+ mkdirSync(dir, { recursive: true });
22
+ return join(dir, `${agentId}.output`);
23
+ }
24
+
25
+ /** Write the initial user prompt entry. */
26
+ export function writeInitialEntry(path: string, agentId: string, prompt: string, cwd: string): void {
27
+ const entry = {
28
+ isSidechain: true,
29
+ agentId,
30
+ type: "user",
31
+ message: { role: "user", content: prompt },
32
+ timestamp: new Date().toISOString(),
33
+ cwd,
34
+ };
35
+ writeFileSync(path, JSON.stringify(entry) + "\n", "utf-8");
36
+ }
37
+
38
+ /**
39
+ * Subscribe to session events and flush new messages to the output file on each turn_end.
40
+ * Returns a cleanup function that does a final flush and unsubscribes.
41
+ */
42
+ export function streamToOutputFile(
43
+ session: AgentSession,
44
+ path: string,
45
+ agentId: string,
46
+ cwd: string,
47
+ ): () => void {
48
+ let writtenCount = 1; // initial user prompt already written
49
+
50
+ const flush = () => {
51
+ const messages = session.messages;
52
+ while (writtenCount < messages.length) {
53
+ const msg = messages[writtenCount];
54
+ const entry = {
55
+ isSidechain: true,
56
+ agentId,
57
+ type: msg.role === "assistant" ? "assistant" : msg.role === "user" ? "user" : "toolResult",
58
+ message: msg,
59
+ timestamp: new Date().toISOString(),
60
+ cwd,
61
+ };
62
+ try {
63
+ appendFileSync(path, JSON.stringify(entry) + "\n", "utf-8");
64
+ } catch { /* ignore write errors */ }
65
+ writtenCount++;
66
+ }
67
+ };
68
+
69
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
70
+ if (event.type === "turn_end") flush();
71
+ });
72
+
73
+ return () => {
74
+ flush();
75
+ unsubscribe();
76
+ };
77
+ }
package/src/types.ts CHANGED
@@ -79,6 +79,27 @@ export interface AgentRecord {
79
79
  worktree?: { path: string; branch: string };
80
80
  /** Worktree cleanup result after agent completion. */
81
81
  worktreeResult?: { hasChanges: boolean; branch?: string };
82
+ /** The tool_use_id from the original Agent tool call. */
83
+ toolCallId?: string;
84
+ /** Path to the streaming output transcript file. */
85
+ outputFile?: string;
86
+ /** Cleanup function for the output file stream subscription. */
87
+ outputCleanup?: () => void;
88
+ }
89
+
90
+ /** Details attached to custom notification messages for visual rendering. */
91
+ export interface NotificationDetails {
92
+ id: string;
93
+ description: string;
94
+ status: string;
95
+ toolUses: number;
96
+ totalTokens: number;
97
+ durationMs: number;
98
+ outputFile?: string;
99
+ error?: string;
100
+ resultPreview: string;
101
+ /** Additional agents in a group notification. */
102
+ others?: NotificationDetails[];
82
103
  }
83
104
 
84
105
  export interface EnvInfo {
@@ -238,6 +238,6 @@ export class ConversationViewer implements Component {
238
238
  lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
239
239
  }
240
240
 
241
- return lines;
241
+ return lines.map(l => truncateToWidth(l, width));
242
242
  }
243
243
  }