@bugabinga/pi-ext-llmiterate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { hostname, tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, test } from "bun:test";
5
+ import { acquireLock, findProjectRoot, realPath, releaseLock, type LockHandle } from "../lock";
6
+
7
+ const tempDirs: string[] = [];
8
+
9
+ afterEach(() => {
10
+ while (tempDirs.length) rmSync(tempDirs.pop()!, { recursive: true, force: true });
11
+ });
12
+
13
+ function tempDir(): string {
14
+ const dir = mkdtempSync(join(tmpdir(), "llmiterate-lock-"));
15
+ tempDirs.push(dir);
16
+ return dir;
17
+ }
18
+
19
+ describe("llmiterate lock/project root", () => {
20
+ test("finds nearest project boundary", () => {
21
+ const root = tempDir();
22
+ mkdirSync(join(root, ".git"));
23
+ const nested = join(root, "a", "b");
24
+ mkdirSync(nested, { recursive: true });
25
+
26
+ expect(findProjectRoot(nested)).toBe(realPath(root));
27
+
28
+ const sub = join(root, "a");
29
+ writeFileSync(join(sub, "AGENTS.md"), "# subproject\n");
30
+ expect(findProjectRoot(nested)).toBe(realPath(sub));
31
+ });
32
+
33
+ test("acquires, blocks duplicate, and releases lock", () => {
34
+ const root = tempDir();
35
+ const storeDir = tempDir();
36
+ const lock = expectLock(acquireLock(storeDir));
37
+
38
+ const duplicate = acquireLock(storeDir);
39
+ expect(duplicate).toContain("singleton lock held by");
40
+ expect(lock.dir).toBe(join(storeDir, "lock"));
41
+ expect(existsSync(join(root, ".pi"))).toBe(false);
42
+
43
+ releaseLock(lock);
44
+ const reacquired = expectLock(acquireLock(storeDir));
45
+ releaseLock(reacquired);
46
+ });
47
+
48
+ test("does not release lock owned by different token", () => {
49
+ const storeDir = tempDir();
50
+ const lock = expectLock(acquireLock(storeDir));
51
+ releaseLock({ dir: lock.dir, token: "wrong" });
52
+ expect(acquireLock(storeDir)).toContain("singleton lock held by");
53
+ releaseLock(lock);
54
+ });
55
+
56
+ test("recovers stale lock in global store", () => {
57
+ const storeDir = tempDir();
58
+ mkdirSync(join(storeDir, "lock"));
59
+ writeFileSync(join(storeDir, "lock", "owner.json"), JSON.stringify({ pid: 99_999_999, hostname: hostname(), token: "stale" }));
60
+
61
+ const lock = expectLock(acquireLock(storeDir));
62
+ expect(lock.dir).toBe(join(storeDir, "lock"));
63
+ releaseLock(lock);
64
+ });
65
+
66
+ test("malformed owner does not trigger recovery", () => {
67
+ const storeDir = tempDir();
68
+ mkdirSync(join(storeDir, "lock"));
69
+ writeFileSync(join(storeDir, "lock", "owner.json"), JSON.stringify({ pid: 99_999_999, hostname: hostname(), token: "old", extra: 1 }));
70
+
71
+ expect(acquireLock(storeDir)).toContain("singleton lock held by another pi");
72
+ expect(existsSync(join(storeDir, "lock", "owner.json"))).toBe(true);
73
+ });
74
+ });
75
+
76
+ function expectLock(value: LockHandle | string): LockHandle {
77
+ expect(typeof value).toBe("object");
78
+ if (typeof value === "string") throw new Error(value);
79
+ return value;
80
+ }
@@ -0,0 +1,124 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, test } from "bun:test";
5
+ import { PiRpcWorker } from "../rpc";
6
+ import type { RpcEvent } from "../types";
7
+
8
+ const tempDirs: string[] = [];
9
+ const workers: PiRpcWorker[] = [];
10
+
11
+ afterEach(() => {
12
+ while (workers.length) workers.pop()!.dispose();
13
+ while (tempDirs.length) rmSync(tempDirs.pop()!, { recursive: true, force: true });
14
+ });
15
+
16
+ function tempDir(): string {
17
+ const dir = mkdtempSync(join(tmpdir(), "llmiterate-rpc-"));
18
+ tempDirs.push(dir);
19
+ return dir;
20
+ }
21
+
22
+ describe("PiRpcWorker integration", () => {
23
+ test("runs prompt through JSON-RPC worker and forwards events", async () => {
24
+ const root = tempDir();
25
+ const fake = writeFakeRpc(root, "ok");
26
+ const events: RpcEvent[] = [];
27
+ const worker = new PiRpcWorker({
28
+ cwd: root,
29
+ sessionDir: join(root, "sessions"),
30
+ invocation: { command: process.execPath, args: [fake] },
31
+ onEvent: (event) => events.push(event),
32
+ });
33
+ workers.push(worker);
34
+
35
+ await worker.runPrompt("do thing", "job name");
36
+
37
+ expect(events.map((e) => e.type)).toEqual(["message_update", "tool_execution_start", "tool_execution_end", "agent_end"]);
38
+ const first = events[0];
39
+ expect(first.type).toBe("message_update");
40
+ if (first.type === "message_update") {
41
+ const content = first.message?.content;
42
+ expect(Array.isArray(content) ? content[0]?.text : undefined).toBe("streaming");
43
+ }
44
+ });
45
+
46
+ test("rejects concurrent prompts instead of corrupting current prompt", async () => {
47
+ const root = tempDir();
48
+ const fake = writeFakeRpc(root, "ok");
49
+ const worker = new PiRpcWorker({
50
+ cwd: root,
51
+ sessionDir: join(root, "sessions"),
52
+ invocation: { command: process.execPath, args: [fake] },
53
+ onEvent: () => {},
54
+ });
55
+ workers.push(worker);
56
+
57
+ const first = worker.runPrompt("first", "first job");
58
+ await expect(worker.runPrompt("second", "second job")).rejects.toThrow("already has an active prompt");
59
+ await first;
60
+ });
61
+
62
+ test("rejects failed RPC command", async () => {
63
+ const root = tempDir();
64
+ const fake = writeFakeRpc(root, "fail-new-session");
65
+ const worker = new PiRpcWorker({
66
+ cwd: root,
67
+ sessionDir: join(root, "sessions"),
68
+ invocation: { command: process.execPath, args: [fake] },
69
+ onEvent: () => {},
70
+ });
71
+ workers.push(worker);
72
+
73
+ await expect(worker.runPrompt("do thing", "job name")).rejects.toThrow("new_session denied");
74
+ });
75
+
76
+ test("rejects malformed RPC stdout", async () => {
77
+ const root = tempDir();
78
+ const fake = writeFakeRpc(root, "malformed");
79
+ const worker = new PiRpcWorker({
80
+ cwd: root,
81
+ sessionDir: join(root, "sessions"),
82
+ invocation: { command: process.execPath, args: [fake] },
83
+ onEvent: () => {},
84
+ });
85
+ workers.push(worker);
86
+
87
+ await expect(worker.runPrompt("do thing", "job name")).rejects.toThrow();
88
+ });
89
+ });
90
+
91
+ function writeFakeRpc(root: string, mode: "ok" | "fail-new-session" | "malformed"): string {
92
+ const file = join(root, "fake-rpc.js");
93
+ writeFileSync(file, `
94
+ process.stdin.setEncoding('utf8');
95
+ let buffer = '';
96
+ process.stdin.on('data', chunk => {
97
+ buffer += chunk;
98
+ const lines = buffer.split('\\n');
99
+ buffer = lines.pop() || '';
100
+ for (const line of lines) handle(line);
101
+ });
102
+ function send(value) { process.stdout.write(JSON.stringify(value) + '\\n'); }
103
+ function handle(line) {
104
+ if (!line.trim()) return;
105
+ const msg = JSON.parse(line);
106
+ if (${JSON.stringify(mode)} === 'fail-new-session' && msg.type === 'new_session') {
107
+ send({ id: msg.id, type: 'response', command: msg.type, success: false, error: 'new_session denied' });
108
+ return;
109
+ }
110
+ send({ id: msg.id, type: 'response', command: msg.type, success: true });
111
+ if (${JSON.stringify(mode)} === 'malformed' && msg.type === 'prompt') {
112
+ process.stdout.write('not json\\n');
113
+ return;
114
+ }
115
+ if (msg.type === 'prompt') {
116
+ send({ type: 'message_update', message: { role: 'assistant', content: [{ type: 'text', text: 'streaming' }] } });
117
+ send({ type: 'tool_execution_start', toolName: 'edit' });
118
+ send({ type: 'tool_execution_end', toolName: 'edit', isError: false, result: { content: [{ type: 'text', text: 'ok' }] } });
119
+ send({ type: 'agent_end' });
120
+ }
121
+ }
122
+ `, "utf-8");
123
+ return file;
124
+ }
@@ -0,0 +1,80 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { describe, expect, test } from "bun:test";
3
+ import { defaultConfig, parseMarkerPrompts } from "../core";
4
+ import { LlmiterateWidget, renderLlmiterateUi } from "../ui";
5
+ import type { LlmiterateRun } from "../types";
6
+
7
+ interface UiCall {
8
+ key: string;
9
+ value: string | undefined;
10
+ }
11
+
12
+ const theme = {
13
+ fg: (_color: string, text: string) => text,
14
+ bold: (text: string) => text,
15
+ } as unknown as ExtensionContext["ui"]["theme"];
16
+
17
+ describe("llmiterate UI", () => {
18
+ test("status line states", () => {
19
+ const calls: UiCall[] = [];
20
+ const ctx = fakeContext({ setStatus: (key, value) => calls.push({ key, value }) });
21
+
22
+ renderLlmiterateUi(ctx, false, [], { config: defaultConfig(), watchErrors: 0, queuedRuns: 0 });
23
+ renderLlmiterateUi(ctx, false, [], { config: defaultConfig(), lockError: "held", watchErrors: 0, queuedRuns: 0 });
24
+ renderLlmiterateUi(ctx, false, [], { config: defaultConfig(), watchErrors: 2, queuedRuns: 0 });
25
+ renderLlmiterateUi(ctx, false, [], { config: defaultConfig(), watchErrors: 0, queuedRuns: 3 });
26
+
27
+ expect(calls.map((c) => c.value)).toEqual([
28
+ "llmiterate idle",
29
+ "llmiterate standby",
30
+ "llmiterate 2 watch error(s)",
31
+ "llmiterate queued 3",
32
+ ]);
33
+ });
34
+
35
+ test("clears widget when hidden", () => {
36
+ let widgetValue: unknown = "not cleared";
37
+ const ctx = fakeContext({ setWidget: (_key, value) => { widgetValue = value; } });
38
+
39
+ renderLlmiterateUi(ctx, false, [], { config: defaultConfig(), watchErrors: 0, queuedRuns: 0 });
40
+ expect(widgetValue).toBeUndefined();
41
+ });
42
+
43
+ test("widget empty snapshot", () => {
44
+ expect(new LlmiterateWidget([], theme).render(60).join("\n")).toMatchSnapshot();
45
+ });
46
+
47
+ test("widget run snapshot", () => {
48
+ expect(new LlmiterateWidget([sampleRun()], theme).render(80).join("\n")).toMatchSnapshot();
49
+ });
50
+ });
51
+
52
+ function fakeContext(overrides: {
53
+ setStatus?: (key: string, value: string | undefined) => void;
54
+ setWidget?: (key: string, value: unknown) => void;
55
+ }): ExtensionContext {
56
+ return {
57
+ hasUI: true,
58
+ ui: {
59
+ theme,
60
+ setStatus: overrides.setStatus ?? (() => {}),
61
+ setWidget: overrides.setWidget ?? (() => {}),
62
+ },
63
+ } as unknown as ExtensionContext;
64
+ }
65
+
66
+ function sampleRun(): LlmiterateRun {
67
+ const [block] = parseMarkerPrompts("src/main.ts", "LLM implement foo and bar");
68
+ return {
69
+ id: 1,
70
+ block: block!,
71
+ projectFile: "src/main.ts",
72
+ absoluteFile: "/tmp/project/src/main.ts",
73
+ fullPrompt: "prompt",
74
+ status: "running",
75
+ queuedAt: 1,
76
+ updatedAt: 2,
77
+ assistantText: "I am editing the file now and will run tests afterwards.",
78
+ toolText: "running edit",
79
+ };
80
+ }
@@ -0,0 +1,133 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, test } from "bun:test";
5
+ import { compilePathFilter, normalizeConfig } from "../core";
6
+ import { ProjectWatcher } from "../watcher";
7
+
8
+ const tempDirs: string[] = [];
9
+ const watchers: ProjectWatcher[] = [];
10
+
11
+ afterEach(() => {
12
+ while (watchers.length) watchers.pop()!.stop();
13
+ while (tempDirs.length) rmSync(tempDirs.pop()!, { recursive: true, force: true });
14
+ });
15
+
16
+ function tempDir(): string {
17
+ const dir = mkdtempSync(join(tmpdir(), "llmiterate-watcher-"));
18
+ tempDirs.push(dir);
19
+ return dir;
20
+ }
21
+
22
+ describe("ProjectWatcher integration", () => {
23
+ test("default config scans nothing", () => {
24
+ const root = tempDir();
25
+ mkdirSync(join(root, "src"));
26
+ writeFileSync(join(root, "src", "a.ts"), "LLM ignored");
27
+
28
+ const config = normalizeConfig({ debounceMs: 10 });
29
+ const seen: string[] = [];
30
+ const watcher = new ProjectWatcher(root, config, compilePathFilter(config), (file) => seen.push(file), () => {});
31
+ watchers.push(watcher);
32
+
33
+ expect(watcher.scanAll((file) => { seen.push(file); return 1; })).toBe(0);
34
+ expect(seen).toEqual([]);
35
+ });
36
+
37
+ test("default config starts no filesystem watchers", async () => {
38
+ const root = tempDir();
39
+ mkdirSync(join(root, "src"));
40
+
41
+ const config = normalizeConfig({ debounceMs: 10 });
42
+ const seen: string[] = [];
43
+ const watcher = new ProjectWatcher(root, config, compilePathFilter(config), (file) => seen.push(file), () => {});
44
+ watchers.push(watcher);
45
+
46
+ watcher.start();
47
+ writeFileSync(join(root, "src", "a.ts"), "LLM ignored");
48
+ await sleep(40);
49
+ expect(watcher.errorCount).toBe(0);
50
+ expect(seen).toEqual([]);
51
+ });
52
+
53
+ test("scanAll respects include whitelist and aggregates callback results", () => {
54
+ const root = tempDir();
55
+ mkdirSync(join(root, "src"));
56
+ mkdirSync(join(root, "research"));
57
+ writeFileSync(join(root, "src", "a.ts"), "LLM do it");
58
+ writeFileSync(join(root, "src", "a.png"), "no");
59
+ writeFileSync(join(root, "research", "b.ts"), "LLM ignore");
60
+
61
+ const config = normalizeConfig({ include: ["src/**/*.ts"], exclude: [], debounceMs: 10 });
62
+ const seen: string[] = [];
63
+ const watcher = new ProjectWatcher(root, config, compilePathFilter(config), (file) => seen.push(file), () => {});
64
+ watchers.push(watcher);
65
+
66
+ const total = watcher.scanAll((file) => {
67
+ seen.push(file);
68
+ return 2;
69
+ });
70
+
71
+ expect(total).toBe(2);
72
+ expect(seen.map((p) => p.replace(root, ""))).toEqual(["/src/a.ts"]);
73
+ });
74
+
75
+ test("watcher emits debounced file save events", async () => {
76
+ const root = tempDir();
77
+ mkdirSync(join(root, "src"));
78
+ const config = normalizeConfig({ include: ["**/*.ts"], exclude: [], debounceMs: 20 });
79
+ const seen: string[] = [];
80
+ const watcher = new ProjectWatcher(root, config, compilePathFilter(config), (file) => seen.push(file), () => {});
81
+ watchers.push(watcher);
82
+
83
+ watcher.start();
84
+ await sleep(20);
85
+ writeFileSync(join(root, "src", "new.ts"), "LLM hello");
86
+ await eventually(() => seen.some((p) => p.endsWith("new.ts")));
87
+ });
88
+
89
+ test("watcher ignores new files outside include whitelist", async () => {
90
+ const root = tempDir();
91
+ mkdirSync(join(root, "src"));
92
+ mkdirSync(join(root, "research"));
93
+ const config = normalizeConfig({ include: ["src/**/*.ts"], exclude: [], debounceMs: 20 });
94
+ const seen: string[] = [];
95
+ const watcher = new ProjectWatcher(root, config, compilePathFilter(config), (file) => seen.push(file), () => {});
96
+ watchers.push(watcher);
97
+
98
+ watcher.start();
99
+ await sleep(20);
100
+ writeFileSync(join(root, "research", "ignored.ts"), "LLM nope");
101
+ await sleep(80);
102
+ expect(seen).toEqual([]);
103
+ writeFileSync(join(root, "src", "seen.ts"), "LLM hello");
104
+ await eventually(() => seen.some((p) => p.endsWith("seen.ts")));
105
+ });
106
+
107
+ test("watcher notices files moved into new whitelisted directories", async () => {
108
+ const root = tempDir();
109
+ const config = normalizeConfig({ include: ["nested/**/*.ts"], exclude: [], debounceMs: 20 });
110
+ const seen: string[] = [];
111
+ const watcher = new ProjectWatcher(root, config, compilePathFilter(config), (file) => seen.push(file), () => {});
112
+ watchers.push(watcher);
113
+
114
+ watcher.start();
115
+ mkdirSync(join(root, "nested"));
116
+ await sleep(60);
117
+ writeFileSync(join(root, "nested", "moved.ts"), "LLM hello");
118
+ await eventually(() => seen.some((p) => p.endsWith("moved.ts")));
119
+ });
120
+ });
121
+
122
+ function sleep(ms: number): Promise<void> {
123
+ return new Promise((resolve) => setTimeout(resolve, ms));
124
+ }
125
+
126
+ async function eventually(predicate: () => boolean): Promise<void> {
127
+ const deadline = Date.now() + 1500;
128
+ while (Date.now() < deadline) {
129
+ if (predicate()) return;
130
+ await sleep(25);
131
+ }
132
+ expect(predicate()).toBe(true);
133
+ }
Binary file