pi-gortex 0.3.0 → 0.4.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/src/diff.ts ADDED
@@ -0,0 +1,404 @@
1
+ // Diffs for Gortex's file-writing tools. The daemon reports an applied edit
2
+ // without its content, so the bridge snapshots the files a call names, runs
3
+ // the call, and diffs what changed in Pi's own edit format.
4
+
5
+ import { existsSync } from "node:fs";
6
+ import { readFile, stat } from "node:fs/promises";
7
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
8
+
9
+ import { generateDiffString, generateUnifiedPatch, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
10
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+
12
+ import { bareToolName, isNativeGortexTool } from "./native.ts";
13
+
14
+ export interface FileDiff {
15
+ /** Relative to the session's cwd. */
16
+ path: string;
17
+ /** Empty when `note` explains why the change is not drawn as a diff. */
18
+ diff: string;
19
+ patch: string;
20
+ firstChangedLine?: number;
21
+ note?: string;
22
+ }
23
+
24
+ /**
25
+ * A result's details: every changed file under `diffs`, plus Pi's EditToolDetails
26
+ * fields at the top level. `patch` covers every file; `diff` and
27
+ * `firstChangedLine` are set when exactly one file changed.
28
+ */
29
+ export interface EditDetails {
30
+ diffs?: FileDiff[];
31
+ diff?: string;
32
+ patch?: string;
33
+ firstChangedLine?: number;
34
+ }
35
+
36
+ const MAX_FILE_BYTES = 1024 * 1024;
37
+ // Pi's line diff costs about (lines old + new) x (changed lines) and runs on
38
+ // the event loop; this budget keeps one diff to roughly a tenth of a second.
39
+ const MAX_DIFF_WORK = 2_000_000;
40
+
41
+ const LEGACY_EDIT_TOOLS = new Set(["edit_file", "write_file", "edit_symbol", "batch_edit"]);
42
+ const EDIT_OPERATIONS = new Set(["file", "symbol", "write", "batch"]);
43
+ // Argument containers the `edit` facade flattens before dispatch.
44
+ const CONTAINERS = ["arguments", "options", "source", "context", "guard", "output"];
45
+ const PATH_KEYS = ["path", "file", "file_path", "source", "destination"];
46
+ const SYMBOL_KEYS = ["id", "symbol", "symbol_id"];
47
+ // Pi's own tools that write the file named by their `path`.
48
+ const PI_WRITE_TOOLS = new Set(["edit", "write"]);
49
+
50
+ type Snapshot =
51
+ | { kind: "missing" }
52
+ | { kind: "text"; raw: Buffer; text: string }
53
+ /** Not diffable. `reason` is empty when the change should pass silently. */
54
+ | { kind: "skipped"; reason: string; size: number; mtimeMs: number };
55
+
56
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
57
+
58
+ function asRecord(value: unknown): Record<string, unknown> {
59
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
60
+ }
61
+
62
+ /** isFileEdit reports whether a call writes files the bridge can name up front. */
63
+ export function isFileEdit(tool: string, params: Record<string, unknown>): boolean {
64
+ if (LEGACY_EDIT_TOOLS.has(tool)) return !isDryRun(params);
65
+ return tool === "edit" && EDIT_OPERATIONS.has(String(params.operation)) && !isDryRun(params);
66
+ }
67
+
68
+ function isDryRun(params: Record<string, unknown>): boolean {
69
+ return flatten(params).dry_run === true;
70
+ }
71
+
72
+ function flatten(params: Record<string, unknown>): Record<string, unknown> {
73
+ const out: Record<string, unknown> = {};
74
+ for (const key of CONTAINERS) Object.assign(out, asRecord(params[key]));
75
+ for (const [key, value] of Object.entries(params)) {
76
+ if (key === "target" || (CONTAINERS.includes(key) && typeof value === "object")) continue;
77
+ out[key] = value;
78
+ }
79
+ const target = asRecord(params.target);
80
+ if (typeof target.file === "string") out.path = target.file;
81
+ if (typeof target.symbol === "string") out.id = target.symbol;
82
+ return out;
83
+ }
84
+
85
+ function batchItems(value: unknown): Record<string, unknown>[] {
86
+ let items = value;
87
+ if (typeof items === "string") {
88
+ try {
89
+ items = JSON.parse(items);
90
+ } catch {
91
+ return [];
92
+ }
93
+ }
94
+ return Array.isArray(items) ? items.map(asRecord) : [];
95
+ }
96
+
97
+ function namedPaths(entry: Record<string, unknown>): string[] {
98
+ const out: string[] = [];
99
+ for (const key of PATH_KEYS) {
100
+ if (typeof entry[key] === "string") out.push(entry[key]);
101
+ }
102
+ for (const key of SYMBOL_KEYS) {
103
+ const id = entry[key];
104
+ if (typeof id === "string" && id.includes("::")) out.push(id.slice(0, id.indexOf("::")));
105
+ }
106
+ return out;
107
+ }
108
+
109
+ const repoRoots = new Map<string, string>();
110
+
111
+ /** The nearest directory at or above cwd holding `.git`, else cwd itself. */
112
+ function repoRoot(cwd: string): string {
113
+ let root = repoRoots.get(cwd);
114
+ if (root !== undefined) return root;
115
+ root = cwd;
116
+ for (let dir = cwd; ; dir = dirname(dir)) {
117
+ if (existsSync(join(dir, ".git"))) {
118
+ root = dir;
119
+ break;
120
+ }
121
+ if (dirname(dir) === dir) break;
122
+ }
123
+ repoRoots.set(cwd, root);
124
+ return root;
125
+ }
126
+
127
+ /**
128
+ * candidatePaths lists the disk paths a call may write. Gortex paths are
129
+ * relative to the repo root and may carry the repo name as a first segment, so
130
+ * both forms are tried against cwd and the repo root; a candidate that does
131
+ * not exist simply never changes.
132
+ */
133
+ export function candidatePaths(params: Record<string, unknown>, cwd: string): string[] {
134
+ const args = flatten(params);
135
+ const raw = [...namedPaths(args), ...batchItems(args.changes ?? args.edits).flatMap(namedPaths)];
136
+ const bases = [...new Set([cwd, repoRoot(cwd)])];
137
+ const out = new Set<string>();
138
+ for (const name of raw) {
139
+ if (!name) continue;
140
+ if (isAbsolute(name)) {
141
+ out.add(resolve(name));
142
+ continue;
143
+ }
144
+ const path = name.replace(/\\/g, "/");
145
+ const slash = path.indexOf("/");
146
+ for (const base of bases) {
147
+ out.add(resolve(base, path));
148
+ if (slash > 0) out.add(resolve(base, path.slice(slash + 1)));
149
+ }
150
+ }
151
+ return [...out].sort();
152
+ }
153
+
154
+ async function snapshot(path: string): Promise<Snapshot> {
155
+ try {
156
+ const info = await stat(path);
157
+ const skipped = (reason: string): Snapshot => ({ kind: "skipped", reason, size: info.size, mtimeMs: info.mtimeMs });
158
+ if (!info.isFile()) return skipped("");
159
+ if (info.size > MAX_FILE_BYTES) return skipped("over 1 MiB");
160
+ const raw = await readFile(path);
161
+ if (raw.includes(0)) return skipped("binary file");
162
+ let text: string;
163
+ try {
164
+ text = utf8.decode(raw);
165
+ } catch {
166
+ return skipped("not UTF-8 text");
167
+ }
168
+ return { kind: "text", raw, text: text.replace(/^/, "").replace(/\r\n/g, "\n") };
169
+ } catch (err) {
170
+ if ((err as { code?: string }).code === "ENOENT") return { kind: "missing" };
171
+ return { kind: "skipped", reason: "", size: -1, mtimeMs: -1 };
172
+ }
173
+ }
174
+
175
+ function snapshotAll(paths: string[]): Promise<Snapshot[]> {
176
+ return Promise.all(paths.map(snapshot));
177
+ }
178
+
179
+ function lineEnding(raw: Buffer): string {
180
+ return raw.includes("\r\n") ? "CRLF" : "LF";
181
+ }
182
+
183
+ function hasBom(raw: Buffer): boolean {
184
+ return raw[0] === 0xef && raw[1] === 0xbb && raw[2] === 0xbf;
185
+ }
186
+
187
+ /** What changed between two texts that are equal once normalized, or "" for nothing. */
188
+ function invisibleChange(old: Buffer, now: Buffer): string {
189
+ if (old.equals(now)) return "";
190
+ const notes: string[] = [];
191
+ if (lineEnding(old) !== lineEnding(now)) notes.push(`line endings ${lineEnding(old)} -> ${lineEnding(now)}`);
192
+ if (hasBom(old) !== hasBom(now)) notes.push(hasBom(now) ? "byte order mark added" : "byte order mark removed");
193
+ return notes.length > 0 ? notes.join(", ") : "line endings changed";
194
+ }
195
+
196
+ /** A lower bound on the work Pi's line diff does: total lines times lines not shared. */
197
+ function diffWork(old: string, now: string): number {
198
+ const a = old.split("\n");
199
+ const b = now.split("\n");
200
+ const counts = new Map<string, number>();
201
+ for (const line of a) counts.set(line, (counts.get(line) ?? 0) + 1);
202
+ let shared = 0;
203
+ for (const line of b) {
204
+ const n = counts.get(line);
205
+ if (n) {
206
+ counts.set(line, n - 1);
207
+ shared++;
208
+ }
209
+ }
210
+ return (a.length + b.length) * (a.length + b.length - 2 * shared);
211
+ }
212
+
213
+ function textOf(s: Snapshot): string | undefined {
214
+ if (s.kind === "missing") return "";
215
+ return s.kind === "text" ? s.text : undefined;
216
+ }
217
+
218
+ function diffOne(path: string, old: Snapshot, now: Snapshot): FileDiff | undefined {
219
+ const note = (text: string): FileDiff => ({ path, diff: "", patch: "", note: text });
220
+ if (old.kind === "skipped" || now.kind === "skipped") {
221
+ const reason = (now.kind === "skipped" ? now.reason : "") || (old.kind === "skipped" ? old.reason : "");
222
+ const unchanged =
223
+ old.kind === "skipped" && now.kind === "skipped" && old.size === now.size && old.mtimeMs === now.mtimeMs;
224
+ return reason && !unchanged ? note(`not diffed: ${reason}`) : undefined;
225
+ }
226
+ const before = textOf(old)!;
227
+ const after = textOf(now)!;
228
+ if (before === after) {
229
+ if (old.kind !== "text" || now.kind !== "text") return undefined;
230
+ const change = invisibleChange(old.raw, now.raw);
231
+ return change ? note(change) : undefined;
232
+ }
233
+ if (diffWork(before, after) > MAX_DIFF_WORK) return note("not diffed: change too large to diff quickly");
234
+ const { diff, firstChangedLine } = generateDiffString(before, after);
235
+ if (!diff) return undefined;
236
+ return { path, diff, patch: generateUnifiedPatch(path, before, after), firstChangedLine };
237
+ }
238
+
239
+ function diffSnapshots(paths: string[], before: Snapshot[], after: Snapshot[], cwd: string): FileDiff[] {
240
+ const diffs: FileDiff[] = [];
241
+ paths.forEach((abs, i) => {
242
+ const diff = diffOne(relative(cwd, abs) || abs, before[i]!, after[i]!);
243
+ if (diff) diffs.push(diff);
244
+ });
245
+ return diffs;
246
+ }
247
+
248
+ /** editDetails shapes diffs as a result's details; no diffs give no fields. */
249
+ export function editDetails(diffs: FileDiff[]): EditDetails {
250
+ if (diffs.length === 0) return {};
251
+ const details: EditDetails = { diffs };
252
+ const patch = diffs.map((d) => d.patch).join("");
253
+ if (patch) details.patch = patch;
254
+ if (diffs.length === 1 && diffs[0]!.diff) {
255
+ details.diff = diffs[0]!.diff;
256
+ if (diffs[0]!.firstChangedLine !== undefined) details.firstChangedLine = diffs[0]!.firstChangedLine;
257
+ }
258
+ return details;
259
+ }
260
+
261
+ // A shell command can write any file, so a diff taken while one runs may
262
+ // include its writes. Tracked on both channels through Pi's events.
263
+ const SHELL_TOOLS = new Set(["bash", "powershell"]);
264
+ const shellCallsRunning = new Set<string>();
265
+ let shellCallsSeen = 0;
266
+
267
+ /** Starts watching for shell calls; the result reports whether one overlapped. */
268
+ function shellWindow(): () => boolean {
269
+ const busy = shellCallsRunning.size > 0;
270
+ const seen = shellCallsSeen;
271
+ return () => busy || shellCallsRunning.size > 0 || shellCallsSeen !== seen;
272
+ }
273
+
274
+ const SHELL_NOTE = "not diffed: a shell command ran at the same time";
275
+
276
+ function shellNotes(diffs: FileDiff[]): FileDiff[] {
277
+ return diffs.map(({ path }) => ({ path, diff: "", patch: "", note: SHELL_NOTE }));
278
+ }
279
+
280
+ function withLocks<T>(paths: string[], fn: () => Promise<T>): Promise<T> {
281
+ return paths.reduceRight<() => Promise<T>>((next, path) => () => withFileMutationQueue(path, next), fn)();
282
+ }
283
+
284
+ /**
285
+ * withFileDiffs runs call while holding Pi's per-file mutation lock on every
286
+ * candidate path, and returns its result with a diff for each file it changed.
287
+ * A failed call is not diffed.
288
+ */
289
+ export async function withFileDiffs<T>(
290
+ paths: string[],
291
+ cwd: string,
292
+ call: () => Promise<T>,
293
+ succeeded: (result: T) => boolean,
294
+ ): Promise<{ result: T; diffs: FileDiff[] }> {
295
+ return withLocks(paths, async () => {
296
+ const overlapped = shellWindow();
297
+ const before = await snapshotAll(paths);
298
+ const result = await call();
299
+ if (!succeeded(result)) return { result, diffs: [] };
300
+ const diffs = diffSnapshots(paths, before, await snapshotAll(paths), cwd);
301
+ return { result, diffs: overlapped() ? shellNotes(diffs) : diffs };
302
+ });
303
+ }
304
+
305
+ interface PendingEdit {
306
+ cwd: string;
307
+ paths: string[];
308
+ before?: Snapshot[];
309
+ /** Per path, the other calls in flight that also write it, so a diff could mix both. */
310
+ contested: Map<string, Set<string>>;
311
+ overlapped?: () => boolean;
312
+ }
313
+
314
+ function contest(entry: PendingEdit, path: string, by: string): void {
315
+ const ids = entry.contested.get(path) ?? new Set<string>();
316
+ ids.add(by);
317
+ entry.contested.set(path, ids);
318
+ }
319
+
320
+ /**
321
+ * watchNativeEdits diffs the gortex tools Pi's built-in MCP runs. Pi executes
322
+ * those, so the files are read on `tool_call` and again on `tool_result`, which
323
+ * stores the diffs in the result's details. Pi prepares a whole parallel batch
324
+ * before running any of it, so no lock can span the call; a path two calls in
325
+ * flight write gets no diff instead. Register it after every handler that may
326
+ * block, since a blocked call never reaches `tool_result`.
327
+ */
328
+ export function watchNativeEdits(pi: ExtensionAPI, active: () => boolean): void {
329
+ const pending = new Map<string, PendingEdit>();
330
+
331
+ function claim(id: string, entry: PendingEdit): void {
332
+ for (const [otherId, other] of pending) {
333
+ for (const path of entry.paths) {
334
+ if (!other.paths.includes(path)) continue;
335
+ contest(other, path, id);
336
+ contest(entry, path, otherId);
337
+ }
338
+ }
339
+ pending.set(id, entry);
340
+ }
341
+
342
+ /** A call that ended without a result never ran, so it contests nothing. */
343
+ function release(id: string): void {
344
+ if (!pending.delete(id)) return;
345
+ for (const other of pending.values()) {
346
+ for (const ids of other.contested.values()) ids.delete(id);
347
+ }
348
+ }
349
+
350
+ pi.on("tool_call", async (event, ctx: ExtensionContext) => {
351
+ if (SHELL_TOOLS.has(event.toolName)) {
352
+ shellCallsRunning.add(event.toolCallId);
353
+ shellCallsSeen++;
354
+ }
355
+ try {
356
+ if (!active() || event.parentToolCallId) return;
357
+ const input = (event.input ?? {}) as Record<string, unknown>;
358
+ const cwd = ctx.cwd;
359
+ if (PI_WRITE_TOOLS.has(event.toolName) && typeof input.path === "string") {
360
+ claim(event.toolCallId, { cwd, paths: [resolve(cwd, input.path)], contested: new Map() });
361
+ return;
362
+ }
363
+ if (!isNativeGortexTool(event.toolName) || !isFileEdit(bareToolName(event.toolName), input)) return;
364
+ const paths = candidatePaths(input, cwd);
365
+ if (paths.length === 0) return;
366
+ const overlapped = shellWindow();
367
+ claim(event.toolCallId, { cwd, paths, before: await snapshotAll(paths), contested: new Map(), overlapped });
368
+ } catch {
369
+ // A throwing tool_call handler blocks the call.
370
+ }
371
+ });
372
+
373
+ pi.on("tool_result", async (event) => {
374
+ const entry = pending.get(event.toolCallId);
375
+ if (!entry) return;
376
+ pending.delete(event.toolCallId);
377
+ if (!entry.before || event.isError) return;
378
+ try {
379
+ const paths = entry.paths.filter((p) => !entry.contested.get(p)?.size);
380
+ const keep = entry.paths.map((p) => paths.includes(p));
381
+ const before = entry.before.filter((_, i) => keep[i]);
382
+ let diffs = diffSnapshots(paths, before, await snapshotAll(paths), entry.cwd);
383
+ if (diffs.length === 0) return;
384
+ if (entry.overlapped?.()) diffs = shellNotes(diffs);
385
+ return { details: { ...asRecord(event.details), ...editDetails(diffs) } };
386
+ } catch {
387
+ return;
388
+ }
389
+ });
390
+
391
+ // Pi emits this after tool_result, and also for a call another handler blocked.
392
+ pi.on("tool_execution_end", (event) => {
393
+ release(event.toolCallId);
394
+ shellCallsRunning.delete(event.toolCallId);
395
+ });
396
+ pi.on("turn_end", () => {
397
+ pending.clear();
398
+ shellCallsRunning.clear();
399
+ });
400
+ pi.on("session_shutdown", () => {
401
+ pending.clear();
402
+ shellCallsRunning.clear();
403
+ });
404
+ }
package/src/hook.ts CHANGED
@@ -26,6 +26,7 @@ const HOOK_EXEC_OPTS: ExecFileSyncOpts = {
26
26
  encoding: "utf8",
27
27
  maxBuffer: 16 * 1024 * 1024,
28
28
  timeout: 5_000,
29
+ windowsHide: true,
29
30
  };
30
31
 
31
32
  /**
@@ -55,9 +56,9 @@ export function callHook(argv: string[], deps: ProcessDeps, envelope: PiEnvelope
55
56
  // The Go classifier switches on Claude-Code tool names and canonical input
56
57
  // keys ("file_path"/"pattern"/"command"), so the translation lives here.
57
58
  //
58
- // NOTE: Pi is pre-1.0. The built-in tool names mapped here are pinned to Pi's
59
- // documented API (packages/coding-agent/docs/extensions.md); if an upgrade
60
- // renames one, adjust this small map; the Go wire contract stays unchanged.
59
+ // The built-in tool names mapped here are pinned to Pi's documented API
60
+ // (packages/coding-agent/docs/extensions.md); if an upgrade renames one, adjust
61
+ // this small map; the Go wire contract stays unchanged.
61
62
  export const toolNameMap: Record<string, string> = {
62
63
  read: "Read",
63
64
  grep: "Grep",