@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 +36 -0
- package/README.md +67 -0
- package/package.json +4 -4
- package/src/agent-manager.ts +12 -0
- package/src/cross-extension-rpc.ts +61 -0
- package/src/index.ts +231 -62
- package/src/output-file.ts +77 -0
- package/src/types.ts +21 -0
- package/src/ui/conversation-viewer.ts +1 -1
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.
|
|
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.
|
|
25
|
-
"@mariozechner/pi-coding-agent": "^0.
|
|
26
|
-
"@mariozechner/pi-tui": "^0.
|
|
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": {
|
package/src/agent-manager.ts
CHANGED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
:
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
//
|
|
288
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
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)
|
|
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 {
|