pi-gortex 0.3.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,7 +62,7 @@ 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. |
@@ -88,6 +88,21 @@ built-ins Pi may grow later as well as today's collisions. Each aliased tool
88
88
  front-loads the rename into its own description, because Gortex's guidance and
89
89
  denial messages name the bare tool and the model has to map it.
90
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
+
91
106
  ## Channel 2: read discipline over the hook bridge
92
107
 
93
108
  Every tool call Pi is about to run is forwarded to Gortex's hook with a
@@ -109,11 +124,14 @@ behaviour identical across hosts.
109
124
 
110
125
  ## Orientation injection
111
126
 
112
- The session briefing rides a tail user message appended during context assembly.
113
- Carrying it in the system prompt would place it at the head of the conversation,
114
- where a change invalidates prefix prompt caching on every turn. The text is
115
- computed once per session and cleared only once it has been appended, so a
116
- 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.
117
135
 
118
136
  ## The readiness barrier
119
137
 
@@ -174,3 +192,7 @@ Every value resolves at runtime, layered from the environment down through a
174
192
  project file and a global one to defaults. See the table in the
175
193
  [README](../README.md#configure). Runtime resolution is what lets one artifact
176
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.3.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,8 +35,8 @@
35
35
  "prepublishOnly": "npm run check"
36
36
  },
37
37
  "devDependencies": {
38
- "@earendil-works/pi-coding-agent": "~1.0.2",
39
- "@earendil-works/pi-tui": "^1.0.2",
38
+ "@earendil-works/pi-coding-agent": "1.0.2",
39
+ "@earendil-works/pi-tui": "1.0.2",
40
40
  "@types/node": "^24.19.1",
41
41
  "jiti": "^2.7.0",
42
42
  "typescript": "^7.0.2"
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;
@@ -141,8 +141,9 @@ export function resolveConfig(opts: ResolveConfigOptions = {}): GortexConfig {
141
141
 
142
142
  const [project, global] = sidecarPaths(cwd, agentDir).map((path) => readSidecar(path, readFile));
143
143
 
144
- // `gortex` unqualified is resolved by spawn against PATH.
145
- 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;
146
147
 
147
148
  const mode = normalizeMode(firstString(env.GORTEX_HOOK_MODE, project?.hook_mode, global?.hook_mode));
148
149
  const hookArgv = [bin, "hook", "--agent=pi"];
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/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // hook bridge. See docs/architecture.md for the design.
4
4
 
5
5
  import type {
6
- ContextEvent,
6
+ BeforeAgentStartEventResult,
7
7
  ExtensionAPI,
8
8
  ExtensionContext,
9
9
  ToolCallEvent,
@@ -12,6 +12,7 @@ import type {
12
12
 
13
13
  import { resolveConfig } from "./config.ts";
14
14
  import type { GortexConfig } from "./config.ts";
15
+ import { watchNativeEdits } from "./diff.ts";
15
16
  import { callHook, normalizeToolCall } from "./hook.ts";
16
17
  import {
17
18
  MCPStdioClient,
@@ -32,6 +33,7 @@ import {
32
33
  supportsNativeMcp,
33
34
  waitForNativeTools,
34
35
  } from "./native.ts";
36
+ import { nativeRenderers } from "./render.ts";
35
37
  import { nodeProcessDeps } from "./runtime.ts";
36
38
  import type { ProcessDeps } from "./runtime.ts";
37
39
  import {
@@ -47,6 +49,7 @@ import {
47
49
  setBridgeError,
48
50
  setClient,
49
51
  setVersionWarning,
52
+ withdrawnToolNames,
50
53
  } from "./state.ts";
51
54
 
52
55
  export type { GortexConfig, SidecarConfig } from "./config.ts";
@@ -109,6 +112,37 @@ function notifyUser(ctx: ExtensionContext | undefined, message: string): void {
109
112
  }
110
113
  }
111
114
 
115
+ /** Marks the messages this extension stores in the session. */
116
+ const GORTEX_MESSAGE_TYPE = "gortex";
117
+
118
+ interface GortexMessageDetails {
119
+ /** Whether the content includes the session briefing, not only warnings. */
120
+ briefing: boolean;
121
+ }
122
+
123
+ /** Kept out of the transcript; Pi still sends it to the model. */
124
+ function gortexMessage(content: string, briefing: boolean): BeforeAgentStartEventResult {
125
+ const details: GortexMessageDetails = { briefing };
126
+ return { message: { customType: GORTEX_MESSAGE_TYPE, content, display: false, details } };
127
+ }
128
+
129
+ /**
130
+ * Whether a briefing already sits in what the model sees: after a resume or
131
+ * /reload, or when compaction kept it. Answers false when Pi can't tell.
132
+ */
133
+ function briefingInContext(ctx: ExtensionContext | undefined): boolean {
134
+ try {
135
+ return (ctx?.sessionManager.buildContextEntries() ?? []).some(
136
+ (entry) =>
137
+ entry.type === "custom_message" &&
138
+ entry.customType === GORTEX_MESSAGE_TYPE &&
139
+ (entry.details as GortexMessageDetails | undefined)?.briefing === true,
140
+ );
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
112
146
  /** Test seams. Pi itself calls the factory with the `pi` object alone. */
113
147
  export interface GortexExtensionOptions {
114
148
  /** Pre-resolved configuration; resolveConfig() runs when this is absent. */
@@ -145,10 +179,10 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
145
179
  // Latched for the whole session once the first turn has run the
146
180
  // session_start hook, whether or not it produced anything to inject.
147
181
  let startupHookRan = false;
148
- // Orientation awaiting injection into the next LLM call, computed once per
149
- // session. The `context` hook appends it as a tail user message, because a
150
- // systemPrompt change sits at messages[0] and invalidates prefix caching.
151
- let pendingOrientation = "";
182
+ // This session's briefing, kept so a compaction that summarises it away can
183
+ // send it again on the next turn.
184
+ let briefing = "";
185
+ let briefingCompacted = false;
152
186
 
153
187
  // Settles when the current session_start handler is done: bridge up and
154
188
  // tools registered, or the handshake failed. Never rejects.
@@ -198,7 +232,8 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
198
232
  // session's bridge child (if any) is stopped before a fresh handshake.
199
233
  pi.on("session_start", async (_event, ctx) => {
200
234
  startupHookRan = false;
201
- pendingOrientation = "";
235
+ briefing = "";
236
+ briefingCompacted = false;
202
237
  setBridgeError("");
203
238
  setVersionWarning("");
204
239
  armSessionReady(); // no-op when a turn is already parked on this session
@@ -232,6 +267,7 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
232
267
  if (nativeRefusal) notifyUser(ctx, `Pi refused the gortex MCP server (${nativeRefusal}), so the extension runs its own client.`);
233
268
  ensureDaemon(config.bin, deps);
234
269
  gortexToolNames.clear();
270
+ withdrawnToolNames.clear();
235
271
  stopBridge();
236
272
  const c = new MCPStdioClient({ bin: config.bin, toolsPreset: config.toolsPreset, deps });
237
273
  // Claim the slot before the first await: an overlapping
@@ -258,10 +294,20 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
258
294
  }
259
295
  }
260
296
 
261
- // Fires before the agent loop's first LLM call. It can't mutate messages
262
- // itself, so it just parks the orientation for the `context` hook.
263
- pi.on("before_agent_start", async (_event, ctx) => {
264
- if (startupHookRan) return;
297
+ pi.on("session_compact", () => {
298
+ briefingCompacted = true;
299
+ });
300
+
301
+ // Fires before the agent loop's first LLM call. The returned message is
302
+ // stored in the session after the prompt, so every later LLM call carries it
303
+ // and the system prompt, which prefix caching keys on, stays untouched.
304
+ pi.on("before_agent_start", async (_event, ctx): Promise<BeforeAgentStartEventResult | void> => {
305
+ if (startupHookRan) {
306
+ if (!briefingCompacted) return;
307
+ briefingCompacted = false;
308
+ if (!briefing || briefingInContext(ctx)) return;
309
+ return gortexMessage(briefing, true);
310
+ }
265
311
  startupHookRan = true; // before the first await, so a second turn can't race in
266
312
  // Pi awaits each listener, so this holds the turn until the tools are
267
313
  // registered and bridgeError reflects the handshake (or the cap expires).
@@ -300,30 +346,15 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
300
346
  parts.push(versionWarning);
301
347
  setVersionWarning("");
302
348
  }
303
- if (decision.orientation) parts.push(decision.orientation);
304
- if (parts.length > 0) pendingOrientation = parts.join("\n\n");
305
- return;
306
- });
307
-
308
- // Fires before each LLM call with a mutable message array. Appends the
309
- // parked orientation once, clearing it only after the push lands so the
310
- // orientation survives a context shape this hook can't append to.
311
- // Pi exports ContextEvent but not its result type, so it is spelled out here.
312
- pi.on("context", (event: ContextEvent): { messages?: ContextEvent["messages"] } | void => {
313
- if (!pendingOrientation) return;
314
- try {
315
- const messages = (event as { messages?: unknown })?.messages;
316
- if (Array.isArray(messages)) {
317
- // Pi's AgentMessage also carries a `timestamp`; only the two fields
318
- // Pi reads for a tail user turn are pushed.
319
- messages.push({ role: "user", content: pendingOrientation } as ContextEvent["messages"][number]);
320
- pendingOrientation = "";
321
- return { messages: messages as ContextEvent["messages"] };
322
- }
323
- } catch {
324
- // best effort; never break context assembly.
349
+ let briefed = false;
350
+ if (decision.orientation) {
351
+ briefing = decision.orientation;
352
+ // A resumed or reloaded session already holds it.
353
+ briefed = !briefingInContext(ctx);
354
+ if (briefed) parts.push(decision.orientation);
325
355
  }
326
- return;
356
+ if (parts.length === 0) return;
357
+ return gortexMessage(parts.join("\n\n"), briefed);
327
358
  });
328
359
 
329
360
  // Never let the bridge child outlive the session.
@@ -331,10 +362,8 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
331
362
  stopBridge();
332
363
  });
333
364
 
334
- if (!config.enforce) return;
335
-
336
365
  // Enforcement: every non-Gortex tool call is checked against the Go hook.
337
- pi.on("tool_call", async (event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | void> => {
366
+ const enforceReadDiscipline = async (event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | void> => {
338
367
  const piName: string = (event as { toolName?: string })?.toolName ?? "";
339
368
  const piInput: Record<string, unknown> = (event?.input as Record<string, unknown>) ?? {};
340
369
  const isGortexTool = native ? isNativeGortexTool(piName) : gortexToolNames.has(piName);
@@ -367,5 +396,18 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
367
396
  }
368
397
  }
369
398
  return;
370
- });
399
+ };
400
+ if (config.enforce) pi.on("tool_call", enforceReadDiscipline);
401
+
402
+ // After enforcement, so a blocked call is never snapshotted. Shell calls are
403
+ // tracked on both channels; edits are snapshotted on Pi's built-in MCP only.
404
+ watchNativeEdits(pi, () => native);
405
+ if (native) {
406
+ // Pi < 1.0.1 has no tool renderers; the diffs are still stored.
407
+ try {
408
+ (pi as Partial<Pick<ExtensionAPI, "registerToolRenderer">>).registerToolRenderer?.(nativeRenderers);
409
+ } catch {
410
+ // drawing diffs is optional
411
+ }
412
+ }
371
413
  }
package/src/render.ts CHANGED
@@ -1,18 +1,25 @@
1
1
  // TUI rendering for bridged tools: a call line read off Gortex's argument
2
2
  // shape (operation, target, options), Pi's own read renderer for a file read,
3
- // and a result collapsed until the user expands it.
3
+ // an edit's diff, and a result collapsed until the user expands it.
4
4
 
5
- import { createReadToolDefinition } from "@earendil-works/pi-coding-agent";
6
- import type { Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
7
- import { Text, truncateToWidth } from "@earendil-works/pi-tui";
5
+ import { createReadToolDefinition, renderDiff } from "@earendil-works/pi-coding-agent";
6
+ import type { Theme, ToolDefinition, ToolRenderers } from "@earendil-works/pi-coding-agent";
7
+ import { Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
8
8
  import type { Component } from "@earendil-works/pi-tui";
9
9
 
10
+ import type { EditDetails } from "./diff.ts";
10
11
  import { textFromResult } from "./mcp-client.ts";
12
+ import { isNativeGortexTool } from "./native.ts";
11
13
 
12
14
  type ToolRenderContext = Parameters<NonNullable<ToolDefinition["renderCall"]>>[2];
13
15
 
14
16
  export type RenderCall = (args: Record<string, unknown>, theme: Theme, context: ToolRenderContext) => Component;
15
- export type RenderResult = (result: unknown, options: { expanded?: boolean }) => Component;
17
+ export type RenderResult = (
18
+ result: unknown,
19
+ options: { expanded?: boolean },
20
+ theme?: Theme,
21
+ context?: ToolRenderContext,
22
+ ) => Component;
16
23
 
17
24
  const OPERATION_ARGS = ["operation", "kind"];
18
25
  const SUBJECT_ARGS = ["target", "query", "question", "task", "path"];
@@ -144,6 +151,56 @@ export function resultText(result: unknown): string {
144
151
  return textFromResult(result) || JSON.stringify(structured ?? result ?? {});
145
152
  }
146
153
 
147
- /** renderResult collapses the result to nothing until the user expands it with ctrl+o. */
148
- export const renderResult: RenderResult = (result, options) =>
149
- new Text(options?.expanded ? resultText(result) : "", 0, 0);
154
+ /** diffView draws each file an edit changed with Pi's diff renderer, or nothing when the result has none. */
155
+ function diffView(result: unknown, theme: Theme | undefined, context: ToolRenderContext | undefined): Container | undefined {
156
+ const diffs = (result as { details?: EditDetails })?.details?.diffs;
157
+ if (!diffs?.length || !theme || context?.isError) return undefined;
158
+ const view = new Container();
159
+ for (const { path, diff, note } of diffs) {
160
+ view.addChild(new Spacer(1));
161
+ if (note) {
162
+ view.addChild(new Text(`${theme.fg("accent", path)} ${theme.fg("muted", note)}`, 1, 0));
163
+ continue;
164
+ }
165
+ if (diffs.length > 1) view.addChild(new Text(theme.fg("accent", path), 1, 0));
166
+ view.addChild(new Text(renderDiff(diff, { filePath: path }), 1, 0));
167
+ }
168
+ return view;
169
+ }
170
+
171
+ /**
172
+ * renderResult shows the diff of every file an edit changed, the way Pi's own
173
+ * edit does. Gortex's text reply stays collapsed until the user expands it
174
+ * with ctrl+o.
175
+ */
176
+ export const renderResult: RenderResult = (result, options, theme, context) => {
177
+ const text = new Text(options?.expanded ? resultText(result) : "", 0, 0);
178
+ const view = diffView(result, theme, context);
179
+ if (!view) return text;
180
+ if (options?.expanded) view.addChild(text);
181
+ return view;
182
+ };
183
+
184
+ /**
185
+ * nativeRenderers draws the diffs watchNativeEdits stores above Pi's own MCP
186
+ * result, which shows once expanded or on an error. Every other tool keeps its
187
+ * renderers.
188
+ */
189
+ export function nativeRenderers(toolName: string, next: () => ToolRenderers | undefined): ToolRenderers | undefined {
190
+ const base = next();
191
+ // Without a base, Pi's MCP extension fills in its own renderers.
192
+ if (!base || !isNativeGortexTool(toolName)) return base;
193
+ const baseResult = base.renderResult;
194
+ return {
195
+ ...base,
196
+ renderResult(result, options, theme, context) {
197
+ // Pi's renderer reuses lastComponent as its own and clears it.
198
+ const own = () => baseResult?.(result, options, theme, { ...context, lastComponent: undefined });
199
+ const rest = options.expanded || context.isError ? own() : undefined;
200
+ const view = diffView(result, theme, context);
201
+ if (!view) return rest ?? new Text("", 0, 0);
202
+ if (rest) view.addChild(rest);
203
+ return view;
204
+ },
205
+ };
206
+ }
package/src/state.ts CHANGED
@@ -33,6 +33,13 @@ let versionWarning = "";
33
33
  */
34
34
  export const gortexToolNames = new Set<string>();
35
35
 
36
+ /**
37
+ * Registered names the daemon has since dropped from its list. Pi cannot
38
+ * unregister a tool, so these are switched off in the active set instead, and
39
+ * back on if the daemon lists them again.
40
+ */
41
+ export const withdrawnToolNames = new Set<string>();
42
+
36
43
  export function getClient(): MCPStdioClient | null {
37
44
  return client;
38
45
  }
package/src/tools.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  // Tool registration: each tool the bridge lists becomes a native Pi tool whose
2
2
  // execute() forwards to tools/call.
3
3
 
4
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
6
+ import { candidatePaths, editDetails, isFileEdit, withFileDiffs } from "./diff.ts";
7
+ import type { FileDiff } from "./diff.ts";
5
8
 
6
9
  import type { ToolDescriptor } from "./mcp-client.ts";
7
10
  import { callRenderer, readCallRenderer, renderResult, resultText } from "./render.ts";
8
11
  import type { RenderCall, RenderResult } from "./render.ts";
9
- import { getClient, gortexToolNames } from "./state.ts";
12
+ import { getClient, gortexToolNames, withdrawnToolNames } from "./state.ts";
10
13
  import { toolNameMap } from "./hook.ts";
11
14
 
12
15
  // Pi's reserved built-in names (mirrors toolNameMap's keys). A bridge tool that
@@ -44,7 +47,13 @@ interface BridgeToolDefinition {
44
47
  label: string;
45
48
  description: string;
46
49
  parameters: unknown;
47
- execute(toolCallId: string, params: Record<string, unknown>): Promise<unknown>;
50
+ execute(
51
+ toolCallId: string,
52
+ params: Record<string, unknown>,
53
+ signal: AbortSignal | undefined,
54
+ onUpdate: unknown,
55
+ ctx: ExtensionContext | undefined,
56
+ ): Promise<unknown>;
48
57
  renderCall: RenderCall;
49
58
  renderResult: RenderResult;
50
59
  }
@@ -86,12 +95,20 @@ export function registerOneTool(pi: ExtensionAPI, desc: ToolDescriptor): void {
86
95
  label: name,
87
96
  description: piAliasedDescription(name, piAliasName(name), (desc.description || name).trim()),
88
97
  parameters,
89
- async execute(_id: string, params: Record<string, unknown>) {
98
+ async execute(_id, params, _signal, _onUpdate, ctx) {
90
99
  const client = getClient();
91
100
  if (!client) throw new Error(`gortex ${name}: MCP bridge is not connected`);
101
+ const args = params ?? {};
102
+ const callTool = () => client.callTool(name, args);
92
103
  let result: unknown;
104
+ let diffs: FileDiff[] = [];
93
105
  try {
94
- result = await client.callTool(name, params ?? {});
106
+ if (ctx && isFileEdit(name, args)) {
107
+ const paths = candidatePaths(args, ctx.cwd);
108
+ ({ result, diffs } = await withFileDiffs(paths, ctx.cwd, callTool, (r) => !(r as { isError?: boolean })?.isError));
109
+ } else {
110
+ result = await callTool();
111
+ }
95
112
  } catch (err) {
96
113
  throw new Error(`gortex ${name} failed: ${err instanceof Error ? err.message : String(err)}`);
97
114
  }
@@ -103,7 +120,7 @@ export function registerOneTool(pi: ExtensionAPI, desc: ToolDescriptor): void {
103
120
  }
104
121
  const text = resultText(result);
105
122
  if ((result as { isError?: boolean })?.isError) throw new Error(text || `gortex ${name} failed`);
106
- return { content: [{ type: "text", text }], details: {} };
123
+ return { content: [{ type: "text", text }], details: editDetails(diffs) };
107
124
  },
108
125
  renderCall: (name === "read" ? readCallRenderer : callRenderer)(piAliasName(name)),
109
126
  renderResult,
@@ -115,13 +132,36 @@ export function registerOneTool(pi: ExtensionAPI, desc: ToolDescriptor): void {
115
132
  export async function registerGortexTools(pi: ExtensionAPI): Promise<void> {
116
133
  const client = getClient();
117
134
  if (!client) return;
135
+ const listed = new Set<string>();
118
136
  for (const desc of await client.listTools()) {
119
- if (!desc) continue;
137
+ if (!desc?.name) continue;
120
138
  registerOneTool(pi, desc);
139
+ listed.add(piAliasName(desc.name));
140
+ }
141
+ matchActiveTools(pi, listed);
142
+ }
143
+
144
+ /** Keeps the tools offered to the model in step with the daemon's list. */
145
+ function matchActiveTools(pi: ExtensionAPI, listed: Set<string>): void {
146
+ const dropped = [...gortexToolNames].filter((name) => !listed.has(name));
147
+ const relisted = [...withdrawnToolNames].filter((name) => listed.has(name));
148
+ if (dropped.length === 0 && relisted.length === 0) return;
149
+ for (const name of dropped) {
150
+ gortexToolNames.delete(name);
151
+ withdrawnToolNames.add(name);
152
+ }
153
+ for (const name of relisted) withdrawnToolNames.delete(name);
154
+ try {
155
+ const active = new Set(pi.getActiveTools());
156
+ for (const name of dropped) active.delete(name);
157
+ for (const name of relisted) active.add(name);
158
+ pi.setActiveTools([...active]);
159
+ } catch {
160
+ // an invalidated instance keeps its last tool set.
121
161
  }
122
162
  }
123
163
 
124
- /** Re-fetches the tool list after a list_changed and registers anything new. */
164
+ /** Re-fetches the tool list after a list_changed and applies the difference. */
125
165
  export async function syncTools(pi: ExtensionAPI): Promise<void> {
126
166
  try {
127
167
  await registerGortexTools(pi);