@tintinweb/pi-subagents 0.4.10 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,254 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ // ── Mock wrapTextWithAnsi ──────────────────────────────────────────────
3
+ // We need to control what wrapTextWithAnsi returns to simulate the
4
+ // upstream bug (returning lines wider than requested width).
5
+ // vi.mock is hoisted and intercepts before conversation-viewer.ts binds
6
+ // its import.
7
+ let wrapOverride = null;
8
+ vi.mock("@mariozechner/pi-tui", async (importOriginal) => {
9
+ const original = await importOriginal();
10
+ return {
11
+ ...original,
12
+ wrapTextWithAnsi: (...args) => {
13
+ if (wrapOverride)
14
+ return wrapOverride(...args);
15
+ return original.wrapTextWithAnsi(...args);
16
+ },
17
+ };
18
+ });
19
+ // Must import AFTER vi.mock declaration (vitest hoists vi.mock but the
20
+ // dynamic import of the test subject must happen after)
21
+ const { visibleWidth } = await import("@mariozechner/pi-tui");
22
+ const { ConversationViewer } = await import("./conversation-viewer.js");
23
+ // ── Helpers ────────────────────────────────────────────────────────────
24
+ function mockTui(rows = 40, columns = 80) {
25
+ return {
26
+ terminal: { rows, columns },
27
+ requestRender: vi.fn(),
28
+ };
29
+ }
30
+ function mockSession(messages = []) {
31
+ return {
32
+ messages,
33
+ subscribe: vi.fn(() => vi.fn()),
34
+ dispose: vi.fn(),
35
+ getSessionStats: () => ({ tokens: { total: 0 } }),
36
+ };
37
+ }
38
+ function mockRecord(overrides = {}) {
39
+ return {
40
+ id: "test-1",
41
+ type: "general-purpose",
42
+ description: "test agent",
43
+ status: "running",
44
+ toolUses: 0,
45
+ startedAt: Date.now(),
46
+ ...overrides,
47
+ };
48
+ }
49
+ function ansiTheme() {
50
+ return {
51
+ fg: (_color, text) => `\x1b[38;5;240m${text}\x1b[0m`,
52
+ bold: (text) => `\x1b[1m${text}\x1b[22m`,
53
+ };
54
+ }
55
+ function assertAllLinesFit(lines, width) {
56
+ for (let i = 0; i < lines.length; i++) {
57
+ const vw = visibleWidth(lines[i]);
58
+ expect(vw, `line ${i} exceeds width (${vw} > ${width}): ${JSON.stringify(lines[i])}`).toBeLessThanOrEqual(width);
59
+ }
60
+ }
61
+ // ── Tests ──────────────────────────────────────────────────────────────
62
+ beforeEach(() => {
63
+ wrapOverride = null;
64
+ });
65
+ describe("ConversationViewer", () => {
66
+ describe("render width safety", () => {
67
+ const widths = [40, 80, 120, 216];
68
+ it("no line exceeds width with empty messages", () => {
69
+ for (const w of widths) {
70
+ const viewer = new ConversationViewer(mockTui(30, w), mockSession([]), mockRecord(), undefined, ansiTheme(), vi.fn());
71
+ assertAllLinesFit(viewer.render(w), w);
72
+ }
73
+ });
74
+ it("no line exceeds width with plain text messages", () => {
75
+ const messages = [
76
+ { role: "user", content: "Hello, how are you?" },
77
+ { role: "assistant", content: [{ type: "text", text: "I am fine, thank you for asking." }] },
78
+ ];
79
+ for (const w of widths) {
80
+ const viewer = new ConversationViewer(mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn());
81
+ assertAllLinesFit(viewer.render(w), w);
82
+ }
83
+ });
84
+ it("no line exceeds width when text is longer than viewport", () => {
85
+ const longLine = "A".repeat(500);
86
+ const messages = [
87
+ { role: "user", content: longLine },
88
+ { role: "assistant", content: [{ type: "text", text: longLine }] },
89
+ { role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: longLine }] },
90
+ ];
91
+ for (const w of widths) {
92
+ const viewer = new ConversationViewer(mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn());
93
+ assertAllLinesFit(viewer.render(w), w);
94
+ }
95
+ });
96
+ it("no line exceeds width with embedded ANSI escape codes in content", () => {
97
+ const ansiText = `\x1b[1mBold heading\x1b[22m and \x1b[31mred text\x1b[0m ${"X".repeat(300)}`;
98
+ const messages = [
99
+ { role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: ansiText }] },
100
+ ];
101
+ for (const w of widths) {
102
+ const viewer = new ConversationViewer(mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn());
103
+ assertAllLinesFit(viewer.render(w), w);
104
+ }
105
+ });
106
+ it("no line exceeds width with long URLs", () => {
107
+ const url = "https://example.com/" + "a/b/c/d/e/".repeat(30) + "?q=" + "x".repeat(100);
108
+ const messages = [
109
+ { role: "assistant", content: [{ type: "text", text: `Check this link: ${url}` }] },
110
+ ];
111
+ for (const w of widths) {
112
+ const viewer = new ConversationViewer(mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn());
113
+ assertAllLinesFit(viewer.render(w), w);
114
+ }
115
+ });
116
+ it("no line exceeds width with wide table-like content", () => {
117
+ const header = "| " + Array.from({ length: 20 }, (_, i) => `Column${i}`).join(" | ") + " |";
118
+ const dataRow = "| " + Array.from({ length: 20 }, () => "value123").join(" | ") + " |";
119
+ const table = [header, dataRow, dataRow, dataRow].join("\n");
120
+ const messages = [
121
+ { role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: table }] },
122
+ ];
123
+ for (const w of widths) {
124
+ const viewer = new ConversationViewer(mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn());
125
+ assertAllLinesFit(viewer.render(w), w);
126
+ }
127
+ });
128
+ it("no line exceeds width with bashExecution messages", () => {
129
+ const messages = [
130
+ {
131
+ role: "bashExecution", command: "cat " + "/very/long/path/".repeat(20) + "file.txt",
132
+ output: "O".repeat(600),
133
+ exitCode: 0, cancelled: false, truncated: false, timestamp: Date.now(),
134
+ },
135
+ ];
136
+ for (const w of widths) {
137
+ const viewer = new ConversationViewer(mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn());
138
+ assertAllLinesFit(viewer.render(w), w);
139
+ }
140
+ });
141
+ it("no line exceeds width with running activity indicator", () => {
142
+ const activity = {
143
+ activeTools: new Map([["read", "file.ts"], ["grep", "pattern"]]),
144
+ toolUses: 5, tokens: "10k", responseText: "R".repeat(400),
145
+ session: { getSessionStats: () => ({ tokens: { total: 50000 } }) },
146
+ };
147
+ const messages = [
148
+ { role: "user", content: "do the thing" },
149
+ { role: "assistant", content: [{ type: "text", text: "working on it" }] },
150
+ ];
151
+ for (const w of widths) {
152
+ const viewer = new ConversationViewer(mockTui(30, w), mockSession(messages), mockRecord({ status: "running" }), activity, ansiTheme(), vi.fn());
153
+ assertAllLinesFit(viewer.render(w), w);
154
+ }
155
+ });
156
+ it("no line exceeds width with tool calls", () => {
157
+ const messages = [
158
+ {
159
+ role: "assistant",
160
+ content: [
161
+ { type: "text", text: "Let me check that." },
162
+ { type: "toolCall", toolUseId: "t1", name: "very_long_tool_name_" + "x".repeat(200), input: {} },
163
+ ],
164
+ },
165
+ ];
166
+ for (const w of widths) {
167
+ const viewer = new ConversationViewer(mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn());
168
+ assertAllLinesFit(viewer.render(w), w);
169
+ }
170
+ });
171
+ it("no line exceeds width at narrow terminal", () => {
172
+ const messages = [
173
+ { role: "user", content: "Hello world, this is a normal sentence." },
174
+ { role: "assistant", content: [{ type: "text", text: "Sure, here's the answer." }] },
175
+ ];
176
+ for (const w of [8, 10, 15, 20]) {
177
+ const viewer = new ConversationViewer(mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn());
178
+ assertAllLinesFit(viewer.render(w), w);
179
+ }
180
+ });
181
+ it("no line exceeds width with mixed ANSI + unicode content", () => {
182
+ const text = `\x1b[32m✓\x1b[0m Test passed — 日本語テスト ${"あ".repeat(50)} \x1b[33m⚠\x1b[0m`;
183
+ const messages = [
184
+ { role: "toolResult", toolUseId: "t1", content: [{ type: "text", text }] },
185
+ ];
186
+ for (const w of widths) {
187
+ const viewer = new ConversationViewer(mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn());
188
+ assertAllLinesFit(viewer.render(w), w);
189
+ }
190
+ });
191
+ });
192
+ describe("safety net against upstream wrapTextWithAnsi bugs", () => {
193
+ // These tests call buildContentLines() directly (via the private method)
194
+ // because render() has its own truncation via row(). The safety net in
195
+ // buildContentLines is what prevents the TUI crash — it must clamp
196
+ // independently of render().
197
+ /** Call the private buildContentLines method directly. */
198
+ function callBuildContentLines(viewer, width) {
199
+ return viewer.buildContentLines(width);
200
+ }
201
+ it("mock is intercepting wrapTextWithAnsi", async () => {
202
+ const { wrapTextWithAnsi } = await import("@mariozechner/pi-tui");
203
+ wrapOverride = () => ["MOCK_SENTINEL"];
204
+ expect(wrapTextWithAnsi("anything", 10)).toEqual(["MOCK_SENTINEL"]);
205
+ wrapOverride = null;
206
+ });
207
+ it("clamps overwidth lines from toolResult content", () => {
208
+ const w = 80;
209
+ wrapOverride = () => ["X".repeat(w + 50)];
210
+ const messages = [
211
+ { role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: "output" }] },
212
+ ];
213
+ const viewer = new ConversationViewer(mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn());
214
+ assertAllLinesFit(callBuildContentLines(viewer, w), w);
215
+ });
216
+ it("clamps overwidth lines from user message content", () => {
217
+ const w = 80;
218
+ wrapOverride = () => ["Y".repeat(w + 100)];
219
+ const messages = [{ role: "user", content: "hello" }];
220
+ const viewer = new ConversationViewer(mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn());
221
+ assertAllLinesFit(callBuildContentLines(viewer, w), w);
222
+ });
223
+ it("clamps overwidth lines from assistant message content", () => {
224
+ const w = 80;
225
+ wrapOverride = () => ["Z".repeat(w + 100)];
226
+ const messages = [
227
+ { role: "assistant", content: [{ type: "text", text: "response" }] },
228
+ ];
229
+ const viewer = new ConversationViewer(mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn());
230
+ assertAllLinesFit(callBuildContentLines(viewer, w), w);
231
+ });
232
+ it("clamps overwidth lines from bashExecution output", () => {
233
+ const w = 80;
234
+ wrapOverride = () => ["B".repeat(w + 100)];
235
+ const messages = [
236
+ {
237
+ role: "bashExecution", command: "ls", output: "out",
238
+ exitCode: 0, cancelled: false, truncated: false, timestamp: Date.now(),
239
+ },
240
+ ];
241
+ const viewer = new ConversationViewer(mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn());
242
+ assertAllLinesFit(callBuildContentLines(viewer, w), w);
243
+ });
244
+ it("clamps overwidth lines that also contain ANSI codes", () => {
245
+ const w = 80;
246
+ wrapOverride = () => [`\x1b[1m\x1b[31m${"W".repeat(w + 30)}\x1b[0m`];
247
+ const messages = [
248
+ { role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: "output" }] },
249
+ ];
250
+ const viewer = new ConversationViewer(mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn());
251
+ assertAllLinesFit(callBuildContentLines(viewer, w), w);
252
+ });
253
+ });
254
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * worktree.ts — Git worktree isolation for agents.
3
+ *
4
+ * Creates a temporary git worktree so the agent works on an isolated copy of the repo.
5
+ * On completion, if no changes were made, the worktree is cleaned up.
6
+ * If changes exist, a branch is created and returned in the result.
7
+ */
8
+ export interface WorktreeInfo {
9
+ /** Absolute path to the worktree directory. */
10
+ path: string;
11
+ /** Branch name created for this worktree (if changes exist). */
12
+ branch: string;
13
+ }
14
+ export interface WorktreeCleanupResult {
15
+ /** Whether changes were found in the worktree. */
16
+ hasChanges: boolean;
17
+ /** Branch name if changes were committed. */
18
+ branch?: string;
19
+ /** Worktree path if it was kept. */
20
+ path?: string;
21
+ }
22
+ /**
23
+ * Create a temporary git worktree for an agent.
24
+ * Returns the worktree path, or undefined if not in a git repo.
25
+ */
26
+ export declare function createWorktree(cwd: string, agentId: string): WorktreeInfo | undefined;
27
+ /**
28
+ * Clean up a worktree after agent completion.
29
+ * - If no changes: remove worktree entirely.
30
+ * - If changes exist: create a branch, commit changes, return branch info.
31
+ */
32
+ export declare function cleanupWorktree(cwd: string, worktree: WorktreeInfo, agentDescription: string): WorktreeCleanupResult;
33
+ /**
34
+ * Prune any orphaned worktrees (crash recovery).
35
+ */
36
+ export declare function pruneWorktrees(cwd: string): void;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * worktree.ts — Git worktree isolation for agents.
3
+ *
4
+ * Creates a temporary git worktree so the agent works on an isolated copy of the repo.
5
+ * On completion, if no changes were made, the worktree is cleaned up.
6
+ * If changes exist, a branch is created and returned in the result.
7
+ */
8
+ import { execFileSync } from "node:child_process";
9
+ import { randomUUID } from "node:crypto";
10
+ import { existsSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ /**
14
+ * Create a temporary git worktree for an agent.
15
+ * Returns the worktree path, or undefined if not in a git repo.
16
+ */
17
+ export function createWorktree(cwd, agentId) {
18
+ // Verify we're in a git repo with at least one commit (HEAD must exist)
19
+ try {
20
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
21
+ execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 });
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ const branch = `pi-agent-${agentId}`;
27
+ const suffix = randomUUID().slice(0, 8);
28
+ const worktreePath = join(tmpdir(), `pi-agent-${agentId}-${suffix}`);
29
+ try {
30
+ // Create detached worktree at HEAD
31
+ execFileSync("git", ["worktree", "add", "--detach", worktreePath, "HEAD"], {
32
+ cwd,
33
+ stdio: "pipe",
34
+ timeout: 30000,
35
+ });
36
+ return { path: worktreePath, branch };
37
+ }
38
+ catch {
39
+ // If worktree creation fails, return undefined (agent runs in normal cwd)
40
+ return undefined;
41
+ }
42
+ }
43
+ /**
44
+ * Clean up a worktree after agent completion.
45
+ * - If no changes: remove worktree entirely.
46
+ * - If changes exist: create a branch, commit changes, return branch info.
47
+ */
48
+ export function cleanupWorktree(cwd, worktree, agentDescription) {
49
+ if (!existsSync(worktree.path)) {
50
+ return { hasChanges: false };
51
+ }
52
+ try {
53
+ // Check for uncommitted changes in the worktree
54
+ const status = execFileSync("git", ["status", "--porcelain"], {
55
+ cwd: worktree.path,
56
+ stdio: "pipe",
57
+ timeout: 10000,
58
+ }).toString().trim();
59
+ if (!status) {
60
+ // No changes — remove worktree
61
+ removeWorktree(cwd, worktree.path);
62
+ return { hasChanges: false };
63
+ }
64
+ // Changes exist — stage, commit, and create a branch
65
+ execFileSync("git", ["add", "-A"], { cwd: worktree.path, stdio: "pipe", timeout: 10000 });
66
+ // Truncate description for commit message (no shell sanitization needed — execFileSync uses argv)
67
+ const safeDesc = agentDescription.slice(0, 200);
68
+ const commitMsg = `pi-agent: ${safeDesc}`;
69
+ execFileSync("git", ["commit", "-m", commitMsg], {
70
+ cwd: worktree.path,
71
+ stdio: "pipe",
72
+ timeout: 10000,
73
+ });
74
+ // Create a branch pointing to the worktree's HEAD.
75
+ // If the branch already exists, append a suffix to avoid overwriting previous work.
76
+ let branchName = worktree.branch;
77
+ try {
78
+ execFileSync("git", ["branch", branchName], {
79
+ cwd: worktree.path,
80
+ stdio: "pipe",
81
+ timeout: 5000,
82
+ });
83
+ }
84
+ catch {
85
+ // Branch already exists — use a unique suffix
86
+ branchName = `${worktree.branch}-${Date.now()}`;
87
+ execFileSync("git", ["branch", branchName], {
88
+ cwd: worktree.path,
89
+ stdio: "pipe",
90
+ timeout: 5000,
91
+ });
92
+ }
93
+ // Update branch name in worktree info for the caller
94
+ worktree.branch = branchName;
95
+ // Remove the worktree (branch persists in main repo)
96
+ removeWorktree(cwd, worktree.path);
97
+ return {
98
+ hasChanges: true,
99
+ branch: worktree.branch,
100
+ path: worktree.path,
101
+ };
102
+ }
103
+ catch {
104
+ // Best effort cleanup on error
105
+ try {
106
+ removeWorktree(cwd, worktree.path);
107
+ }
108
+ catch { /* ignore */ }
109
+ return { hasChanges: false };
110
+ }
111
+ }
112
+ /**
113
+ * Force-remove a worktree.
114
+ */
115
+ function removeWorktree(cwd, worktreePath) {
116
+ try {
117
+ execFileSync("git", ["worktree", "remove", "--force", worktreePath], {
118
+ cwd,
119
+ stdio: "pipe",
120
+ timeout: 10000,
121
+ });
122
+ }
123
+ catch {
124
+ // If git worktree remove fails, try pruning
125
+ try {
126
+ execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
127
+ }
128
+ catch { /* ignore */ }
129
+ }
130
+ }
131
+ /**
132
+ * Prune any orphaned worktrees (crash recovery).
133
+ */
134
+ export function pruneWorktrees(cwd) {
135
+ try {
136
+ execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
137
+ }
138
+ catch { /* ignore */ }
139
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.4.10",
3
+ "version": "0.5.0",
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,12 +21,14 @@
21
21
  "autonomous"
22
22
  ],
23
23
  "dependencies": {
24
- "@mariozechner/pi-ai": "^0.60.0",
25
- "@mariozechner/pi-coding-agent": "^0.60.0",
26
- "@mariozechner/pi-tui": "^0.60.0",
24
+ "@mariozechner/pi-ai": "^0.61.1",
25
+ "@mariozechner/pi-coding-agent": "^0.61.1",
26
+ "@mariozechner/pi-tui": "^0.61.1",
27
27
  "@sinclair/typebox": "latest"
28
28
  },
29
29
  "scripts": {
30
+ "build": "tsc",
31
+ "prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
30
32
  "test": "vitest run",
31
33
  "test:watch": "vitest",
32
34
  "typecheck": "tsc --noEmit",
@@ -34,9 +36,9 @@
34
36
  "lint:fix": "biome check --fix src/ test/"
35
37
  },
36
38
  "devDependencies": {
37
- "@types/node": "^20.0.0",
38
- "typescript": "^5.0.0",
39
39
  "@biomejs/biome": "^2.3.5",
40
+ "@types/node": "^25.5.0",
41
+ "typescript": "^5.0.0",
40
42
  "vitest": "^4.0.18"
41
43
  },
42
44
  "pi": {
@@ -393,7 +393,7 @@ export function getAgentConversation(session: AgentSession): string {
393
393
  const toolCalls: string[] = [];
394
394
  for (const c of msg.content) {
395
395
  if (c.type === "text" && c.text) textParts.push(c.text);
396
- else if (c.type === "toolCall") toolCalls.push(` Tool: ${(c as any).toolName ?? "unknown"}`);
396
+ else if (c.type === "toolCall") toolCalls.push(` Tool: ${(c as any).name ?? (c as any).toolName ?? "unknown"}`);
397
397
  }
398
398
  if (textParts.length > 0) parts.push(`[Assistant]: ${textParts.join("\n")}`);
399
399
  if (toolCalls.length > 0) parts.push(`[Tool Calls]:\n${toolCalls.join("\n")}`);
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * Cross-extension RPC handlers for the subagents extension.
3
3
  *
4
- * Exposes ping and spawn RPCs over the pi.events event bus,
4
+ * Exposes ping, spawn, and stop RPCs over the pi.events event bus,
5
5
  * using per-request scoped reply channels.
6
+ *
7
+ * Reply envelope follows pi-mono convention:
8
+ * success → { success: true, data?: T }
9
+ * error → { success: false, error: string }
6
10
  */
7
11
 
8
12
  /** Minimal event bus interface needed by the RPC handlers. */
@@ -11,9 +15,18 @@ export interface EventBus {
11
15
  emit(event: string, data: unknown): void;
12
16
  }
13
17
 
14
- /** Minimal AgentManager interface needed by the spawn RPC. */
18
+ /** RPC reply envelope matches pi-mono's RpcResponse shape. */
19
+ export type RpcReply<T = void> =
20
+ | { success: true; data?: T }
21
+ | { success: false; error: string };
22
+
23
+ /** RPC protocol version — bumped when the envelope or method contracts change. */
24
+ export const PROTOCOL_VERSION = 2;
25
+
26
+ /** Minimal AgentManager interface needed by the spawn/stop RPCs. */
15
27
  export interface SpawnCapable {
16
28
  spawn(pi: unknown, ctx: unknown, type: string, prompt: string, options: any): string;
29
+ abort(id: string): boolean;
17
30
  }
18
31
 
19
32
  export interface RpcDeps {
@@ -26,36 +39,57 @@ export interface RpcDeps {
26
39
  export interface RpcHandle {
27
40
  unsubPing: () => void;
28
41
  unsubSpawn: () => void;
42
+ unsubStop: () => void;
29
43
  }
30
44
 
31
45
  /**
32
- * Register ping and spawn RPC handlers on the event bus.
46
+ * Wire a single RPC handler: listen on `channel`, run `fn(params)`,
47
+ * emit the reply envelope on `channel:reply:${requestId}`.
48
+ */
49
+ function handleRpc<P extends { requestId: string }>(
50
+ events: EventBus,
51
+ channel: string,
52
+ fn: (params: P) => unknown | Promise<unknown>,
53
+ ): () => void {
54
+ return events.on(channel, async (raw: unknown) => {
55
+ const params = raw as P;
56
+ try {
57
+ const data = await fn(params);
58
+ const reply: { success: true; data?: unknown } = { success: true };
59
+ if (data !== undefined) reply.data = data;
60
+ events.emit(`${channel}:reply:${params.requestId}`, reply);
61
+ } catch (err: any) {
62
+ events.emit(`${channel}:reply:${params.requestId}`, {
63
+ success: false, error: err?.message ?? String(err),
64
+ });
65
+ }
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Register ping, spawn, and stop RPC handlers on the event bus.
33
71
  * Returns unsub functions for cleanup.
34
72
  */
35
73
  export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
36
74
  const { events, pi, getCtx, manager } = deps;
37
75
 
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}`, {});
76
+ const unsubPing = handleRpc(events, "subagents:rpc:ping", () => {
77
+ return { version: PROTOCOL_VERSION };
41
78
  });
42
79
 
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
- });
80
+ const unsubSpawn = handleRpc<{ requestId: string; type: string; prompt: string; options?: any }>(
81
+ events, "subagents:rpc:spawn", ({ type, prompt, options }) => {
82
+ const ctx = getCtx();
83
+ if (!ctx) throw new Error("No active session");
84
+ return { id: manager.spawn(pi, ctx, type, prompt, options ?? {}) };
85
+ },
86
+ );
87
+
88
+ const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
89
+ events, "subagents:rpc:stop", ({ agentId }) => {
90
+ if (!manager.abort(agentId)) throw new Error("Agent not found");
91
+ },
92
+ );
59
93
 
60
- return { unsubPing, unsubSpawn };
94
+ return { unsubPing, unsubSpawn, unsubStop };
61
95
  }
package/src/index.ts CHANGED
@@ -289,7 +289,7 @@ export default function (pi: ExtensionAPI) {
289
289
  content: notification + footer,
290
290
  display: true,
291
291
  details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
292
- }, { deliverAs: "followUp" });
292
+ }, { deliverAs: "followUp", triggerTurn: true });
293
293
  }
294
294
 
295
295
  function sendIndividualNudge(record: AgentRecord) {
@@ -326,7 +326,7 @@ export default function (pi: ExtensionAPI) {
326
326
  content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
327
327
  display: true,
328
328
  details,
329
- }, { deliverAs: "followUp" });
329
+ }, { deliverAs: "followUp", triggerTurn: true });
330
330
  });
331
331
  widget.update();
332
332
  },
@@ -431,7 +431,7 @@ export default function (pi: ExtensionAPI) {
431
431
 
432
432
  pi.on("session_switch", () => { manager.clearCompleted(); });
433
433
 
434
- const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc } = registerRpcHandlers({
434
+ const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc, unsubStop: unsubStopRpc } = registerRpcHandlers({
435
435
  events: pi.events,
436
436
  pi,
437
437
  getCtx: () => currentCtx,
@@ -445,6 +445,7 @@ export default function (pi: ExtensionAPI) {
445
445
  // If the session is going down, there's nothing left to consume agent results.
446
446
  pi.on("session_shutdown", async () => {
447
447
  unsubSpawnRpc();
448
+ unsubStopRpc();
448
449
  unsubPingRpc();
449
450
  currentCtx = undefined;
450
451
  delete (globalThis as any)[MANAGER_KEY];
@@ -191,7 +191,7 @@ export class ConversationViewer implements Component {
191
191
  for (const c of msg.content) {
192
192
  if (c.type === "text" && c.text) textParts.push(c.text);
193
193
  else if (c.type === "toolCall") {
194
- toolCalls.push((c as any).toolName ?? "unknown");
194
+ toolCalls.push((c as any).name ?? (c as any).toolName ?? "unknown");
195
195
  }
196
196
  }
197
197
  if (needsSeparator) lines.push(th.fg("dim", "───"));