@tintinweb/pi-subagents 0.4.9 → 0.4.11
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/.github/workflows/ci.yml +21 -0
- package/CHANGELOG.md +18 -0
- package/README.md +11 -11
- package/biome.json +26 -0
- 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 +80 -26
- package/dist/agent-types.d.ts +10 -0
- package/dist/agent-types.js +23 -1
- package/dist/cross-extension-rpc.d.ts +30 -0
- package/dist/cross-extension-rpc.js +33 -0
- package/dist/custom-agents.js +36 -8
- package/dist/index.js +335 -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 +2 -2
- 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 +7 -2
- package/src/agent-manager.ts +7 -5
- package/src/agent-runner.ts +24 -19
- package/src/agent-types.ts +5 -5
- package/src/custom-agents.ts +4 -4
- package/src/index.ts +54 -33
- package/src/memory.ts +2 -2
- package/src/output-file.ts +1 -1
- package/src/skill-loader.ts +1 -1
- package/src/types.ts +3 -1
- package/src/ui/agent-widget.ts +18 -2
- package/src/ui/conversation-viewer.ts +4 -4
- package/src/worktree.ts +2 -2
|
@@ -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", toolName: "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.4.
|
|
3
|
+
"version": "0.4.11",
|
|
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",
|
|
@@ -27,13 +27,18 @@
|
|
|
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
|
-
"typecheck": "tsc --noEmit"
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"lint": "biome check src/ test/",
|
|
36
|
+
"lint:fix": "biome check --fix src/ test/"
|
|
33
37
|
},
|
|
34
38
|
"devDependencies": {
|
|
35
39
|
"@types/node": "^20.0.0",
|
|
36
40
|
"typescript": "^5.0.0",
|
|
41
|
+
"@biomejs/biome": "^2.3.5",
|
|
37
42
|
"vitest": "^4.0.18"
|
|
38
43
|
},
|
|
39
44
|
"pi": {
|
package/src/agent-manager.ts
CHANGED
|
@@ -7,12 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
|
-
import type { ExtensionContext, ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
11
10
|
import type { Model } from "@mariozechner/pi-ai";
|
|
12
|
-
import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
|
13
|
-
import {
|
|
14
|
-
import type {
|
|
15
|
-
import {
|
|
11
|
+
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
12
|
+
import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
|
|
13
|
+
import type { AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
|
|
14
|
+
import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
|
|
16
15
|
|
|
17
16
|
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
18
17
|
export type OnAgentStart = (record: AgentRecord) => void;
|
|
@@ -44,6 +43,8 @@ interface SpawnOptions {
|
|
|
44
43
|
onTextDelta?: (delta: string, fullText: string) => void;
|
|
45
44
|
/** Called when the agent session is created (for accessing session stats). */
|
|
46
45
|
onSessionCreated?: (session: AgentSession) => void;
|
|
46
|
+
/** Called at the end of each agentic turn with the cumulative count. */
|
|
47
|
+
onTurnEnd?: (turnCount: number) => void;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export class AgentManager {
|
|
@@ -149,6 +150,7 @@ export class AgentManager {
|
|
|
149
150
|
if (activity.type === "end") record.toolUses++;
|
|
150
151
|
options.onToolActivity?.(activity);
|
|
151
152
|
},
|
|
153
|
+
onTurnEnd: options.onTurnEnd,
|
|
152
154
|
onTextDelta: options.onTextDelta,
|
|
153
155
|
onSessionCreated: (session) => {
|
|
154
156
|
record.session = session;
|
package/src/agent-runner.ts
CHANGED
|
@@ -2,35 +2,35 @@
|
|
|
2
2
|
* agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import type { Model } from "@mariozechner/pi-ai";
|
|
6
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
5
7
|
import {
|
|
8
|
+
type AgentSession,
|
|
9
|
+
type AgentSessionEvent,
|
|
6
10
|
createAgentSession,
|
|
7
11
|
DefaultResourceLoader,
|
|
12
|
+
type ExtensionAPI,
|
|
8
13
|
SessionManager,
|
|
9
14
|
SettingsManager,
|
|
10
|
-
type AgentSession,
|
|
11
|
-
type AgentSessionEvent,
|
|
12
|
-
type ExtensionAPI,
|
|
13
15
|
} from "@mariozechner/pi-coding-agent";
|
|
14
|
-
import
|
|
15
|
-
import type { Model } from "@mariozechner/pi-ai";
|
|
16
|
-
import { getToolsForType, getConfig, getAgentConfig, getMemoryTools, getReadOnlyMemoryTools } from "./agent-types.js";
|
|
17
|
-
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
16
|
+
import { getAgentConfig, getConfig, getMemoryTools, getReadOnlyMemoryTools, getToolsForType } from "./agent-types.js";
|
|
18
17
|
import { buildParentContext, extractText } from "./context.js";
|
|
19
18
|
import { detectEnv } from "./env.js";
|
|
20
19
|
import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
|
|
20
|
+
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
21
21
|
import { preloadSkills } from "./skill-loader.js";
|
|
22
22
|
import type { SubagentType, ThinkingLevel } from "./types.js";
|
|
23
23
|
|
|
24
24
|
/** Names of tools registered by this extension that subagents must NOT inherit. */
|
|
25
25
|
const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
|
|
26
26
|
|
|
27
|
-
/** Default max turns
|
|
28
|
-
let defaultMaxTurns
|
|
27
|
+
/** Default max turns. undefined = unlimited (no turn limit). */
|
|
28
|
+
let defaultMaxTurns: number | undefined;
|
|
29
29
|
|
|
30
|
-
/** Get the default max turns value. */
|
|
31
|
-
export function getDefaultMaxTurns(): number { return defaultMaxTurns; }
|
|
32
|
-
/** Set the default max turns value
|
|
33
|
-
export function setDefaultMaxTurns(n: number): void { defaultMaxTurns = Math.max(1, n); }
|
|
30
|
+
/** Get the default max turns value. undefined = unlimited. */
|
|
31
|
+
export function getDefaultMaxTurns(): number | undefined { return defaultMaxTurns; }
|
|
32
|
+
/** Set the default max turns value. undefined = unlimited, otherwise minimum 1. */
|
|
33
|
+
export function setDefaultMaxTurns(n: number | undefined): void { defaultMaxTurns = n != null ? Math.max(1, n) : undefined; }
|
|
34
34
|
|
|
35
35
|
/** Additional turns allowed after the soft limit steer message. */
|
|
36
36
|
let graceTurns = 5;
|
|
@@ -93,6 +93,8 @@ export interface RunOptions {
|
|
|
93
93
|
/** Called on streaming text deltas from the assistant response. */
|
|
94
94
|
onTextDelta?: (delta: string, fullText: string) => void;
|
|
95
95
|
onSessionCreated?: (session: AgentSession) => void;
|
|
96
|
+
/** Called at the end of each agentic turn with the cumulative count. */
|
|
97
|
+
onTurnEnd?: (turnCount: number) => void;
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
export interface RunResult {
|
|
@@ -285,12 +287,15 @@ export async function runAgent(
|
|
|
285
287
|
const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
|
|
286
288
|
if (event.type === "turn_end") {
|
|
287
289
|
turnCount++;
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
290
|
+
options.onTurnEnd?.(turnCount);
|
|
291
|
+
if (maxTurns != null) {
|
|
292
|
+
if (!softLimitReached && turnCount >= maxTurns) {
|
|
293
|
+
softLimitReached = true;
|
|
294
|
+
session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
|
|
295
|
+
} else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
|
|
296
|
+
aborted = true;
|
|
297
|
+
session.abort();
|
|
298
|
+
}
|
|
294
299
|
}
|
|
295
300
|
}
|
|
296
301
|
if (event.type === "message_start") {
|
package/src/agent-types.ts
CHANGED
|
@@ -5,18 +5,18 @@
|
|
|
5
5
|
* User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
8
9
|
import {
|
|
9
|
-
createReadTool,
|
|
10
10
|
createBashTool,
|
|
11
11
|
createEditTool,
|
|
12
|
-
createWriteTool,
|
|
13
|
-
createGrepTool,
|
|
14
12
|
createFindTool,
|
|
13
|
+
createGrepTool,
|
|
15
14
|
createLsTool,
|
|
15
|
+
createReadTool,
|
|
16
|
+
createWriteTool,
|
|
16
17
|
} from "@mariozechner/pi-coding-agent";
|
|
17
|
-
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
18
|
-
import type { AgentConfig } from "./types.js";
|
|
19
18
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
19
|
+
import type { AgentConfig } from "./types.js";
|
|
20
20
|
|
|
21
21
|
type ToolFactory = (cwd: string) => AgentTool<any>;
|
|
22
22
|
|
package/src/custom-agents.ts
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* custom-agents.ts — Load user-defined agents from project (.pi/agents/) and global (~/.pi/agent/agents/) locations.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
7
|
-
import { join, basename } from "node:path";
|
|
5
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8
6
|
import { homedir } from "node:os";
|
|
9
|
-
import
|
|
7
|
+
import { basename, join } from "node:path";
|
|
8
|
+
import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
|
|
10
9
|
import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
|
|
10
|
+
import type { AgentConfig, MemoryScope, ThinkingLevel } from "./types.js";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Scan for custom agent .md files from multiple locations.
|