@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.
package/index.ts ADDED
@@ -0,0 +1,434 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import {
5
+ buildAgentPrompt,
6
+ compilePathFilter,
7
+ emptyState,
8
+ loadConfig,
9
+ loadState,
10
+ parseMarkerPrompts,
11
+ previewText,
12
+ projectStoreDir,
13
+ saveState,
14
+ shouldIncludeCompiledPath,
15
+ toSlash,
16
+ type CompiledPathFilter,
17
+ type LlmiterateConfig,
18
+ type LlmiterateState,
19
+ type MarkerPrompt,
20
+ } from "./core";
21
+ import { acquireLock, findProjectRoot, realPath, releaseLock, type LockHandle } from "./lock";
22
+ import { PiRpcWorker } from "./rpc";
23
+ import { renderLlmiterateUi } from "./ui";
24
+ import { ProjectWatcher } from "./watcher";
25
+ import type { LlmiterateRun, RpcEvent, RpcMessage, RpcTextPart, RpcToolResult } from "./types";
26
+
27
+ interface WatchRuntime {
28
+ root: string;
29
+ cwd: string;
30
+ config: LlmiterateConfig;
31
+ pathFilter: CompiledPathFilter;
32
+ storeDir: string;
33
+ state: LlmiterateState;
34
+ watcher?: ProjectWatcher;
35
+ lock?: LockHandle;
36
+ lockError?: string;
37
+ }
38
+
39
+ interface ScannedFile {
40
+ runtime: WatchRuntime;
41
+ projectRel: string;
42
+ agentRel: string;
43
+ text: string;
44
+ }
45
+
46
+ const MAX_RUNS = 20;
47
+ const STATE_PROMPT_PREVIEW_CHARS = 120;
48
+
49
+ export default function (pi: ExtensionAPI) {
50
+ let runtime: WatchRuntime | undefined;
51
+ let sessionCtx: ExtensionContext | undefined;
52
+ let panelVisible = false;
53
+ let nextRunId = 1;
54
+ let activeRunId: number | undefined;
55
+ let activeJob = false;
56
+ let rpcWorker: PiRpcWorker | undefined;
57
+ const runs: LlmiterateRun[] = [];
58
+
59
+ pi.on("session_start", async (_event, ctx) => {
60
+ sessionCtx = ctx;
61
+ start(ctx.cwd);
62
+ updateUi(ctx);
63
+ });
64
+
65
+ pi.on("session_shutdown", async () => {
66
+ stop();
67
+ sessionCtx = undefined;
68
+ activeRunId = undefined;
69
+ });
70
+
71
+ pi.registerCommand("llmiterate", {
72
+ description: "Toggle/show llmiterate watcher UI. Args: show | hide | status | scan | reset | reload",
73
+ handler: async (args, ctx) => {
74
+ runCommand(args.trim().toLowerCase(), ctx);
75
+ updateUi(ctx);
76
+ },
77
+ });
78
+
79
+ function runCommand(command: string, ctx: ExtensionCommandContext): void {
80
+ switch (command) {
81
+ case "": panelVisible = !panelVisible; return;
82
+ case "show":
83
+ case "status": panelVisible = true; return;
84
+ case "hide": panelVisible = false; return;
85
+ case "scan": scanCommand(ctx); return;
86
+ case "reset": resetCommand(ctx); return;
87
+ case "reload":
88
+ start(ctx.cwd);
89
+ ctx.ui.notify("llmiterate config/watcher reloaded", "info");
90
+ return;
91
+ default:
92
+ ctx.ui.notify("Usage: /llmiterate [show|hide|status|scan|reset|reload]", "warning");
93
+ }
94
+ }
95
+
96
+ function scanCommand(ctx: ExtensionCommandContext): void {
97
+ if (notifyIfStandby(ctx)) {
98
+ panelVisible = true;
99
+ return;
100
+ }
101
+
102
+ const count = scanAll();
103
+ panelVisible = true;
104
+ ctx.ui.notify(`llmiterate scan queued ${count} new prompt(s)`, "info");
105
+ }
106
+
107
+ function resetCommand(ctx: ExtensionCommandContext): void {
108
+ if (notifyIfStandby(ctx)) return;
109
+
110
+ if (runtime) {
111
+ runtime.state = emptyState();
112
+ persistState(runtime);
113
+ }
114
+ stopWorker();
115
+ runs.length = 0;
116
+ activeRunId = undefined;
117
+ ctx.ui.notify("llmiterate state reset", "info");
118
+ }
119
+
120
+ function notifyIfStandby(ctx: ExtensionCommandContext): boolean {
121
+ if (!runtime?.lockError) return false;
122
+ ctx.ui.notify(`llmiterate inactive: ${runtime.lockError}`, "warning");
123
+ return true;
124
+ }
125
+
126
+ function start(cwd: string): void {
127
+ stop();
128
+ const root = findProjectRoot(cwd);
129
+ const config = loadConfig(root);
130
+ const storeDir = projectStoreDir(root);
131
+ const state = loadState(storeDir);
132
+ runtime = {
133
+ root,
134
+ cwd: realPath(cwd),
135
+ config,
136
+ pathFilter: compilePathFilter(config),
137
+ storeDir,
138
+ state,
139
+ };
140
+ if (!config.enabled) return;
141
+
142
+ const lock = acquireLock(storeDir);
143
+ if (typeof lock === "string") {
144
+ runtime.lockError = lock;
145
+ return;
146
+ }
147
+ runtime.lock = lock;
148
+ runtime.watcher = new ProjectWatcher(root, config, runtime.pathFilter, scanFile, () => updateUi(sessionCtx));
149
+ runtime.watcher.start();
150
+ }
151
+
152
+ function stop(): void {
153
+ if (!runtime) return;
154
+ runtime.watcher?.stop();
155
+ stopWorker();
156
+ if (runtime.lock) releaseLock(runtime.lock);
157
+ runtime = undefined;
158
+ }
159
+
160
+ function scanFile(absPath: string): number {
161
+ const file = readScannedFile(absPath);
162
+ if (!file) return 0;
163
+
164
+ let queued = 0;
165
+ const blocks = parseMarkerPrompts(file.projectRel, file.text, file.runtime.config.markers);
166
+ for (const block of blocks) {
167
+ if (file.runtime.state.processed[block.key]) continue;
168
+ recordProcessedPrompt(file.runtime, block);
169
+ enqueueRun({ ...block, file: file.agentRel }, file.projectRel, absPath);
170
+ queued++;
171
+ }
172
+
173
+ if (queued > 0) persistState(file.runtime);
174
+ return queued;
175
+ }
176
+
177
+ function readScannedFile(absPath: string): ScannedFile | undefined {
178
+ const current = runtime;
179
+ if (!current) return undefined;
180
+
181
+ const projectRel = toSlash(path.relative(current.root, absPath));
182
+ if (!shouldIncludeCompiledPath(projectRel, current.pathFilter)) return undefined;
183
+
184
+ const stat = statFile(absPath);
185
+ if (!stat?.isFile() || stat.size > current.config.maxFileBytes) return undefined;
186
+
187
+ const text = readUtf8(absPath);
188
+ if (text === undefined) return undefined;
189
+
190
+ return {
191
+ runtime: current,
192
+ projectRel,
193
+ agentRel: toSlash(path.relative(current.cwd, absPath)),
194
+ text,
195
+ };
196
+ }
197
+
198
+ function recordProcessedPrompt(current: WatchRuntime, block: MarkerPrompt): void {
199
+ current.state.processed[block.key] = {
200
+ file: block.file,
201
+ startLine: block.startLine,
202
+ endLine: block.endLine,
203
+ promptHash: block.promptHash,
204
+ promptPreview: previewText(block.prompt, STATE_PROMPT_PREVIEW_CHARS),
205
+ queuedAt: Date.now(),
206
+ };
207
+ }
208
+
209
+ function statFile(absPath: string): fs.Stats | undefined {
210
+ try { return fs.statSync(absPath); } catch { return undefined; }
211
+ }
212
+
213
+ function readUtf8(absPath: string): string | undefined {
214
+ try { return fs.readFileSync(absPath, "utf-8"); } catch { return undefined; }
215
+ }
216
+
217
+ function persistState(current: WatchRuntime): void {
218
+ saveState(current.storeDir, current.state);
219
+ }
220
+
221
+ function scanAll(): number {
222
+ return runtime?.watcher?.scanAll(scanFile) ?? 0;
223
+ }
224
+
225
+ function enqueueRun(block: MarkerPrompt, projectFile: string, absoluteFile: string): void {
226
+ runs.unshift(createRun(block, projectFile, absoluteFile));
227
+ trimRuns();
228
+ startNextRun();
229
+ updateUi(sessionCtx);
230
+ }
231
+
232
+ function createRun(block: MarkerPrompt, projectFile: string, absoluteFile: string): LlmiterateRun {
233
+ const now = Date.now();
234
+ return {
235
+ id: nextRunId++,
236
+ block,
237
+ projectFile,
238
+ absoluteFile,
239
+ fullPrompt: buildAgentPrompt(block),
240
+ status: "queued",
241
+ queuedAt: now,
242
+ updatedAt: now,
243
+ assistantText: "",
244
+ toolText: "",
245
+ };
246
+ }
247
+
248
+ function trimRuns(): void {
249
+ while (runs.length > MAX_RUNS) runs.pop();
250
+ }
251
+
252
+ function startNextRun(): void {
253
+ if (activeJob || !runtime) return;
254
+
255
+ while (runtime) {
256
+ const run = nextQueuedRun();
257
+ if (!run) return;
258
+ if (isRunStillValid(run)) {
259
+ void runInRpcWorker(run);
260
+ return;
261
+ }
262
+ failRun(run, "marker span changed before background agent start; skipped stale request");
263
+ updateUi(sessionCtx);
264
+ }
265
+ }
266
+
267
+ function nextQueuedRun(): LlmiterateRun | undefined {
268
+ for (let i = runs.length - 1; i >= 0; i--) {
269
+ if (runs[i]?.status === "queued") return runs[i];
270
+ }
271
+ return undefined;
272
+ }
273
+
274
+ async function runInRpcWorker(run: LlmiterateRun): Promise<void> {
275
+ if (!runtime) return;
276
+
277
+ if (!isRunStillValid(run)) {
278
+ failRun(run, "marker span changed before background agent start; skipped stale request");
279
+ updateUi(sessionCtx);
280
+ queueMicrotask(startNextRun);
281
+ return;
282
+ }
283
+
284
+ markRunRunning(run);
285
+ updateUi(sessionCtx);
286
+
287
+ try {
288
+ await worker().runPrompt(run.fullPrompt, sessionName(run));
289
+ markRunDone(run);
290
+ } catch (error) {
291
+ failRun(run, errorMessage(error));
292
+ } finally {
293
+ activeJob = false;
294
+ activeRunId = undefined;
295
+ touchRun(run);
296
+ updateUi(sessionCtx);
297
+ startNextRun();
298
+ }
299
+ }
300
+
301
+ function worker(): PiRpcWorker {
302
+ if (!runtime) throw new Error("llmiterate runtime is not started");
303
+ rpcWorker ??= new PiRpcWorker({
304
+ cwd: runtime.root,
305
+ sessionDir: path.join(runtime.storeDir, "sessions"),
306
+ onEvent: handleRpcEvent,
307
+ });
308
+ return rpcWorker;
309
+ }
310
+
311
+ function stopWorker(): void {
312
+ rpcWorker?.dispose();
313
+ rpcWorker = undefined;
314
+ activeJob = false;
315
+ activeRunId = undefined;
316
+ }
317
+
318
+ function handleRpcEvent(event: RpcEvent): void {
319
+ const run = activeRun();
320
+ if (!run) return;
321
+
322
+ switch (event.type) {
323
+ case "message_update":
324
+ case "message_end":
325
+ if (event.message?.role === "assistant") updateAssistantText(run, event.message);
326
+ break;
327
+ case "tool_execution_start":
328
+ updateToolText(run, `running ${toolName(event.toolName)}`);
329
+ break;
330
+ case "tool_execution_end": {
331
+ const failed = event.isError === true;
332
+ updateToolText(run, `${failed ? "error" : "done"} ${toolName(event.toolName)}`);
333
+ if (failed) failRun(run, extractToolResultText(event.result));
334
+ break;
335
+ }
336
+ case "agent_end":
337
+ markRunDone(run);
338
+ break;
339
+ }
340
+
341
+ updateUi(sessionCtx);
342
+ }
343
+
344
+ function markRunRunning(run: LlmiterateRun): void {
345
+ activeJob = true;
346
+ activeRunId = run.id;
347
+ run.status = "running";
348
+ run.toolText = "starting isolated RPC session";
349
+ run.assistantText = "";
350
+ touchRun(run);
351
+ }
352
+
353
+ function markRunDone(run: LlmiterateRun): void {
354
+ if (run.status === "error") return;
355
+ run.status = "done";
356
+ touchRun(run);
357
+ }
358
+
359
+ function failRun(run: LlmiterateRun, error: string): void {
360
+ run.status = "error";
361
+ run.error = error || "background pi failed";
362
+ touchRun(run);
363
+ }
364
+
365
+ function updateAssistantText(run: LlmiterateRun, message: RpcMessage): void {
366
+ run.assistantText = extractMessageText(message);
367
+ touchRun(run);
368
+ }
369
+
370
+ function updateToolText(run: LlmiterateRun, text: string): void {
371
+ run.toolText = text;
372
+ touchRun(run);
373
+ }
374
+
375
+ function touchRun(run: LlmiterateRun): void {
376
+ run.updatedAt = Date.now();
377
+ }
378
+
379
+ function isRunStillValid(run: LlmiterateRun): boolean {
380
+ if (!runtime) return false;
381
+ const text = readUtf8(run.absoluteFile);
382
+ if (text === undefined) return false;
383
+ return parseMarkerPrompts(run.projectFile, text, runtime.config.markers).some((block) =>
384
+ block.startLine === run.block.startLine && block.promptHash === run.block.promptHash
385
+ );
386
+ }
387
+
388
+ function activeRun(): LlmiterateRun | undefined {
389
+ return runs.find((run) => run.id === activeRunId);
390
+ }
391
+
392
+ function updateUi(ctx: ExtensionContext | undefined): void {
393
+ renderLlmiterateUi(ctx, panelVisible, runs, {
394
+ config: runtime?.config,
395
+ lockError: runtime?.lockError,
396
+ watchErrors: runtime?.watcher?.errorCount ?? 0,
397
+ activeRun: activeRun(),
398
+ queuedRuns: queuedRunCount(),
399
+ });
400
+ }
401
+
402
+ function queuedRunCount(): number {
403
+ return runs.filter((run) => run.status === "queued").length;
404
+ }
405
+ }
406
+
407
+ function sessionName(run: LlmiterateRun): string {
408
+ return `llmiterate ${run.projectFile}:${run.block.startLine}`;
409
+ }
410
+
411
+ function extractMessageText(message: RpcMessage): string {
412
+ if (typeof message.content === "string") return message.content.trim();
413
+ return textParts(message.content).join("\n").trim();
414
+ }
415
+
416
+ function extractToolResultText(result: RpcToolResult | undefined): string {
417
+ return textParts(result?.content).join("\n").trim();
418
+ }
419
+
420
+ function textParts(parts: RpcMessage["content"]): string[] {
421
+ return Array.isArray(parts) ? parts.filter(isTextPart).map((part) => part.text) : [];
422
+ }
423
+
424
+ function isTextPart(part: RpcTextPart): part is RpcTextPart & { text: string } {
425
+ return part.type === "text" && typeof part.text === "string";
426
+ }
427
+
428
+ function toolName(value: unknown): string {
429
+ return typeof value === "string" && value.trim() ? value : "tool";
430
+ }
431
+
432
+ function errorMessage(error: unknown): string {
433
+ return error instanceof Error ? error.message : String(error);
434
+ }
package/lock.ts ADDED
@@ -0,0 +1,139 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ const LOCK_DIR = "lock";
7
+ const OWNER_FILE = "owner.json";
8
+
9
+ export interface LockHandle {
10
+ dir: string;
11
+ token: string;
12
+ }
13
+
14
+ interface LockOwner {
15
+ pid: number;
16
+ hostname: string;
17
+ token: string;
18
+ }
19
+
20
+ export function findProjectRoot(cwd: string): string {
21
+ const start = realPath(cwd);
22
+
23
+ for (let dir: string | undefined = start; dir; dir = parentDir(dir)) {
24
+ if (isProjectBoundary(dir)) return realPath(dir);
25
+ }
26
+
27
+ return start;
28
+ }
29
+
30
+ export function realPath(file: string): string {
31
+ try { return fs.realpathSync(file); } catch { return path.resolve(file); }
32
+ }
33
+
34
+ export function acquireLock(storeDir: string): LockHandle | string {
35
+ const lockDir = path.join(storeDir, LOCK_DIR);
36
+ fs.mkdirSync(storeDir, { recursive: true });
37
+
38
+ const token = randomUUID();
39
+ const owner = createOwner(token);
40
+ const created = tryCreateLock(lockDir, owner);
41
+ if (created) return created;
42
+
43
+ const existing = readLockOwner(lockDir);
44
+ if (existing && isStaleOwner(existing)) {
45
+ const recovered = reclaimStaleLock(lockDir, existing, owner);
46
+ if (recovered) return recovered;
47
+ }
48
+
49
+ const by = existing ? `pid ${existing.pid} on ${existing.hostname}` : "another pi";
50
+ return `singleton lock held by ${by}`;
51
+ }
52
+
53
+ export function releaseLock(lock: LockHandle): void {
54
+ const owner = readLockOwner(lock.dir);
55
+ if (!owner || owner.token !== lock.token) return;
56
+ fs.rmSync(lock.dir, { recursive: true, force: true });
57
+ }
58
+
59
+ function createOwner(token: string): LockOwner {
60
+ return { pid: process.pid, hostname: os.hostname(), token };
61
+ }
62
+
63
+ function tryCreateLock(lockDir: string, owner: LockOwner): LockHandle | undefined {
64
+ try {
65
+ fs.mkdirSync(lockDir);
66
+ fs.writeFileSync(path.join(lockDir, OWNER_FILE), JSON.stringify(owner, null, 2) + "\n", "utf-8");
67
+ return { dir: lockDir, token: owner.token };
68
+ } catch (error) {
69
+ if (nodeErrorCode(error) !== "EEXIST") throw error;
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ function readLockOwner(lockDir: string): LockOwner | undefined {
75
+ try {
76
+ return parseLockOwner(JSON.parse(fs.readFileSync(path.join(lockDir, OWNER_FILE), "utf-8")));
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ function parseLockOwner(value: unknown): LockOwner | undefined {
83
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
84
+ const keys = Object.keys(value).sort();
85
+ if (keys.join("\0") !== "hostname\0pid\0token") return undefined;
86
+
87
+ const owner = value as Partial<LockOwner>;
88
+ if (typeof owner.pid !== "number") return undefined;
89
+ if (typeof owner.hostname !== "string") return undefined;
90
+ if (typeof owner.token !== "string") return undefined;
91
+ return { pid: owner.pid, hostname: owner.hostname, token: owner.token };
92
+ }
93
+
94
+ function reclaimStaleLock(lockDir: string, staleOwner: LockOwner, newOwner: LockOwner): LockHandle | undefined {
95
+ const reclaimedDir = `${lockDir}.stale.${newOwner.token}`;
96
+ try {
97
+ fs.renameSync(lockDir, reclaimedDir);
98
+ } catch {
99
+ return undefined;
100
+ }
101
+
102
+ const movedOwner = readLockOwner(reclaimedDir);
103
+ if (movedOwner?.token !== staleOwner.token) {
104
+ try { fs.renameSync(reclaimedDir, lockDir); } catch {}
105
+ return undefined;
106
+ }
107
+
108
+ fs.rmSync(reclaimedDir, { recursive: true, force: true });
109
+ return tryCreateLock(lockDir, newOwner);
110
+ }
111
+
112
+ function isStaleOwner(owner: LockOwner): boolean {
113
+ if (owner.hostname !== os.hostname()) return false;
114
+ try {
115
+ process.kill(owner.pid, 0);
116
+ return false;
117
+ } catch (error) {
118
+ return nodeErrorCode(error) === "ESRCH";
119
+ }
120
+ }
121
+
122
+ function isProjectBoundary(dir: string): boolean {
123
+ return isDirectory(path.join(dir, ".pi"))
124
+ || fs.existsSync(path.join(dir, "AGENTS.md"))
125
+ || fs.existsSync(path.join(dir, ".git"));
126
+ }
127
+
128
+ function parentDir(dir: string): string | undefined {
129
+ const parent = path.dirname(dir);
130
+ return parent === dir ? undefined : parent;
131
+ }
132
+
133
+ function isDirectory(file: string): boolean {
134
+ try { return fs.statSync(file).isDirectory(); } catch { return false; }
135
+ }
136
+
137
+ function nodeErrorCode(error: unknown): string | undefined {
138
+ return error instanceof Error && "code" in error ? String((error as NodeJS.ErrnoException).code) : undefined;
139
+ }
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@bugabinga/pi-ext-llmiterate",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "index.ts",
6
+ "scripts": {
7
+ "test": "bun test ./__tests__/*.test.ts"
8
+ },
9
+ "peerDependencies": {
10
+ "@earendil-works/pi-coding-agent": "*",
11
+ "@earendil-works/pi-tui": "*"
12
+ },
13
+ "license": "MIT",
14
+ "description": "LLM iteration watcher for Pi.",
15
+ "keywords": [
16
+ "pi",
17
+ "pi-extension"
18
+ ]
19
+ }