@aaroncql/pim-agent 0.0.1
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/LICENSE +21 -0
- package/README.md +212 -0
- package/bin/pim.ts +109 -0
- package/package.json +49 -0
- package/src/extensions/_init/index.ts +109 -0
- package/src/extensions/bash/capture.test.ts +126 -0
- package/src/extensions/bash/capture.ts +80 -0
- package/src/extensions/bash/format.test.ts +240 -0
- package/src/extensions/bash/format.ts +76 -0
- package/src/extensions/bash/index.ts +86 -0
- package/src/extensions/bash/run.test.ts +262 -0
- package/src/extensions/bash/run.ts +207 -0
- package/src/extensions/bash/schema.ts +54 -0
- package/src/extensions/command-picker/index.ts +52 -0
- package/src/extensions/command-picker/ranker.test.ts +46 -0
- package/src/extensions/command-picker/ranker.ts +17 -0
- package/src/extensions/edit/edit.test.ts +285 -0
- package/src/extensions/edit/edit.ts +382 -0
- package/src/extensions/edit/index.ts +54 -0
- package/src/extensions/edit/schema.ts +37 -0
- package/src/extensions/file-picker/catalog.test.ts +263 -0
- package/src/extensions/file-picker/catalog.ts +219 -0
- package/src/extensions/file-picker/index.test.ts +168 -0
- package/src/extensions/file-picker/index.ts +119 -0
- package/src/extensions/file-picker/ranker.test.ts +94 -0
- package/src/extensions/file-picker/ranker.ts +76 -0
- package/src/extensions/footer/git.test.ts +76 -0
- package/src/extensions/footer/git.ts +87 -0
- package/src/extensions/footer/index.test.ts +161 -0
- package/src/extensions/footer/index.ts +148 -0
- package/src/extensions/footer/powerline.ts +87 -0
- package/src/extensions/footer/segments.test.ts +164 -0
- package/src/extensions/footer/segments.ts +234 -0
- package/src/extensions/glob/glob.test.ts +171 -0
- package/src/extensions/glob/glob.ts +34 -0
- package/src/extensions/glob/index.test.ts +68 -0
- package/src/extensions/glob/index.ts +136 -0
- package/src/extensions/glob/render.test.ts +126 -0
- package/src/extensions/glob/render.ts +74 -0
- package/src/extensions/glob/schema.ts +52 -0
- package/src/extensions/grep/grep.test.ts +387 -0
- package/src/extensions/grep/grep.ts +215 -0
- package/src/extensions/grep/index.test.ts +68 -0
- package/src/extensions/grep/index.ts +158 -0
- package/src/extensions/grep/render.test.ts +269 -0
- package/src/extensions/grep/render.ts +243 -0
- package/src/extensions/grep/schema.ts +92 -0
- package/src/extensions/read/index.ts +84 -0
- package/src/extensions/read/read.test.ts +177 -0
- package/src/extensions/read/read.ts +206 -0
- package/src/extensions/read/render.test.ts +61 -0
- package/src/extensions/read/render.ts +33 -0
- package/src/extensions/read/schema.ts +27 -0
- package/src/extensions/subagent/index.test.ts +44 -0
- package/src/extensions/subagent/index.ts +30 -0
- package/src/extensions/subagent/render.test.ts +292 -0
- package/src/extensions/subagent/render.ts +359 -0
- package/src/extensions/subagent/schema.ts +9 -0
- package/src/extensions/subagent/subagent.test.ts +315 -0
- package/src/extensions/subagent/subagent.ts +418 -0
- package/src/extensions/system-prompt/index.ts +28 -0
- package/src/extensions/system-prompt/prompt.test.ts +64 -0
- package/src/extensions/system-prompt/prompt.ts +213 -0
- package/src/extensions/todo/index.test.ts +244 -0
- package/src/extensions/todo/index.ts +122 -0
- package/src/extensions/todo/render.test.ts +180 -0
- package/src/extensions/todo/render.ts +172 -0
- package/src/extensions/todo/schema.ts +24 -0
- package/src/extensions/todo/todo.test.ts +222 -0
- package/src/extensions/todo/todo.ts +188 -0
- package/src/extensions/tps/index.test.ts +254 -0
- package/src/extensions/tps/index.ts +136 -0
- package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
- package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
- package/src/extensions/web-fetch/fetch.test.ts +244 -0
- package/src/extensions/web-fetch/fetch.ts +249 -0
- package/src/extensions/web-fetch/index.ts +107 -0
- package/src/extensions/web-fetch/render.test.ts +56 -0
- package/src/extensions/web-fetch/render.ts +39 -0
- package/src/extensions/web-fetch/schema.ts +23 -0
- package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
- package/src/extensions/web-search/ExaMcpClient.ts +258 -0
- package/src/extensions/web-search/index.ts +118 -0
- package/src/extensions/web-search/render.test.ts +21 -0
- package/src/extensions/web-search/render.ts +9 -0
- package/src/extensions/web-search/schema.ts +21 -0
- package/src/extensions/web-search/search.test.ts +53 -0
- package/src/extensions/web-search/search.ts +23 -0
- package/src/extensions/working-indicator/index.test.ts +21 -0
- package/src/extensions/working-indicator/index.ts +77 -0
- package/src/extensions/write/index.ts +76 -0
- package/src/extensions/write/render.test.ts +64 -0
- package/src/extensions/write/schema.ts +14 -0
- package/src/extensions/write/write.test.ts +108 -0
- package/src/extensions/write/write.ts +104 -0
- package/src/shared/DiffLines.test.ts +193 -0
- package/src/shared/DiffLines.ts +307 -0
- package/src/shared/DiffRenderer.test.ts +206 -0
- package/src/shared/DiffRenderer.ts +396 -0
- package/src/shared/DiffView.ts +199 -0
- package/src/shared/EditMatcher.test.ts +123 -0
- package/src/shared/EditMatcher.ts +826 -0
- package/src/shared/FileScanner.test.ts +158 -0
- package/src/shared/FileScanner.ts +41 -0
- package/src/shared/Fs.ts +46 -0
- package/src/shared/FsErrors.ts +72 -0
- package/src/shared/FuzzyMatcher.test.ts +114 -0
- package/src/shared/FuzzyMatcher.ts +73 -0
- package/src/shared/GitignoreFilter.test.ts +64 -0
- package/src/shared/GitignoreFilter.ts +142 -0
- package/src/shared/GlobExclusions.ts +23 -0
- package/src/shared/Levenshtein.ts +33 -0
- package/src/shared/Lines.test.ts +25 -0
- package/src/shared/Lines.ts +77 -0
- package/src/shared/McpClient.test.ts +235 -0
- package/src/shared/McpClient.ts +406 -0
- package/src/shared/OutputBudget.test.ts +99 -0
- package/src/shared/OutputBudget.ts +79 -0
- package/src/shared/Paths.test.ts +51 -0
- package/src/shared/Paths.ts +52 -0
- package/src/shared/PimSettings.test.ts +90 -0
- package/src/shared/PimSettings.ts +124 -0
- package/src/shared/Renderer.test.ts +190 -0
- package/src/shared/Renderer.ts +256 -0
- package/src/shared/SpillCache.test.ts +94 -0
- package/src/shared/SpillCache.ts +89 -0
- package/src/shared/Tools.test.ts +392 -0
- package/src/shared/Tools.ts +636 -0
- package/src/telegram/Bot.ts +198 -0
- package/src/telegram/Commands.ts +721 -0
- package/src/telegram/Config.test.ts +275 -0
- package/src/telegram/Config.ts +162 -0
- package/src/telegram/Markdown.test.ts +143 -0
- package/src/telegram/Markdown.ts +177 -0
- package/src/telegram/Message.ts +211 -0
- package/src/telegram/Renderer.test.ts +216 -0
- package/src/telegram/Renderer.ts +713 -0
- package/src/telegram/SendFileSchema.ts +19 -0
- package/src/telegram/SendFileTool.ts +94 -0
- package/src/telegram/Session.ts +579 -0
- package/src/telegram/SessionRegistry.test.ts +89 -0
- package/src/telegram/SessionRegistry.ts +170 -0
- package/src/telegram/Supervisor.ts +357 -0
- package/src/telegram/TaskScheduler.test.ts +278 -0
- package/src/telegram/TaskScheduler.ts +293 -0
- package/src/telegram/TaskSchema.ts +88 -0
- package/src/telegram/TaskStore.ts +73 -0
- package/src/telegram/TaskTool.test.ts +179 -0
- package/src/telegram/TaskTool.ts +159 -0
- package/src/telegram/TypingIndicator.ts +43 -0
- package/src/telegram/index.ts +32 -0
- package/src/themes/pim-dark.json +84 -0
- package/src/themes/pim-light.json +84 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { SpillCache } from "../../shared/SpillCache";
|
|
6
|
+
import { killAllActiveBashGroups, runBashCommand } from "./run";
|
|
7
|
+
import {
|
|
8
|
+
DRAIN_GRACE_MS,
|
|
9
|
+
KILL_GRACE_MS,
|
|
10
|
+
STREAM_HEAD_BYTES,
|
|
11
|
+
STREAM_TAIL_BYTES,
|
|
12
|
+
} from "./schema";
|
|
13
|
+
|
|
14
|
+
let previousPimHomeDir: string | undefined;
|
|
15
|
+
let testPimHomeDir: string | undefined;
|
|
16
|
+
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
previousPimHomeDir = process.env.PIM_HOME_DIR;
|
|
19
|
+
testPimHomeDir = await mkdtemp(join(tmpdir(), "pim-bash-home-"));
|
|
20
|
+
process.env.PIM_HOME_DIR = testPimHomeDir;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterAll(async () => {
|
|
24
|
+
if (previousPimHomeDir === undefined) {
|
|
25
|
+
delete process.env.PIM_HOME_DIR;
|
|
26
|
+
} else {
|
|
27
|
+
process.env.PIM_HOME_DIR = previousPimHomeDir;
|
|
28
|
+
}
|
|
29
|
+
if (testPimHomeDir) {
|
|
30
|
+
await rm(testPimHomeDir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function shellQuote(value: string): string {
|
|
35
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function waitForFile(path: string, timeoutMs: number): Promise<void> {
|
|
39
|
+
const deadline = Date.now() + timeoutMs;
|
|
40
|
+
while (Date.now() < deadline) {
|
|
41
|
+
if (await Bun.file(path).exists()) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
await Bun.sleep(10);
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`Timed out waiting for ${path}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function waitForNoProcess(
|
|
50
|
+
marker: string,
|
|
51
|
+
timeoutMs: number
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const deadline = Date.now() + timeoutMs;
|
|
54
|
+
while (true) {
|
|
55
|
+
const probe = Bun.spawnSync({ cmd: ["pgrep", "-f", marker] });
|
|
56
|
+
if (probe.exitCode !== 0) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (Date.now() >= deadline) {
|
|
60
|
+
throw new Error(`Process still running for marker ${marker}`);
|
|
61
|
+
}
|
|
62
|
+
await Bun.sleep(25);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("runBashCommand (integration)", () => {
|
|
67
|
+
test("captures stdout from a successful command", async () => {
|
|
68
|
+
const r = await runBashCommand(
|
|
69
|
+
"echo hello",
|
|
70
|
+
5000,
|
|
71
|
+
undefined,
|
|
72
|
+
process.cwd()
|
|
73
|
+
);
|
|
74
|
+
expect(r.exitCode).toBe(0);
|
|
75
|
+
expect(r.aborted).toBe(false);
|
|
76
|
+
expect(r.timedOut).toBe(false);
|
|
77
|
+
expect(r.stdout.text.trim()).toBe("hello");
|
|
78
|
+
expect(r.stderr.totalBytes).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("captures stderr and non-zero exit", async () => {
|
|
82
|
+
const r = await runBashCommand(
|
|
83
|
+
"echo oops 1>&2; exit 3",
|
|
84
|
+
5000,
|
|
85
|
+
undefined,
|
|
86
|
+
process.cwd()
|
|
87
|
+
);
|
|
88
|
+
expect(r.exitCode).toBe(3);
|
|
89
|
+
expect(r.stderr.text.trim()).toBe("oops");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("respects cwd", async () => {
|
|
93
|
+
const r = await runBashCommand("pwd", 5000, undefined, "/tmp");
|
|
94
|
+
expect(r.exitCode).toBe(0);
|
|
95
|
+
expect(r.stdout.text.trim()).toBe("/tmp");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("times out and reports timedOut", async () => {
|
|
99
|
+
const r = await runBashCommand("sleep 5", 25, undefined, process.cwd());
|
|
100
|
+
expect(r.timedOut).toBe(true);
|
|
101
|
+
expect(r.exitCode === null || r.exitCode !== 0).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("aborts when signal fires", async () => {
|
|
105
|
+
const ctrl = new AbortController();
|
|
106
|
+
const promise = runBashCommand("sleep 5", 5000, ctrl.signal, process.cwd());
|
|
107
|
+
ctrl.abort();
|
|
108
|
+
const r = await promise;
|
|
109
|
+
expect(r.aborted).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("returns promptly when a backgrounded child inherits the pipe", async () => {
|
|
113
|
+
const startedAt = Date.now();
|
|
114
|
+
const r = await runBashCommand(
|
|
115
|
+
"nohup sleep 47 > /dev/null 2>&1 & disown; echo done",
|
|
116
|
+
5000,
|
|
117
|
+
undefined,
|
|
118
|
+
process.cwd()
|
|
119
|
+
);
|
|
120
|
+
const elapsed = Date.now() - startedAt;
|
|
121
|
+
expect(r.exitCode).toBe(0);
|
|
122
|
+
expect(r.timedOut).toBe(false);
|
|
123
|
+
expect(r.stdout.text.trim()).toBe("done");
|
|
124
|
+
expect(elapsed).toBeLessThan(2000);
|
|
125
|
+
// clean up the orphaned sleep so it doesn't linger
|
|
126
|
+
try {
|
|
127
|
+
Bun.spawnSync({ cmd: ["pkill", "-f", "sleep 47"] });
|
|
128
|
+
} catch {}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("timeout kills the whole process group", async () => {
|
|
132
|
+
const marker = `pim-test-timeout-${Date.now()}`;
|
|
133
|
+
const startedAt = Date.now();
|
|
134
|
+
const r = await runBashCommand(
|
|
135
|
+
`bash -c ${shellQuote(`exec -a ${marker} sleep 60`)}`,
|
|
136
|
+
50,
|
|
137
|
+
undefined,
|
|
138
|
+
process.cwd()
|
|
139
|
+
);
|
|
140
|
+
const elapsed = Date.now() - startedAt;
|
|
141
|
+
expect(r.timedOut).toBe(true);
|
|
142
|
+
expect(r.exitCode === null || r.exitCode !== 0).toBe(true);
|
|
143
|
+
expect(elapsed).toBeLessThan(KILL_GRACE_MS + 2000);
|
|
144
|
+
await waitForNoProcess(marker, KILL_GRACE_MS + 500);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("does not crash on timeout while drains still hold readers", async () => {
|
|
148
|
+
// Regression: stream.cancel() on a locked stream rejects (Bun throws
|
|
149
|
+
// synchronously). Drains run fire-and-forget, so when a quiet command
|
|
150
|
+
// times out (no output → drain blocked on read), the finally hits
|
|
151
|
+
// cancel before drain has released. Unhandled rejection would crash Bun.
|
|
152
|
+
const rejections: unknown[] = [];
|
|
153
|
+
const onRejection = (err: unknown) => rejections.push(err);
|
|
154
|
+
process.on("unhandledRejection", onRejection);
|
|
155
|
+
try {
|
|
156
|
+
const r = await runBashCommand("sleep 5", 25, undefined, process.cwd());
|
|
157
|
+
expect(r.timedOut).toBe(true);
|
|
158
|
+
await Bun.sleep(25);
|
|
159
|
+
expect(rejections).toEqual([]);
|
|
160
|
+
} finally {
|
|
161
|
+
process.off("unhandledRejection", onRejection);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("bounded drain returns even when a daemon escapes our process group", async () => {
|
|
166
|
+
// A child that calls setsid itself leaves our pgid and survives killGroup.
|
|
167
|
+
// If it keeps the pipe open, drain would block forever; the DRAIN_GRACE_MS
|
|
168
|
+
// bound forces us to return anyway. The marker lets us clean up after.
|
|
169
|
+
const marker = `pim-test-detached-${Date.now()}`;
|
|
170
|
+
const startedAt = Date.now();
|
|
171
|
+
const r = await runBashCommand(
|
|
172
|
+
`setsid bash -c 'sleep 60; echo ${marker}' > /tmp/${marker}.out 2>&1 < /dev/null & disown; echo done`,
|
|
173
|
+
5000,
|
|
174
|
+
undefined,
|
|
175
|
+
process.cwd()
|
|
176
|
+
);
|
|
177
|
+
const elapsed = Date.now() - startedAt;
|
|
178
|
+
expect(r.exitCode).toBe(0);
|
|
179
|
+
expect(r.stdout.text.trim()).toBe("done");
|
|
180
|
+
expect(elapsed).toBeLessThan(DRAIN_GRACE_MS + 2000);
|
|
181
|
+
try {
|
|
182
|
+
Bun.spawnSync({ cmd: ["pkill", "-f", marker] });
|
|
183
|
+
Bun.spawnSync({ cmd: ["rm", "-f", `/tmp/${marker}.out`] });
|
|
184
|
+
} catch {}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("killAllActiveBashGroups sweeps in-flight subtrees", async () => {
|
|
188
|
+
const id = Date.now();
|
|
189
|
+
const marker = `/tmp/pim-test-active-${id}.marker`;
|
|
190
|
+
const ready = `/tmp/pim-test-active-${id}.ready`;
|
|
191
|
+
const processMarker = `pim-test-active-${id}`;
|
|
192
|
+
const pending = runBashCommand(
|
|
193
|
+
`touch ${shellQuote(ready)}; bash -c ${shellQuote(`exec -a ${processMarker} sleep 30`)} && touch ${shellQuote(marker)}`,
|
|
194
|
+
30_000,
|
|
195
|
+
undefined,
|
|
196
|
+
process.cwd()
|
|
197
|
+
);
|
|
198
|
+
try {
|
|
199
|
+
await waitForFile(ready, 1000);
|
|
200
|
+
killAllActiveBashGroups("SIGTERM");
|
|
201
|
+
const result = await pending;
|
|
202
|
+
expect(result.exitCode === null || result.exitCode !== 0).toBe(true);
|
|
203
|
+
await waitForNoProcess(processMarker, KILL_GRACE_MS + 500);
|
|
204
|
+
expect(await Bun.file(marker).exists()).toBe(false);
|
|
205
|
+
} finally {
|
|
206
|
+
Bun.spawnSync({ cmd: ["rm", "-f", marker, ready] });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("truncates very large stdout", async () => {
|
|
211
|
+
const totalBytes = STREAM_HEAD_BYTES + STREAM_TAIL_BYTES + 1000;
|
|
212
|
+
const r = await runBashCommand(
|
|
213
|
+
`head -c ${totalBytes} /dev/zero | tr '\\0' 'A'`,
|
|
214
|
+
5000,
|
|
215
|
+
undefined,
|
|
216
|
+
process.cwd()
|
|
217
|
+
);
|
|
218
|
+
expect(r.exitCode).toBe(0);
|
|
219
|
+
expect(r.stdout.totalBytes).toBe(totalBytes);
|
|
220
|
+
expect(r.stdout.truncated).toBe(true);
|
|
221
|
+
expect(r.stdout.text).toContain("bytes truncated");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("spills full stdout to ~/.pim/cache when truncated", async () => {
|
|
225
|
+
const totalBytes = STREAM_HEAD_BYTES + STREAM_TAIL_BYTES + 4096;
|
|
226
|
+
const r = await runBashCommand(
|
|
227
|
+
`head -c ${totalBytes} /dev/zero | tr '\\0' 'A'`,
|
|
228
|
+
5000,
|
|
229
|
+
undefined,
|
|
230
|
+
process.cwd()
|
|
231
|
+
);
|
|
232
|
+
try {
|
|
233
|
+
expect(r.exitCode).toBe(0);
|
|
234
|
+
expect(r.stdout.truncated).toBe(true);
|
|
235
|
+
expect(r.stdout.path).toBeTruthy();
|
|
236
|
+
expect(r.stdout.path!.startsWith(join(SpillCache.dir(), "bash-"))).toBe(
|
|
237
|
+
true
|
|
238
|
+
);
|
|
239
|
+
expect(r.stdout.path!.endsWith(".out")).toBe(true);
|
|
240
|
+
const cacheMode = (await stat(SpillCache.dir())).mode & 0o777;
|
|
241
|
+
const spillMode = (await stat(r.stdout.path!)).mode & 0o777;
|
|
242
|
+
expect(cacheMode).toBe(0o700);
|
|
243
|
+
expect(spillMode).toBe(0o600);
|
|
244
|
+
const spilled = await Bun.file(r.stdout.path!).text();
|
|
245
|
+
expect(spilled.length).toBe(totalBytes);
|
|
246
|
+
expect(spilled).toBe("A".repeat(totalBytes));
|
|
247
|
+
} finally {
|
|
248
|
+
if (r.stdout.path) {
|
|
249
|
+
try {
|
|
250
|
+
Bun.spawnSync({ cmd: ["rm", "-f", r.stdout.path] });
|
|
251
|
+
} catch {}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("omits spill path when stream is empty", async () => {
|
|
257
|
+
const r = await runBashCommand("true", 5000, undefined, process.cwd());
|
|
258
|
+
expect(r.exitCode).toBe(0);
|
|
259
|
+
expect(r.stdout.path).toBeNull();
|
|
260
|
+
expect(r.stderr.path).toBeNull();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { SpillCache } from "../../shared/SpillCache";
|
|
2
|
+
import { StreamCapture } from "./capture";
|
|
3
|
+
import {
|
|
4
|
+
type BashCommandResult,
|
|
5
|
+
DRAIN_GRACE_MS,
|
|
6
|
+
KILL_GRACE_MS,
|
|
7
|
+
} from "./schema";
|
|
8
|
+
|
|
9
|
+
type Reader = ReadableStreamDefaultReader<Uint8Array>;
|
|
10
|
+
|
|
11
|
+
const activePids = new Set<number>();
|
|
12
|
+
|
|
13
|
+
// Wired into the extension's signal handlers so a daemon that `setsid`s
|
|
14
|
+
// out of our group (or harbor/parent SIGTERM) still tears down its subtree.
|
|
15
|
+
export function killAllActiveBashGroups(sig: NodeJS.Signals = "SIGTERM"): void {
|
|
16
|
+
for (const pid of activePids) {
|
|
17
|
+
killGroup(pid, sig);
|
|
18
|
+
}
|
|
19
|
+
activePids.clear();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function drain(reader: Reader | null, cap: StreamCapture): Promise<void> {
|
|
23
|
+
if (!reader) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
while (true) {
|
|
28
|
+
const { value, done } = await reader.read();
|
|
29
|
+
if (done) {
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
if (value) {
|
|
33
|
+
cap.push(value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// reader cancelled; drop remaining bytes
|
|
38
|
+
} finally {
|
|
39
|
+
try {
|
|
40
|
+
reader.releaseLock();
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function spillIfTruncated(
|
|
46
|
+
cap: StreamCapture,
|
|
47
|
+
ext: "out" | "err"
|
|
48
|
+
): Promise<string | null> {
|
|
49
|
+
if (!cap.truncated) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return SpillCache.write("bash", ext, cap.full());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function killGroup(pid: number | undefined, sig: NodeJS.Signals): void {
|
|
56
|
+
if (pid === undefined) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
process.kill(-pid, sig);
|
|
61
|
+
} catch {
|
|
62
|
+
try {
|
|
63
|
+
process.kill(pid, sig);
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getReader(
|
|
69
|
+
stream: ReadableStream<Uint8Array> | undefined
|
|
70
|
+
): Reader | null {
|
|
71
|
+
return stream ? stream.getReader() : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function runBashCommand(
|
|
75
|
+
command: string,
|
|
76
|
+
timeoutMs: number,
|
|
77
|
+
signal: AbortSignal | undefined,
|
|
78
|
+
cwd: string
|
|
79
|
+
): Promise<BashCommandResult> {
|
|
80
|
+
const startedAt = Date.now();
|
|
81
|
+
const stdoutCap = new StreamCapture();
|
|
82
|
+
const stderrCap = new StreamCapture();
|
|
83
|
+
|
|
84
|
+
// setsid puts bash and its descendants into a fresh process group with
|
|
85
|
+
// pgid == proc.pid, so we can signal the whole tree on timeout/abort
|
|
86
|
+
// instead of leaving backgrounded grandchildren alive holding our pipes.
|
|
87
|
+
const proc = Bun.spawn({
|
|
88
|
+
cmd: ["setsid", "bash", "-lc", command],
|
|
89
|
+
cwd,
|
|
90
|
+
stdout: "pipe",
|
|
91
|
+
stderr: "pipe",
|
|
92
|
+
env: { ...process.env },
|
|
93
|
+
});
|
|
94
|
+
if (proc.pid !== undefined) {
|
|
95
|
+
activePids.add(proc.pid);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let timedOut = false;
|
|
99
|
+
let aborted = false;
|
|
100
|
+
|
|
101
|
+
// We own the readers so we can force-cancel them later even while the
|
|
102
|
+
// background drains are still mid-read. Cancelling via the held reader
|
|
103
|
+
// does not throw the way ReadableStream.cancel() on a locked stream does.
|
|
104
|
+
const stdoutReader = getReader(
|
|
105
|
+
proc.stdout as unknown as ReadableStream<Uint8Array>
|
|
106
|
+
);
|
|
107
|
+
const stderrReader = getReader(
|
|
108
|
+
proc.stderr as unknown as ReadableStream<Uint8Array>
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Fire-and-forget drains. A backgrounded child can inherit the subshell's
|
|
112
|
+
// fds and keep the pipes open after bash exits, so we can't block on EOF;
|
|
113
|
+
// we race proc.exited against a wall-clock timeout instead.
|
|
114
|
+
const stdoutDrain = drain(stdoutReader, stdoutCap);
|
|
115
|
+
const stderrDrain = drain(stderrReader, stderrCap);
|
|
116
|
+
|
|
117
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
118
|
+
const exitedPromise = proc.exited.then(() => "exited" as const);
|
|
119
|
+
const timeoutPromise = new Promise<"timeout">((resolve) => {
|
|
120
|
+
timeoutHandle = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
let abortResolve: ((v: "aborted") => void) | null = null;
|
|
124
|
+
const abortPromise = new Promise<"aborted">((resolve) => {
|
|
125
|
+
abortResolve = resolve;
|
|
126
|
+
});
|
|
127
|
+
const onAbort = () => {
|
|
128
|
+
aborted = true;
|
|
129
|
+
abortResolve?.("aborted");
|
|
130
|
+
};
|
|
131
|
+
if (signal) {
|
|
132
|
+
if (signal.aborted) {
|
|
133
|
+
onAbort();
|
|
134
|
+
} else {
|
|
135
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let exitCode: number | null = null;
|
|
140
|
+
let signalCode: NodeJS.Signals | null = null;
|
|
141
|
+
try {
|
|
142
|
+
const result = await Promise.race([
|
|
143
|
+
exitedPromise,
|
|
144
|
+
timeoutPromise,
|
|
145
|
+
abortPromise,
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
if (result === "timeout") {
|
|
149
|
+
timedOut = true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (result !== "exited") {
|
|
153
|
+
killGroup(proc.pid, "SIGTERM");
|
|
154
|
+
const sigkillTimer = setTimeout(() => {
|
|
155
|
+
killGroup(proc.pid, "SIGKILL");
|
|
156
|
+
}, KILL_GRACE_MS);
|
|
157
|
+
try {
|
|
158
|
+
await proc.exited;
|
|
159
|
+
} finally {
|
|
160
|
+
clearTimeout(sigkillTimer);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
exitCode = proc.exitCode ?? null;
|
|
165
|
+
signalCode = (proc.signalCode as NodeJS.Signals | null | undefined) ?? null;
|
|
166
|
+
|
|
167
|
+
// Bound the drain so a detached grandchild holding the pipe can't keep
|
|
168
|
+
// the drain promise + capture buffer alive past this call.
|
|
169
|
+
await Promise.race([
|
|
170
|
+
Promise.all([stdoutDrain, stderrDrain]),
|
|
171
|
+
Bun.sleep(DRAIN_GRACE_MS),
|
|
172
|
+
]);
|
|
173
|
+
} finally {
|
|
174
|
+
if (timeoutHandle) {
|
|
175
|
+
clearTimeout(timeoutHandle);
|
|
176
|
+
}
|
|
177
|
+
if (signal) {
|
|
178
|
+
signal.removeEventListener("abort", onAbort);
|
|
179
|
+
}
|
|
180
|
+
for (const reader of [stdoutReader, stderrReader]) {
|
|
181
|
+
if (!reader) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
void reader.cancel().catch(() => {});
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
188
|
+
if (proc.pid !== undefined) {
|
|
189
|
+
activePids.delete(proc.pid);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const [stdoutPath, stderrPath] = await Promise.all([
|
|
194
|
+
spillIfTruncated(stdoutCap, "out"),
|
|
195
|
+
spillIfTruncated(stderrCap, "err"),
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
exitCode,
|
|
200
|
+
signal: signalCode,
|
|
201
|
+
stdout: { ...stdoutCap.snapshot(), path: stdoutPath },
|
|
202
|
+
stderr: { ...stderrCap.snapshot(), path: stderrPath },
|
|
203
|
+
timedOut,
|
|
204
|
+
aborted,
|
|
205
|
+
durationMs: Date.now() - startedAt,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type Static, Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
export const STREAM_HEAD_BYTES = 8192;
|
|
4
|
+
export const STREAM_TAIL_BYTES = 8192;
|
|
5
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
6
|
+
export const KILL_GRACE_MS = 2000;
|
|
7
|
+
export const DRAIN_GRACE_MS = 1000;
|
|
8
|
+
|
|
9
|
+
export const bashSchema = Type.Object({
|
|
10
|
+
command: Type.String({
|
|
11
|
+
description: "Runs via bash -lc, so login shell init applies.",
|
|
12
|
+
}),
|
|
13
|
+
timeoutMs: Type.Optional(
|
|
14
|
+
Type.Integer({
|
|
15
|
+
minimum: 1,
|
|
16
|
+
description: `Timeout in milliseconds. Default is ${DEFAULT_TIMEOUT_MS} (${DEFAULT_TIMEOUT_MS / 1000}s) — raise it for long-running commands like builds, test suites, training runs, or installs.`,
|
|
17
|
+
})
|
|
18
|
+
),
|
|
19
|
+
});
|
|
20
|
+
export type BashInput = Static<typeof bashSchema>;
|
|
21
|
+
|
|
22
|
+
export type CapturedStream = {
|
|
23
|
+
readonly text: string;
|
|
24
|
+
readonly totalBytes: number;
|
|
25
|
+
readonly truncated: boolean;
|
|
26
|
+
readonly path: string | null;
|
|
27
|
+
readonly nextStart: number | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type BashCommandResult = {
|
|
31
|
+
readonly exitCode: number | null;
|
|
32
|
+
readonly signal: NodeJS.Signals | null;
|
|
33
|
+
readonly stdout: CapturedStream;
|
|
34
|
+
readonly stderr: CapturedStream;
|
|
35
|
+
readonly timedOut: boolean;
|
|
36
|
+
readonly aborted: boolean;
|
|
37
|
+
readonly durationMs: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type BashStreamDetails = {
|
|
41
|
+
readonly totalBytes: number;
|
|
42
|
+
readonly truncated: boolean;
|
|
43
|
+
readonly path: string | null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type BashDetails = {
|
|
47
|
+
readonly exitCode: number | null;
|
|
48
|
+
readonly signal: NodeJS.Signals | null;
|
|
49
|
+
readonly durationMs: number;
|
|
50
|
+
readonly timedOut: boolean;
|
|
51
|
+
readonly aborted: boolean;
|
|
52
|
+
readonly stdout: BashStreamDetails;
|
|
53
|
+
readonly stderr: BashStreamDetails;
|
|
54
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { rank } from "./ranker";
|
|
3
|
+
|
|
4
|
+
const MAX_VISIBLE_ROWS = 10;
|
|
5
|
+
const SLASH_PREFIX = /^\/([^\s]*)$/;
|
|
6
|
+
const SLASH_LINES = ["/"];
|
|
7
|
+
|
|
8
|
+
export default function (pi: ExtensionAPI): void {
|
|
9
|
+
pi.on("session_start", (_event, ctx) => {
|
|
10
|
+
ctx.ui.addAutocompleteProvider((current) => ({
|
|
11
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
12
|
+
const line = lines[cursorLine] ?? "";
|
|
13
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
14
|
+
|
|
15
|
+
const slashMatch = beforeCursor.match(SLASH_PREFIX);
|
|
16
|
+
if (!slashMatch) {
|
|
17
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const all = await current.getSuggestions(SLASH_LINES, 0, 1, options);
|
|
21
|
+
if (all === null) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const items = rank(slashMatch[1] ?? "", all.items, {
|
|
26
|
+
limit: MAX_VISIBLE_ROWS,
|
|
27
|
+
});
|
|
28
|
+
if (items.length === 0) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return { items, prefix: beforeCursor };
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
35
|
+
return current.applyCompletion(
|
|
36
|
+
lines,
|
|
37
|
+
cursorLine,
|
|
38
|
+
cursorCol,
|
|
39
|
+
item,
|
|
40
|
+
prefix
|
|
41
|
+
);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
45
|
+
return (
|
|
46
|
+
current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
|
|
47
|
+
true
|
|
48
|
+
);
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
3
|
+
import { rank } from "./ranker";
|
|
4
|
+
|
|
5
|
+
const item = (name: string, description?: string): AutocompleteItem => ({
|
|
6
|
+
value: name,
|
|
7
|
+
label: name,
|
|
8
|
+
...(description !== undefined && { description }),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("empty query returns items alphabetically by label", () => {
|
|
12
|
+
const items = rank("", [
|
|
13
|
+
item("rename", "Rename the session."),
|
|
14
|
+
item("clear", "Clear the session."),
|
|
15
|
+
item("help", "Show help."),
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
expect(items.map((i) => i.value)).toEqual(["clear", "help", "rename"]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("query ranks fuzzy matches by score", () => {
|
|
22
|
+
const items = rank("cl", [
|
|
23
|
+
item("rename", "Rename the session."),
|
|
24
|
+
item("clear", "Clear the session."),
|
|
25
|
+
item("help", "Show help."),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
expect(items[0]?.value).toBe("clear");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("matches against description when label doesn't contain query", () => {
|
|
32
|
+
const items = rank("rename", [
|
|
33
|
+
item("noop", "fully unrelated"),
|
|
34
|
+
item("x", "rename the session"),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
expect(items[0]?.value).toBe("x");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("limit caps the returned items", () => {
|
|
41
|
+
const items = rank("", [item("c"), item("a"), item("b"), item("d")], {
|
|
42
|
+
limit: 2,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(items.map((i) => i.value)).toEqual(["a", "b"]);
|
|
46
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
2
|
+
import { FuzzyMatcher, type FuzzyCandidate } from "../../shared/FuzzyMatcher";
|
|
3
|
+
|
|
4
|
+
export function rank(
|
|
5
|
+
query: string,
|
|
6
|
+
items: readonly AutocompleteItem[],
|
|
7
|
+
options: { readonly limit?: number } = {}
|
|
8
|
+
): AutocompleteItem[] {
|
|
9
|
+
const candidates: FuzzyCandidate<AutocompleteItem>[] = items.map((item) => ({
|
|
10
|
+
item,
|
|
11
|
+
haystacks: [item.label, item.description ?? ""],
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const hits = FuzzyMatcher.rank(query, candidates, options);
|
|
15
|
+
|
|
16
|
+
return hits.map((hit) => hit.item);
|
|
17
|
+
}
|