@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/CHANGELOG.md +8 -0
- package/README.md +160 -0
- package/__bench__/PERF.md +47 -0
- package/__bench__/baseline.txt +4 -0
- package/__bench__/bench.ts +66 -0
- package/__bench__/benchstat.txt +8 -0
- package/__bench__/optimized.txt +20 -0
- package/__tests__/__snapshots__/core.test.ts.snap +17 -0
- package/__tests__/__snapshots__/ui.test.ts.snap +15 -0
- package/__tests__/core.test.ts +268 -0
- package/__tests__/lock.test.ts +80 -0
- package/__tests__/rpc.test.ts +124 -0
- package/__tests__/ui.test.ts +80 -0
- package/__tests__/watcher.test.ts +133 -0
- package/assets/workflow_suite.gif +0 -0
- package/bun.lock +336 -0
- package/core.ts +489 -0
- package/index.ts +434 -0
- package/lock.ts +139 -0
- package/package.json +19 -0
- package/rpc.ts +228 -0
- package/types.ts +45 -0
- package/ui.ts +103 -0
- package/watcher.ts +191 -0
package/rpc.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { RpcEvent } from "./types";
|
|
5
|
+
|
|
6
|
+
const RPC_KILL_GRACE_MS = 5_000;
|
|
7
|
+
const RESPONSE_EVENT = "response";
|
|
8
|
+
const AGENT_END_EVENT = "agent_end";
|
|
9
|
+
const CMD_NEW_SESSION = "new_session";
|
|
10
|
+
const CMD_SET_SESSION_NAME = "set_session_name";
|
|
11
|
+
const CMD_PROMPT = "prompt";
|
|
12
|
+
const CMD_ABORT = "abort";
|
|
13
|
+
|
|
14
|
+
interface PendingCommand {
|
|
15
|
+
command: string;
|
|
16
|
+
resolve(response: unknown): void;
|
|
17
|
+
reject(error: Error): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PiInvocation {
|
|
21
|
+
command: string;
|
|
22
|
+
args: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RpcCommandPayload {
|
|
26
|
+
type: string;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface RpcResponse extends RpcWireEvent {
|
|
31
|
+
type: "response";
|
|
32
|
+
id?: unknown;
|
|
33
|
+
success?: unknown;
|
|
34
|
+
error?: unknown;
|
|
35
|
+
data?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RpcWireEvent {
|
|
39
|
+
type: string;
|
|
40
|
+
[key: string]: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RpcWorkerOptions {
|
|
44
|
+
cwd: string;
|
|
45
|
+
sessionDir: string;
|
|
46
|
+
onEvent(event: RpcEvent): void;
|
|
47
|
+
invocation?: PiInvocation;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class PiRpcWorker {
|
|
51
|
+
private proc?: ChildProcessWithoutNullStreams;
|
|
52
|
+
private stdoutBuffer = "";
|
|
53
|
+
private stderr = "";
|
|
54
|
+
private nextId = 1;
|
|
55
|
+
private runningPrompt = false;
|
|
56
|
+
private readonly pending = new Map<string, PendingCommand>();
|
|
57
|
+
private currentPrompt?: { resolve(): void; reject(error: Error): void };
|
|
58
|
+
|
|
59
|
+
constructor(private readonly options: RpcWorkerOptions) {}
|
|
60
|
+
|
|
61
|
+
async runPrompt(prompt: string, sessionName: string): Promise<void> {
|
|
62
|
+
if (this.runningPrompt) throw new Error("RPC worker already has an active prompt");
|
|
63
|
+
|
|
64
|
+
this.runningPrompt = true;
|
|
65
|
+
try {
|
|
66
|
+
await this.command({ type: CMD_NEW_SESSION });
|
|
67
|
+
await this.command({ type: CMD_SET_SESSION_NAME, name: sessionName });
|
|
68
|
+
|
|
69
|
+
const done = new Promise<void>((resolve, reject) => {
|
|
70
|
+
this.currentPrompt = { resolve, reject };
|
|
71
|
+
});
|
|
72
|
+
await this.command({ type: CMD_PROMPT, message: prompt });
|
|
73
|
+
await done;
|
|
74
|
+
} finally {
|
|
75
|
+
this.runningPrompt = false;
|
|
76
|
+
this.currentPrompt = undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
abort(): void {
|
|
81
|
+
if (!this.proc || this.proc.killed) return;
|
|
82
|
+
void this.command({ type: CMD_ABORT }).catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
dispose(): void {
|
|
86
|
+
const proc = this.proc;
|
|
87
|
+
this.proc = undefined;
|
|
88
|
+
this.rejectAll(new Error("RPC worker disposed"));
|
|
89
|
+
if (!proc || proc.killed) return;
|
|
90
|
+
|
|
91
|
+
let exited = false;
|
|
92
|
+
proc.once("exit", () => { exited = true; });
|
|
93
|
+
proc.kill("SIGTERM");
|
|
94
|
+
setTimeout(() => {
|
|
95
|
+
if (!exited) proc.kill("SIGKILL");
|
|
96
|
+
}, RPC_KILL_GRACE_MS).unref?.();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private ensureStarted(): void {
|
|
100
|
+
if (this.proc && !this.proc.killed) return;
|
|
101
|
+
|
|
102
|
+
fs.mkdirSync(this.options.sessionDir, { recursive: true });
|
|
103
|
+
const invocation = this.options.invocation ?? getPiInvocation([
|
|
104
|
+
"--mode", "rpc",
|
|
105
|
+
"--no-extensions",
|
|
106
|
+
"--session-dir", this.options.sessionDir,
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
this.stderr = "";
|
|
110
|
+
this.proc = spawn(invocation.command, invocation.args, {
|
|
111
|
+
cwd: this.options.cwd,
|
|
112
|
+
shell: false,
|
|
113
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
this.proc.stdout.on("data", (data) => this.handleStdout(data.toString()));
|
|
117
|
+
this.proc.stderr.on("data", (data) => { this.stderr += data.toString(); });
|
|
118
|
+
this.proc.on("error", (error) => this.rejectAll(error));
|
|
119
|
+
this.proc.on("close", (code) => {
|
|
120
|
+
const message = this.stderr.trim() || `RPC worker exited with code ${code ?? "unknown"}`;
|
|
121
|
+
this.proc = undefined;
|
|
122
|
+
this.rejectAll(new Error(message));
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private command(payload: RpcCommandPayload): Promise<unknown> {
|
|
127
|
+
this.ensureStarted();
|
|
128
|
+
if (!this.proc?.stdin.writable) return Promise.reject(new Error("RPC worker stdin closed"));
|
|
129
|
+
|
|
130
|
+
const id = `llmiterate-${this.nextId++}`;
|
|
131
|
+
const line = JSON.stringify({ id, ...payload }) + "\n";
|
|
132
|
+
|
|
133
|
+
return new Promise((resolve, reject) => {
|
|
134
|
+
this.pending.set(id, { command: payload.type, resolve, reject });
|
|
135
|
+
this.proc!.stdin.write(line, (error) => {
|
|
136
|
+
if (!error) return;
|
|
137
|
+
this.pending.delete(id);
|
|
138
|
+
reject(error);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private handleStdout(chunk: string): void {
|
|
144
|
+
this.stdoutBuffer += chunk;
|
|
145
|
+
const lines = this.stdoutBuffer.split("\n");
|
|
146
|
+
this.stdoutBuffer = lines.pop() ?? "";
|
|
147
|
+
for (const line of lines) this.handleLine(line);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private handleLine(line: string): void {
|
|
151
|
+
if (!line.trim()) return;
|
|
152
|
+
|
|
153
|
+
let event: RpcWireEvent;
|
|
154
|
+
try {
|
|
155
|
+
event = parseRpcObject(line);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
this.failProtocol(errorMessage(error));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (isRpcResponse(event)) {
|
|
162
|
+
this.handleResponse(event);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this.options.onEvent(event as RpcEvent);
|
|
167
|
+
if (event.type === AGENT_END_EVENT) this.currentPrompt?.resolve();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private handleResponse(response: RpcResponse): void {
|
|
171
|
+
const id = typeof response.id === "string" ? response.id : undefined;
|
|
172
|
+
if (!id) return;
|
|
173
|
+
|
|
174
|
+
const pending = this.pending.get(id);
|
|
175
|
+
if (!pending) return;
|
|
176
|
+
this.pending.delete(id);
|
|
177
|
+
|
|
178
|
+
if (response.success === false) {
|
|
179
|
+
const reason = response.error ?? `${pending.command} failed`;
|
|
180
|
+
pending.reject(new Error(String(reason)));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
pending.resolve(response.data ?? response);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private failProtocol(message: string): void {
|
|
187
|
+
this.proc?.kill("SIGTERM");
|
|
188
|
+
this.rejectAll(new Error(message));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private rejectAll(error: Error): void {
|
|
192
|
+
for (const pending of this.pending.values()) pending.reject(error);
|
|
193
|
+
this.pending.clear();
|
|
194
|
+
this.currentPrompt?.reject(error);
|
|
195
|
+
this.currentPrompt = undefined;
|
|
196
|
+
this.runningPrompt = false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseRpcObject(line: string): RpcWireEvent {
|
|
201
|
+
const parsed = JSON.parse(line);
|
|
202
|
+
if (!isObject(parsed) || typeof parsed.type !== "string") throw new Error(`invalid RPC event: ${line}`);
|
|
203
|
+
return { ...parsed, type: parsed.type };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isRpcResponse(event: RpcWireEvent): event is RpcResponse {
|
|
207
|
+
return event.type === RESPONSE_EVENT;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function errorMessage(error: unknown): string {
|
|
211
|
+
return error instanceof Error ? error.message : String(error);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
215
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function getPiInvocation(args: string[]): PiInvocation {
|
|
219
|
+
const currentScript = process.argv[1];
|
|
220
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
221
|
+
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
222
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
226
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
227
|
+
return isGenericRuntime ? { command: "pi", args } : { command: process.execPath, args };
|
|
228
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { LlmiterateConfig, MarkerPrompt } from "./core";
|
|
2
|
+
|
|
3
|
+
export type RunStatus = "queued" | "running" | "done" | "error";
|
|
4
|
+
|
|
5
|
+
export interface LlmiterateRun {
|
|
6
|
+
id: number;
|
|
7
|
+
block: MarkerPrompt;
|
|
8
|
+
projectFile: string;
|
|
9
|
+
absoluteFile: string;
|
|
10
|
+
fullPrompt: string;
|
|
11
|
+
status: RunStatus;
|
|
12
|
+
queuedAt: number;
|
|
13
|
+
updatedAt: number;
|
|
14
|
+
assistantText: string;
|
|
15
|
+
toolText: string;
|
|
16
|
+
error?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UiState {
|
|
20
|
+
config?: LlmiterateConfig;
|
|
21
|
+
lockError?: string;
|
|
22
|
+
watchErrors: number;
|
|
23
|
+
activeRun?: LlmiterateRun;
|
|
24
|
+
queuedRuns: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RpcTextPart {
|
|
28
|
+
type?: string;
|
|
29
|
+
text?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RpcMessage {
|
|
33
|
+
role?: string;
|
|
34
|
+
content?: string | RpcTextPart[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type RpcEvent =
|
|
38
|
+
| { type: "message_update" | "message_end"; message?: RpcMessage }
|
|
39
|
+
| { type: "tool_execution_start"; toolName?: unknown }
|
|
40
|
+
| { type: "tool_execution_end"; toolName?: unknown; isError?: unknown; result?: RpcToolResult }
|
|
41
|
+
| { type: "agent_end" };
|
|
42
|
+
|
|
43
|
+
export interface RpcToolResult {
|
|
44
|
+
content?: RpcTextPart[];
|
|
45
|
+
}
|
package/ui.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
4
|
+
import { previewText } from "./core";
|
|
5
|
+
import type { LlmiterateRun, RunStatus, UiState } from "./types";
|
|
6
|
+
|
|
7
|
+
const STATUS_KEY = "llmiterate";
|
|
8
|
+
const WIDGET_KEY = "llmiterate";
|
|
9
|
+
const WIDGET_RUN_LIMIT = 6;
|
|
10
|
+
const PROMPT_PREVIEW_CHARS = 80;
|
|
11
|
+
const DETAIL_PREVIEW_CHARS = 110;
|
|
12
|
+
|
|
13
|
+
export function renderLlmiterateUi(
|
|
14
|
+
ctx: ExtensionContext | undefined,
|
|
15
|
+
panelVisible: boolean,
|
|
16
|
+
runs: readonly LlmiterateRun[],
|
|
17
|
+
state: UiState,
|
|
18
|
+
): void {
|
|
19
|
+
if (!ctx?.hasUI) return;
|
|
20
|
+
|
|
21
|
+
ctx.ui.setStatus(STATUS_KEY, statusLine(ctx, state));
|
|
22
|
+
if (!panelVisible) {
|
|
23
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => new LlmiterateWidget(runs, theme), {
|
|
28
|
+
placement: "belowEditor",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function statusLine(ctx: ExtensionContext, state: UiState): string {
|
|
33
|
+
const theme = ctx.ui.theme;
|
|
34
|
+
if (!state.config?.enabled) return theme.fg("dim", "llmiterate off");
|
|
35
|
+
if (state.lockError) return theme.fg("dim", "llmiterate standby");
|
|
36
|
+
if (state.watchErrors > 0) return theme.fg("warning", `llmiterate ${state.watchErrors} watch error(s)`);
|
|
37
|
+
if (state.activeRun) return theme.fg("accent", `llmiterate ${state.activeRun.status} ${state.activeRun.block.file}:${state.activeRun.block.startLine}`);
|
|
38
|
+
if (state.queuedRuns > 0) return theme.fg("warning", `llmiterate queued ${state.queuedRuns}`);
|
|
39
|
+
return theme.fg("dim", "llmiterate idle");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class LlmiterateWidget implements Component {
|
|
43
|
+
constructor(
|
|
44
|
+
private readonly runs: readonly LlmiterateRun[],
|
|
45
|
+
private readonly theme: ExtensionContext["ui"]["theme"],
|
|
46
|
+
) {}
|
|
47
|
+
|
|
48
|
+
render(width: number): string[] {
|
|
49
|
+
if (width <= 0) return [];
|
|
50
|
+
|
|
51
|
+
const inner = Math.max(1, width - 2);
|
|
52
|
+
const lines = [
|
|
53
|
+
this.border("╭", "╮", inner, ` ${this.theme.bold("llmiterate")} `),
|
|
54
|
+
...this.runLines(inner),
|
|
55
|
+
this.border("╰", "╯", inner),
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
return lines.map((line) => truncateToWidth(line, width, ""));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
invalidate(): void {}
|
|
62
|
+
|
|
63
|
+
private runLines(inner: number): string[] {
|
|
64
|
+
if (this.runs.length === 0) return [this.line(this.theme.fg("dim", "no prompts queued yet"), inner)];
|
|
65
|
+
|
|
66
|
+
const lines: string[] = [];
|
|
67
|
+
for (const run of this.runs.slice(0, WIDGET_RUN_LIMIT)) {
|
|
68
|
+
lines.push(this.line(this.runHeader(run), inner));
|
|
69
|
+
if (run.toolText) lines.push(this.line(this.theme.fg("dim", ` ${run.toolText}`), inner));
|
|
70
|
+
if (run.error) lines.push(this.line(this.theme.fg("error", ` ${previewText(run.error, DETAIL_PREVIEW_CHARS)}`), inner));
|
|
71
|
+
else if (run.assistantText) lines.push(this.line(this.theme.fg("dim", ` ${previewText(run.assistantText, DETAIL_PREVIEW_CHARS)}`), inner));
|
|
72
|
+
}
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private runHeader(run: LlmiterateRun): string {
|
|
77
|
+
return `${statusIcon(run.status)} ${run.block.file}:${run.block.startLine} ${this.theme.fg("muted", run.block.marker)} ${previewText(run.block.prompt, PROMPT_PREVIEW_CHARS)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private border(left: string, right: string, inner: number, label = ""): string {
|
|
81
|
+
const labelWidth = visibleWidth(label);
|
|
82
|
+
return this.theme.fg("border", left)
|
|
83
|
+
+ this.theme.fg("accent", label)
|
|
84
|
+
+ this.theme.fg("border", "─".repeat(Math.max(0, inner - labelWidth)) + right);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private line(text: string, inner: number): string {
|
|
88
|
+
const body = fit(text, inner);
|
|
89
|
+
return this.theme.fg("border", "│") + body + this.theme.fg("border", "│");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function fit(text: string, width: number): string {
|
|
94
|
+
const t = truncateToWidth(text, width, "…");
|
|
95
|
+
return t + " ".repeat(Math.max(0, width - visibleWidth(t)));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function statusIcon(status: RunStatus): string {
|
|
99
|
+
if (status === "queued") return "◌";
|
|
100
|
+
if (status === "running") return "●";
|
|
101
|
+
if (status === "done") return "✓";
|
|
102
|
+
return "✗";
|
|
103
|
+
}
|
package/watcher.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
shouldIncludeCompiledPath,
|
|
5
|
+
shouldWatchDirectory,
|
|
6
|
+
toSlash,
|
|
7
|
+
type CompiledPathFilter,
|
|
8
|
+
type LlmiterateConfig,
|
|
9
|
+
} from "./core";
|
|
10
|
+
|
|
11
|
+
const DIRECTORY_REFRESH_MS = 250;
|
|
12
|
+
|
|
13
|
+
type FileHandler = (absolutePath: string) => void;
|
|
14
|
+
type ErrorHandler = () => void;
|
|
15
|
+
|
|
16
|
+
export class ProjectWatcher {
|
|
17
|
+
private readonly watchers = new Map<string, fs.FSWatcher>();
|
|
18
|
+
private readonly fileTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
19
|
+
private readonly dirTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
20
|
+
|
|
21
|
+
public errorCount = 0;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
private readonly root: string,
|
|
25
|
+
private readonly config: LlmiterateConfig,
|
|
26
|
+
private readonly pathFilter: CompiledPathFilter,
|
|
27
|
+
private readonly onFileReady: FileHandler,
|
|
28
|
+
private readonly onError: ErrorHandler,
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
start(): void {
|
|
32
|
+
if (!shouldWatchDirectory("", this.pathFilter)) return;
|
|
33
|
+
|
|
34
|
+
const root = path.resolve(this.root);
|
|
35
|
+
this.watchTree(root);
|
|
36
|
+
// fs.watch can miss changes made immediately after watcher creation.
|
|
37
|
+
// One delayed reconciliation makes startup deterministic without polling.
|
|
38
|
+
this.scheduleDirectoryRefresh(root);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
stop(): void {
|
|
42
|
+
for (const timer of this.fileTimers.values()) clearTimeout(timer);
|
|
43
|
+
for (const timer of this.dirTimers.values()) clearTimeout(timer);
|
|
44
|
+
for (const watcher of this.watchers.values()) watcher.close();
|
|
45
|
+
this.fileTimers.clear();
|
|
46
|
+
this.dirTimers.clear();
|
|
47
|
+
this.watchers.clear();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
scanAll(onFile: (absolutePath: string) => number): number {
|
|
51
|
+
let total = 0;
|
|
52
|
+
this.walkFiles(this.root, (file) => { total += onFile(file); });
|
|
53
|
+
return total;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private watchTree(dir: string): void {
|
|
57
|
+
const abs = path.resolve(dir);
|
|
58
|
+
if (this.watchers.has(abs)) return;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const watcher = fs.watch(abs, { persistent: false }, (eventType, filename) => {
|
|
62
|
+
if (!filename) {
|
|
63
|
+
this.scheduleDirectoryRefresh(abs);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.handlePathEvent(path.join(abs, filename.toString()));
|
|
68
|
+
if (eventType === "rename") this.scheduleDirectoryRefresh(abs);
|
|
69
|
+
});
|
|
70
|
+
watcher.on("error", () => {
|
|
71
|
+
this.recordError();
|
|
72
|
+
this.unwatchTree(abs);
|
|
73
|
+
});
|
|
74
|
+
watcher.on("close", () => this.watchers.delete(abs));
|
|
75
|
+
this.watchers.set(abs, watcher);
|
|
76
|
+
} catch {
|
|
77
|
+
this.recordError();
|
|
78
|
+
// fs.watch is not equally reliable across platforms/runtimes. Still scan
|
|
79
|
+
// the directory once so startup/reconciliation catches already-written files.
|
|
80
|
+
this.refreshDirectory(abs);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this.refreshDirectory(abs);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private handlePathEvent(absPath: string): void {
|
|
88
|
+
let stat: fs.Stats | undefined;
|
|
89
|
+
try { stat = fs.statSync(absPath); } catch { return; }
|
|
90
|
+
|
|
91
|
+
if (stat.isDirectory()) {
|
|
92
|
+
if (this.shouldWatchDir(absPath)) this.watchTree(absPath);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (stat.isFile()) this.scheduleFile(absPath);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private refreshDirectory(abs: string): void {
|
|
100
|
+
let entries: fs.Dirent[];
|
|
101
|
+
try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
|
102
|
+
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const child = path.join(abs, entry.name);
|
|
105
|
+
const rel = this.projectRel(child);
|
|
106
|
+
if (entry.isDirectory()) {
|
|
107
|
+
if (!shouldWatchDirectory(rel, this.pathFilter)) continue;
|
|
108
|
+
this.watchTree(child);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (entry.isFile() && shouldIncludeCompiledPath(rel, this.pathFilter)) this.scheduleFile(child);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private scheduleDirectoryRefresh(abs: string): void {
|
|
116
|
+
const existing = this.dirTimers.get(abs);
|
|
117
|
+
if (existing) clearTimeout(existing);
|
|
118
|
+
this.dirTimers.set(abs, setTimeout(() => {
|
|
119
|
+
this.dirTimers.delete(abs);
|
|
120
|
+
this.reconcileWatchers();
|
|
121
|
+
this.refreshDirectory(abs);
|
|
122
|
+
}, DIRECTORY_REFRESH_MS));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private reconcileWatchers(): void {
|
|
126
|
+
for (const dir of this.watchers.keys()) {
|
|
127
|
+
if (!fs.existsSync(dir)) this.unwatchTree(dir);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private unwatchTree(abs: string): void {
|
|
132
|
+
const watcher = this.watchers.get(abs);
|
|
133
|
+
this.watchers.delete(abs);
|
|
134
|
+
try { watcher?.close(); } catch {}
|
|
135
|
+
|
|
136
|
+
for (const dir of [...this.watchers.keys()]) {
|
|
137
|
+
if (isDescendant(abs, dir)) this.unwatchTree(dir);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private scheduleFile(absPath: string): void {
|
|
142
|
+
const rel = this.projectRel(absPath);
|
|
143
|
+
if (!isSafeProjectRel(rel) || !shouldIncludeCompiledPath(rel, this.pathFilter)) return;
|
|
144
|
+
|
|
145
|
+
const existing = this.fileTimers.get(rel);
|
|
146
|
+
if (existing) clearTimeout(existing);
|
|
147
|
+
this.fileTimers.set(rel, setTimeout(() => {
|
|
148
|
+
this.fileTimers.delete(rel);
|
|
149
|
+
this.onFileReady(absPath);
|
|
150
|
+
}, this.config.debounceMs));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private walkFiles(dir: string, onFile: FileHandler): void {
|
|
154
|
+
let entries: fs.Dirent[];
|
|
155
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
156
|
+
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
const abs = path.join(dir, entry.name);
|
|
159
|
+
const rel = this.projectRel(abs);
|
|
160
|
+
if (entry.isDirectory()) {
|
|
161
|
+
if (!shouldWatchDirectory(rel, this.pathFilter)) continue;
|
|
162
|
+
this.walkFiles(abs, onFile);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (entry.isFile() && shouldIncludeCompiledPath(rel, this.pathFilter)) onFile(abs);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private projectRel(absPath: string): string {
|
|
170
|
+
return toSlash(path.relative(this.root, absPath));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private shouldWatchDir(absPath: string): boolean {
|
|
174
|
+
const rel = this.projectRel(absPath);
|
|
175
|
+
return isSafeProjectRel(rel) && shouldWatchDirectory(rel, this.pathFilter);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private recordError(): void {
|
|
179
|
+
this.errorCount++;
|
|
180
|
+
this.onError();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isSafeProjectRel(rel: string): boolean {
|
|
185
|
+
return Boolean(rel) && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isDescendant(parent: string, child: string): boolean {
|
|
189
|
+
const rel = path.relative(parent, child);
|
|
190
|
+
return Boolean(rel) && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
191
|
+
}
|