@tintinweb/pi-subagents 0.4.10 → 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.
@@ -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;
@@ -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.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,6 +27,8 @@
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",