pi-gortex 0.2.0 → 0.4.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/README.md CHANGED
@@ -62,10 +62,11 @@ environment -> `<cwd>/.pi/gortex.json` -> `<agent dir>/extensions/gortex.json`
62
62
 
63
63
  | Sidecar key | Environment | Default | Meaning |
64
64
  |----------------|--------------------|--------------------|---------|
65
- | `bin` | `GORTEX_BIN` | `gortex` on `PATH` | Binary used for `daemon start`, `mcp` and `hook`. |
65
+ | `bin` | `GORTEX_BIN` | `gortex` on `PATH` | Binary used for `daemon start`, `mcp` and `hook`. Read from the global sidecar only; a project file cannot set it. |
66
66
  | `hook_mode` | `GORTEX_HOOK_MODE` | `deny` | Posture: `deny`, `enrich`, `consult-unlock`, `nudge`. |
67
67
  | `enforce` | `GORTEX_ENFORCE` | `true` | `false` keeps the graph tools and the briefing, and wires no enforcement. |
68
68
  | `tools_preset` | `GORTEX_TOOLS` | `core` | Eager MCP surface. `core`/`full` use the daemon's default; `edit`/`nav`/`readonly` narrow it. |
69
+ | `native_mcp` | `GORTEX_NATIVE_MCP` | `true` | On Pi 0.99+, register `gortex mcp` with Pi's built-in MCP. `false` keeps the extension's own client. |
69
70
 
70
71
  ```json
71
72
  {
@@ -75,6 +76,17 @@ environment -> `<cwd>/.pi/gortex.json` -> `<agent dir>/extensions/gortex.json`
75
76
  }
76
77
  ```
77
78
 
79
+ The extension reads its configuration when Pi loads it, so a change takes
80
+ effect after `/reload`.
81
+
82
+ **Pi's own `mcp.json` wins.** With `native_mcp` on, the extension registers a
83
+ server named `gortex`. A `gortex` entry in `~/.pi/agent/mcp.json` or
84
+ `.pi/mcp.json` replaces it, including its exposure, and Pi's default exposure
85
+ there is `codemode`, which keeps the tools out of the model's tool list. Leave
86
+ Gortex out of `mcp.json`, or set `"exposure": "direct"` on that entry. A
87
+ Gortex server configured under another name runs as a second connection, and
88
+ read discipline treats its tools as non-graph calls.
89
+
78
90
  **If the graph tools go missing**, the first turn of the session says so and
79
91
  names `/reload` as the retry. The bridge fails open, always: an absent binary or
80
92
  a daemon that is down costs the session its graph tools, never the session.
@@ -4,20 +4,55 @@ How the bridge is built, and why each piece is shaped the way it is.
4
4
 
5
5
  ## The problem
6
6
 
7
- Pi has no MCP support, by design; its extension API covers the ground MCP would.
8
7
  Gortex speaks MCP and owns a hook protocol that every agent it integrates with
9
- shares. This extension is the adapter between the two, and it does two separate
10
- jobs over two separate channels.
11
-
12
- ## Channel 1: graph tools over an MCP stdio bridge
8
+ shares. Pi 0.99 added built-in MCP support; earlier Pi has none. This extension
9
+ is the adapter between the two, and it does two separate jobs over two separate
10
+ channels.
11
+
12
+ ## Channel 1 on Pi 0.99+: Pi's built-in MCP
13
+
14
+ On a Pi that offers `pi.registerMcpServer()`, the extension registers
15
+ `gortex mcp` as a stdio server named `gortex` and leaves the tool channel to Pi.
16
+ Pi spawns and connects the server, registers its tools as `mcp__gortex__<tool>`,
17
+ follows `tools/list_changed`, renders the calls and stops the child on reload
18
+ and quit. `gortex mcp` starts the shared daemon itself, so the extension starts
19
+ nothing.
20
+
21
+ - **Exposure.** The server is registered with `direct` exposure, so its tools
22
+ are declared to the model like built-in tools and Pi holds the first prompt
23
+ until the server connects. Pi's default, codemode, would hand scripts the
24
+ compact text results to parse.
25
+ - **Timeout.** The per-request timeout matches the tool-call cap of the
26
+ extension's own client. Pi's default is a minute, short for some analyzers.
27
+ - **Wire format.** Pi's client identifies itself as `pi` and declares no
28
+ capabilities the extension can extend. The daemon picks the compact format
29
+ from that client name.
30
+ - **Readiness.** Pi reports neither a connection nor a failure to extensions. The
31
+ first turn waits briefly beyond Pi's own wait for an `mcp__gortex__` tool to
32
+ appear, and a binary that does not resolve is caught before registering,
33
+ because it would otherwise read as a server still connecting.
34
+ - **Version skew.** Pi keeps the handshake to itself, so the daemon version is
35
+ read from the readiness line of the session briefing and checked against the
36
+ same floor.
37
+ - **Precedence.** A `gortex` entry in Pi's own `mcp.json` replaces the
38
+ extension's registration, exposure included.
39
+
40
+ The extension keeps its own client below when Pi lacks `registerMcpServer`, when
41
+ the binary does not resolve (its spawn failure is what reaches the model), when
42
+ Pi refuses the registration (the user is warned with Pi's reason), and when
43
+ `native_mcp` is off. It also switches to it on session start when no loaded
44
+ extension connects MCP servers, as with `--no-mcp`: it withdraws the
45
+ registration then. Pi's MCP extension and the extensions that replace it
46
+ register the `/mcp` command, which is what the check looks for.
47
+
48
+ ## Channel 1 on older Pi: the extension's own MCP client
13
49
 
14
50
  One Gortex MCP child process per session, spoken to over newline-delimited
15
51
  JSON-RPC on stdio. Every tool the daemon offers becomes a native Pi tool whose
16
52
  implementation forwards the call down that channel.
17
53
 
18
- - **Handshake.** The client identifies itself as Pi and advertises which compact
19
- wire formats it can decode, so list-shaped results arrive compact without the
20
- daemon needing a per-client allowlist entry. One retry after a short backoff
54
+ - **Handshake.** The client identifies itself as Pi, which is the name the
55
+ daemon picks the compact wire format from. One retry after a short backoff
21
56
  absorbs a daemon that is still warming up.
22
57
  - **Version skew.** The handshake reply carries `serverInfo.version` and the
23
58
  protocol the daemon settled on. A daemon below the supported floor, or one
@@ -53,6 +88,21 @@ built-ins Pi may grow later as well as today's collisions. Each aliased tool
53
88
  front-loads the rename into its own description, because Gortex's guidance and
54
89
  denial messages name the bare tool and the model has to map it.
55
90
 
91
+ ### Edit diffs
92
+
93
+ The daemon reports an applied edit without its content, so the bridge reads the
94
+ files a write names before and after the call and stores their diffs in the
95
+ result's `details`, shaped like Pi's own edit details (see `src/diff.ts`). The
96
+ model still reads only Gortex's reply.
97
+
98
+ On Pi's built-in MCP the reads ride the `tool_call` and `tool_result` events.
99
+ Pi prepares a whole parallel batch before running it, so no lock can span a
100
+ call; a file two calls in flight write gets no diff instead.
101
+
102
+ A wrong diff is worse than none, so where one could mislead or stall Pi (a
103
+ shell command running alongside, or a change a line diff cannot show cheaply) a
104
+ one-line note stands in for it.
105
+
56
106
  ## Channel 2: read discipline over the hook bridge
57
107
 
58
108
  Every tool call Pi is about to run is forwarded to Gortex's hook with a
@@ -64,16 +114,24 @@ own way, while Gortex's classifier switches on canonical tool names and input
64
114
  keys. That translation lives on this side of the bridge, so the Go side keeps
65
115
  one vocabulary for every agent it serves.
66
116
 
117
+ A call to a Gortex tool is flagged as a graph call and sent under the daemon's
118
+ own tool name, which is what the postures key off. It skips the translation, so
119
+ Gortex's `read` never passes for Pi's. On Pi's built-in MCP the same hook also
120
+ sees each tool a codemode script calls.
121
+
67
122
  Postures are resolved by Gortex. Keeping that decision there is what keeps the
68
123
  behaviour identical across hosts.
69
124
 
70
125
  ## Orientation injection
71
126
 
72
- The session briefing rides a tail user message appended during context assembly.
73
- Carrying it in the system prompt would place it at the head of the conversation,
74
- where a change invalidates prefix prompt caching on every turn. The text is
75
- computed once per session and cleared only once it has been appended, so a
76
- context shape the extension cannot append to does not silently drop it.
127
+ The session briefing is stored once in the session history as a hidden message,
128
+ so every LLM call carries it. It stays out of the system prompt, because a
129
+ change there invalidates prefix prompt caching.
130
+
131
+ ## Withdrawn tools
132
+
133
+ Pi cannot unregister a tool, so one the daemon withdraws leaves Pi's active tool
134
+ set instead.
77
135
 
78
136
  ## The readiness barrier
79
137
 
@@ -98,9 +156,13 @@ matter:
98
156
  If the cap expires first, that turn is told the tools may not be callable yet
99
157
  and pointed at a reload. Exactly one turn reports it.
100
158
 
159
+ On Pi's built-in MCP the session hook registers nothing, and the turn waits for
160
+ the server's tools instead (see Channel 1 on Pi 0.99+).
161
+
101
162
  ## Session state
102
163
 
103
- The live bridge, the last bridge error and the registered tool names live at
164
+ This applies to the extension's own client; on Pi's built-in MCP, Pi owns the
165
+ child. The live bridge, the last bridge error and the registered tool names live at
104
166
  module scope, and that is load bearing. A reload re-imports the module, so the
105
167
  state resets with it. A new session, a resume and a fork re-invoke the extension
106
168
  factory against the cached module, and the new invocation stops the previous
@@ -110,6 +172,12 @@ invocation that overlaps it stops that bridge instead of leaving the child
110
172
  behind. State held per instance would leak a Gortex child on every new
111
173
  session.
112
174
 
175
+ Process exit has no next invocation, so the extension also stops the bridge
176
+ from Pi's `session_shutdown` event, which fires on quit and on every
177
+ replacement path. The child is spawned unref'd, pipes included: they must
178
+ never be what holds the host's event loop open, or a print or JSON run that
179
+ finished its work hangs at exit instead of draining.
180
+
113
181
  ## Failure posture
114
182
 
115
183
  Fail open, everywhere. A missing binary, a daemon that is down, a handshake that
@@ -124,3 +192,7 @@ Every value resolves at runtime, layered from the environment down through a
124
192
  project file and a global one to defaults. See the table in the
125
193
  [README](../README.md#configure). Runtime resolution is what lets one artifact
126
194
  serve both the npm package and a directory install.
195
+
196
+ The project file sits in whatever repository Pi was opened in, so it cannot
197
+ set `bin`. Otherwise a cloned repository could choose the executable the
198
+ extension spawns. The binary comes from `GORTEX_BIN`, the global file or `PATH`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gortex",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Gortex for Pi: high-performance code-intelligence engine for AI agents, based on graph. Exposes only the information the agent needs, cutting token usage up to 50x. 100% local.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -35,9 +35,10 @@
35
35
  "prepublishOnly": "npm run check"
36
36
  },
37
37
  "devDependencies": {
38
- "@earendil-works/pi-coding-agent": "~0.87.0",
39
- "@earendil-works/pi-tui": "^0.87.0",
40
- "@types/node": "^26.4.1",
38
+ "@earendil-works/pi-coding-agent": "1.0.2",
39
+ "@earendil-works/pi-tui": "1.0.2",
40
+ "@types/node": "^24.19.1",
41
+ "jiti": "^2.7.0",
41
42
  "typescript": "^7.0.2"
42
43
  },
43
44
  "peerDependencies": {
package/src/config.ts CHANGED
@@ -11,7 +11,7 @@ import { join } from "node:path";
11
11
 
12
12
  /** The sidecar file's on-disk shape. `gortex init` is its writer. */
13
13
  export interface SidecarConfig {
14
- /** Absolute path to the gortex binary. Default: resolved from PATH. */
14
+ /** Absolute path to the gortex binary. Global sidecar only. Default: resolved from PATH. */
15
15
  bin?: string;
16
16
  /** Hook posture: deny | enrich | consult-unlock | nudge. */
17
17
  hook_mode?: string;
@@ -19,6 +19,8 @@ export interface SidecarConfig {
19
19
  enforce?: boolean;
20
20
  /** Eager MCP tool preset: core | full | edit | nav | readonly. */
21
21
  tools_preset?: string;
22
+ /** false keeps the extension's own MCP client on a Pi that has built-in MCP. */
23
+ native_mcp?: boolean;
22
24
  }
23
25
 
24
26
  /** Everything the extension needs to run, fully resolved. */
@@ -31,6 +33,8 @@ export interface GortexConfig {
31
33
  enforce: boolean;
32
34
  /** Eager tool preset; the daemon's default applies when this is core/full. */
33
35
  toolsPreset: string;
36
+ /** Whether to hand the tool channel to Pi's built-in MCP when Pi has one. */
37
+ nativeMcp: boolean;
34
38
  }
35
39
 
36
40
  /** Seams for tests; every one defaults to the real environment. */
@@ -137,8 +141,9 @@ export function resolveConfig(opts: ResolveConfigOptions = {}): GortexConfig {
137
141
 
138
142
  const [project, global] = sidecarPaths(cwd, agentDir).map((path) => readSidecar(path, readFile));
139
143
 
140
- // `gortex` unqualified is resolved by spawn against PATH.
141
- const bin = firstString(env.GORTEX_BIN, project?.bin, global?.bin) ?? DEFAULT_BIN;
144
+ // A checked-out repository must never pick the executable, so the project
145
+ // sidecar has no say in `bin`. `gortex` unqualified is resolved against PATH.
146
+ const bin = firstString(env.GORTEX_BIN, global?.bin) ?? DEFAULT_BIN;
142
147
 
143
148
  const mode = normalizeMode(firstString(env.GORTEX_HOOK_MODE, project?.hook_mode, global?.hook_mode));
144
149
  const hookArgv = [bin, "hook", "--agent=pi"];
@@ -154,5 +159,11 @@ export function resolveConfig(opts: ResolveConfigOptions = {}): GortexConfig {
154
159
  const toolsPreset =
155
160
  firstString(env.GORTEX_TOOLS, project?.tools_preset, global?.tools_preset) ?? DEFAULT_TOOLS_PRESET;
156
161
 
157
- return { bin, hookArgv, enforce, toolsPreset };
162
+ const nativeMcp =
163
+ parseBoolean(env.GORTEX_NATIVE_MCP) ??
164
+ (typeof project?.native_mcp === "boolean" ? project.native_mcp : undefined) ??
165
+ (typeof global?.native_mcp === "boolean" ? global.native_mcp : undefined) ??
166
+ true;
167
+
168
+ return { bin, hookArgv, enforce, toolsPreset, nativeMcp };
158
169
  }
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
+ }