@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.
- package/CHANGELOG.md +14 -4
- package/README.md +23 -8
- package/dist/agent-manager.d.ts +18 -4
- package/dist/agent-manager.js +111 -9
- package/dist/agent-runner.d.ts +10 -6
- package/dist/agent-runner.js +81 -27
- package/dist/agent-types.d.ts +10 -0
- package/dist/agent-types.js +23 -1
- package/dist/cross-extension-rpc.d.ts +46 -0
- package/dist/cross-extension-rpc.js +54 -0
- package/dist/custom-agents.js +36 -8
- package/dist/index.js +336 -66
- package/dist/memory.d.ts +49 -0
- package/dist/memory.js +151 -0
- package/dist/output-file.d.ts +17 -0
- package/dist/output-file.js +66 -0
- package/dist/prompts.d.ts +12 -1
- package/dist/prompts.js +15 -3
- package/dist/skill-loader.d.ts +19 -0
- package/dist/skill-loader.js +67 -0
- package/dist/types.d.ts +45 -1
- package/dist/ui/agent-widget.d.ts +21 -0
- package/dist/ui/agent-widget.js +205 -127
- package/dist/ui/conversation-viewer.d.ts +2 -2
- package/dist/ui/conversation-viewer.js +3 -3
- package/dist/ui/conversation-viewer.test.d.ts +1 -0
- package/dist/ui/conversation-viewer.test.js +254 -0
- package/dist/worktree.d.ts +36 -0
- package/dist/worktree.js +139 -0
- package/package.json +8 -6
- package/src/agent-runner.ts +1 -1
- package/src/cross-extension-rpc.ts +57 -23
- package/src/index.ts +4 -3
- package/src/ui/conversation-viewer.ts +1 -1
|
@@ -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;
|
package/dist/worktree.js
ADDED
|
@@ -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.
|
|
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.
|
|
25
|
-
"@mariozechner/pi-coding-agent": "^0.
|
|
26
|
-
"@mariozechner/pi-tui": "^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": {
|
package/src/agent-runner.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
|
39
|
-
|
|
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 =
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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", "───"));
|