pi-gortex 0.4.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/README.md CHANGED
@@ -13,13 +13,16 @@ need instead of reading whole files around it, which is where the token
13
13
  savings come from. It is a single static binary with no dependency chain,
14
14
  and it covers 257 languages through tree-sitter.
15
15
 
16
- Pi has no MCP support by design, so this extension is the bridge.
16
+ This extension connects the two. It hands `gortex mcp` to Pi's built-in MCP,
17
+ or runs its own MCP client with `native_mcp` off. Read discipline goes through
18
+ `gortex hook` on both.
17
19
 
18
20
  ## What it does
19
21
 
20
- - **Registers Gortex's graph tools as native Pi tools**, over a persistent
21
- `gortex mcp` child per session. The deferred catalogue is reached through the
22
- daemon's own `tools_search`, and promoted tools appear in Pi's registry.
22
+ - **Registers Gortex's graph tools as native Pi tools**, through Pi's
23
+ built-in MCP or over the extension's own `gortex mcp` child per session. The
24
+ deferred catalogue is reached through the daemon's own `tools_search`, and
25
+ promoted tools appear in Pi's registry.
23
26
  - **Applies read discipline.** Pi's tool calls are forwarded to
24
27
  `gortex hook --agent=pi`, which answers block / soft guidance / nothing
25
28
  according to the posture you configured. Gortex owns that logic, so the
@@ -31,7 +34,7 @@ See [docs/architecture.md](docs/architecture.md) for how it works.
31
34
 
32
35
  ## Requirements
33
36
 
34
- - **Node 24+**, **Pi 0.85+**
37
+ - **Node 24+**, **Pi 1.0+**
35
38
  - **The `gortex` binary** on `PATH`, **v0.61.2 or newer**. See
36
39
  [Gortex installation](https://github.com/zzet/gortex/blob/main/docs/installation.md).
37
40
  The extension starts the shared daemon itself and never blocks Pi's startup
@@ -57,16 +60,16 @@ the absolute path, or a single run with `pi -e /path/to/pi-gortex`.
57
60
  ## Configure
58
61
 
59
62
  Zero configuration required. Every value resolves at runtime, first hit wins:
60
- environment -> `<cwd>/.pi/gortex.json` -> `<agent dir>/extensions/gortex.json`
63
+ environment -> `<session cwd>/.pi/gortex.json` -> `<agent dir>/extensions/gortex.json`
61
64
  (agent dir is `$PI_CODING_AGENT_DIR`, else `~/.pi/agent`) -> defaults.
62
65
 
63
66
  | Sidecar key | Environment | Default | Meaning |
64
67
  |----------------|--------------------|--------------------|---------|
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
- | `hook_mode` | `GORTEX_HOOK_MODE` | `deny` | Posture: `deny`, `enrich`, `consult-unlock`, `nudge`. |
68
+ | `bin` | `GORTEX_BIN` | `gortex` on `PATH` | The gortex binary. Read from the global sidecar only; a project file cannot set it. `~` expands, and a relative path resolves against the sidecar's directory (the launch directory for `GORTEX_BIN`). |
69
+ | `hook_mode` | `GORTEX_HOOK_MODE` | `deny` | Posture: `deny`, `enrich`, `consult-unlock`, `nudge` (`adaptive-nudge` is an alias). |
67
70
  | `enforce` | `GORTEX_ENFORCE` | `true` | `false` keeps the graph tools and the briefing, and wires no enforcement. |
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. |
71
+ | `tools_preset` | `GORTEX_TOOLS` | daemon's default | Eager MCP surface: a Gortex preset (`core`, `full`, `agent`, `edit`, `nav`, `readonly`, `localization`), optionally followed by comma-separated tool names and `+tool`/`-tool` changes, e.g. `nav,+edit_file`. A value that does not open with a preset is ignored. |
72
+ | `native_mcp` | `GORTEX_NATIVE_MCP` | `true` | Register `gortex mcp` with Pi's built-in MCP. `false` keeps the extension's own client. |
70
73
 
71
74
  ```json
72
75
  {
@@ -76,8 +79,8 @@ environment -> `<cwd>/.pi/gortex.json` -> `<agent dir>/extensions/gortex.json`
76
79
  }
77
80
  ```
78
81
 
79
- The extension reads its configuration when Pi loads it, so a change takes
80
- effect after `/reload`.
82
+ The extension reads its configuration at every session start, from the
83
+ directory the session runs in, so a change takes effect on `/reload` or `/new`.
81
84
 
82
85
  **Pi's own `mcp.json` wins.** With `native_mcp` on, the extension registers a
83
86
  server named `gortex`. A `gortex` entry in `~/.pi/agent/mcp.json` or
@@ -68,15 +68,15 @@ implementation forwards the call down that channel.
68
68
  arrive in bursts, so the re-sync is debounced; a search call awaits its own
69
69
  re-sync directly, so every tool its reply names is callable by the time the
70
70
  model reads it.
71
- - **Framing.** Reads are incremental. The scan for a frame boundary resumes
72
- where the previous chunk ended, so one large frame split across many chunks
73
- does not re-scan a growing buffer each time. A frame past a generous size cap
74
- kills the child, because a stream cannot be resynced mid-frame, and the next
75
- call respawns it.
71
+ - **Framing.** Frames are cut at the newline byte and decoded whole, so a
72
+ character split across chunks arrives intact. A frame past a generous byte
73
+ cap kills the child, because a stream cannot be resynced mid-frame, and the
74
+ next call respawns it.
76
75
  - **Timeouts.** Every request is bounded: a short cap for ordinary requests, a
77
76
  longer one for the handshake, the longest for tool calls. The last is generous
78
77
  enough for analyzers that legitimately run for minutes, and finite so a wedged
79
- daemon cannot hang an agent turn forever.
78
+ daemon cannot hang an agent turn forever. A call dropped on timeout or on
79
+ Pi's abort tells the daemon to cancel it.
80
80
 
81
81
  ### Tool-name aliasing
82
82
 
@@ -115,7 +115,8 @@ keys. That translation lives on this side of the bridge, so the Go side keeps
115
115
  one vocabulary for every agent it serves.
116
116
 
117
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
118
+ own tool name, which is what the postures key off. On Pi's built-in MCP that
119
+ means the `mcp__gortex__` prefix is stripped. It skips the translation, so
119
120
  Gortex's `read` never passes for Pi's. On Pi's built-in MCP the same hook also
120
121
  sees each tool a codemode script calls.
121
122
 
@@ -193,6 +194,20 @@ project file and a global one to defaults. See the table in the
193
194
  [README](../README.md#configure). Runtime resolution is what lets one artifact
194
195
  serve both the npm package and a directory install.
195
196
 
196
- The project file sits in whatever repository Pi was opened in, so it cannot
197
+ Resolution runs on every `session_start`, against the session's own directory.
198
+ A resumed session runs where it was created, which need not be where Pi was
199
+ launched. So the native server is registered on `session_start` rather than
200
+ when Pi loads the extension, and again only when the session's preset or
201
+ channel differ, since Pi reconnects a re-registered server. Pi reads a
202
+ registration made before its own `session_start` handler and connects one made
203
+ after it. The extension's own `gortex mcp` is spawned in the session's
204
+ directory, which is what the proxy scopes the graph to.
205
+
206
+ A tool preset reaches `gortex mcp` only when it opens with a preset Gortex
207
+ knows. Gortex reads anything else as a bare allow-list, so a typo would strand
208
+ the session; it falls through to the next layer instead. The resolved value
209
+ always overrides an inherited `GORTEX_TOOLS`.
210
+
211
+ The project file sits in whatever repository the session runs in, so it cannot
197
212
  set `bin`. Otherwise a cloned repository could choose the executable the
198
213
  extension spawns. The binary comes from `GORTEX_BIN`, the global file or `PATH`.
package/docs/tests.md CHANGED
@@ -88,15 +88,15 @@ point in the directory when the manifest resolves to nothing.
88
88
  ## Where the double stops
89
89
 
90
90
  The double models what the suites need and no more. Behaviour past that edge,
91
- such as promotion re-sync under real notification traffic, frame splitting, the
92
- frame size cap, a real model turn or a live daemon, is unverified here. A change
91
+ such as promotion re-sync under real notification traffic, a real model turn or
92
+ a live daemon, is unverified here. A change
93
93
  in that territory is verified by hand against a running Gortex, and the suite
94
94
  says as much rather than implying coverage it does not have.
95
95
 
96
96
  ## Pinning
97
97
 
98
- The Pi devDependency is pinned tight on purpose. Pi is pre-1.0 and its runner's
99
- method surface has been renamed without a changelog entry before, so a tight pin
98
+ The Pi devDependency is pinned tight on purpose. Pi's runner's method surface
99
+ has been renamed without a changelog entry before, so a tight pin
100
100
  turns that into a red build rather than a silently skipped suite. The runtime
101
101
  peer dependency stays wide.
102
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gortex",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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",
package/src/config.ts CHANGED
@@ -7,17 +7,17 @@
7
7
 
8
8
  import { readFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
- import { join } from "node:path";
10
+ import { dirname, isAbsolute, join, resolve } 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. Global sidecar only. Default: resolved from PATH. */
14
+ /** The gortex binary. Global sidecar only; relative to the sidecar's directory. Default: resolved from PATH. */
15
15
  bin?: string;
16
16
  /** Hook posture: deny | enrich | consult-unlock | nudge. */
17
17
  hook_mode?: string;
18
18
  /** false mirrors `gortex init --no-hooks`: orientation only, no enforcement. */
19
19
  enforce?: boolean;
20
- /** Eager MCP tool preset: core | full | edit | nav | readonly. */
20
+ /** Eager MCP tool preset (core | full | agent | edit | nav | readonly | localization) or a tool spec. */
21
21
  tools_preset?: string;
22
22
  /** false keeps the extension's own MCP client on a Pi that has built-in MCP. */
23
23
  native_mcp?: boolean;
@@ -31,7 +31,7 @@ export interface GortexConfig {
31
31
  hookArgv: string[];
32
32
  /** Whether to wire read-discipline enforcement onto tool_call. */
33
33
  enforce: boolean;
34
- /** Eager tool preset; the daemon's default applies when this is core/full. */
34
+ /** GORTEX_TOOLS for `gortex mcp`; empty leaves the daemon's default surface. */
35
35
  toolsPreset: string;
36
36
  /** Whether to hand the tool channel to Pi's built-in MCP when Pi has one. */
37
37
  nativeMcp: boolean;
@@ -47,7 +47,15 @@ export interface ResolveConfigOptions {
47
47
  }
48
48
 
49
49
  export const DEFAULT_BIN = "gortex";
50
- export const DEFAULT_TOOLS_PRESET = "core";
50
+ /** No preset: `gortex mcp` serves the daemon's default surface. */
51
+ export const DEFAULT_TOOLS_PRESET = "";
52
+
53
+ /** Expands a leading `~`, `~/` or `~\` to the home directory. */
54
+ export function expandHome(path: string, home: string = homedir()): string {
55
+ if (path === "~") return home;
56
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
57
+ return path;
58
+ }
51
59
 
52
60
  /**
53
61
  * Pi's agent directory, resolved the way Pi itself resolves it
@@ -59,15 +67,46 @@ export const DEFAULT_TOOLS_PRESET = "core";
59
67
  */
60
68
  export function defaultAgentDir(env: Record<string, string | undefined> = process.env): string {
61
69
  const override = env.PI_CODING_AGENT_DIR;
62
- if (override && override.trim() !== "") {
63
- const path = override.trim();
64
- if (path === "~") return homedir();
65
- if (path.startsWith("~/")) return join(homedir(), path.slice(2));
66
- return path;
67
- }
70
+ if (override && override.trim() !== "") return expandHome(override.trim());
68
71
  return join(homedir(), ".pi", "agent");
69
72
  }
70
73
 
74
+ /**
75
+ * A `bin` setting as spawn needs it: `~` expanded, a relative path anchored at
76
+ * `baseDir`, a bare name left for PATH lookup.
77
+ */
78
+ export function resolveBin(bin: string, baseDir: string): string {
79
+ const expanded = expandHome(bin);
80
+ if (!/[\\/]/.test(expanded) || isAbsolute(expanded)) return expanded;
81
+ return resolve(baseDir, expanded);
82
+ }
83
+
84
+ // Preset names and aliases Gortex accepts (internal/mcp/tool_presets.go).
85
+ const KNOWN_PRESETS = new Set([
86
+ "full", "all",
87
+ "core", "default", "classic",
88
+ "agent", "coding-agent",
89
+ "facade-v1", "agent-v2", "compact", "facade",
90
+ "readonly", "read-only", "read_only",
91
+ "edit", "editor", "edit-harness",
92
+ "nav", "navigate", "explore",
93
+ "localization", "locate", "find",
94
+ ]);
95
+
96
+ /**
97
+ * A tools_preset / GORTEX_TOOLS value Gortex should see, or undefined. Gortex
98
+ * reads a spec that does not open with a preset as a bare allow-list, so a
99
+ * typo (or `+tool` alone) would strip the session down to a tool or two. Only
100
+ * a spec opening with a known preset passes, optionally followed by
101
+ * comma-separated tool names and `+tool`/`-tool` changes.
102
+ */
103
+ export function toolsSpec(value: string | undefined): string | undefined {
104
+ const tokens = (value ?? "").split(",").map((token) => token.trim()).filter(Boolean);
105
+ const preset = tokens[0]?.toLowerCase();
106
+ if (preset === undefined || !KNOWN_PRESETS.has(preset)) return undefined;
107
+ return [preset, ...tokens.slice(1)].join(",");
108
+ }
109
+
71
110
  /** Sidecar paths, nearest first. A project file wins over the global one. */
72
111
  export function sidecarPaths(cwd: string, agentDir: string): string[] {
73
112
  return [join(cwd, ".pi", "gortex.json"), join(agentDir, "extensions", "gortex.json")];
@@ -139,30 +178,41 @@ export function resolveConfig(opts: ResolveConfigOptions = {}): GortexConfig {
139
178
  const agentDir = opts.agentDir ?? defaultAgentDir(env);
140
179
  const readFile = opts.readFile ?? ((path: string) => readFileSync(path, "utf8"));
141
180
 
142
- const [project, global] = sidecarPaths(cwd, agentDir).map((path) => readSidecar(path, readFile));
181
+ const [projectPath, globalPath] = sidecarPaths(cwd, agentDir) as [string, string];
182
+ const project = readSidecar(projectPath, readFile);
183
+ const global = readSidecar(globalPath, readFile);
143
184
 
144
185
  // A checked-out repository must never pick the executable, so the project
145
186
  // sidecar has no say in `bin`. `gortex` unqualified is resolved against PATH.
146
- const bin = firstString(env.GORTEX_BIN, global?.bin) ?? DEFAULT_BIN;
147
-
148
- const mode = normalizeMode(firstString(env.GORTEX_HOOK_MODE, project?.hook_mode, global?.hook_mode));
187
+ const envBin = firstString(env.GORTEX_BIN);
188
+ const globalBin = firstString(global.bin);
189
+ const bin = envBin
190
+ ? resolveBin(envBin, process.cwd())
191
+ : globalBin
192
+ ? resolveBin(globalBin, dirname(globalPath))
193
+ : DEFAULT_BIN;
194
+
195
+ const mode = normalizeMode(firstString(env.GORTEX_HOOK_MODE, project.hook_mode, global.hook_mode));
149
196
  const hookArgv = [bin, "hook", "--agent=pi"];
150
197
  // "deny" is the hook's own default, so it rides as an absent flag.
151
198
  if (mode !== "deny") hookArgv.push(`--mode=${mode}`);
152
199
 
153
200
  const enforce =
154
201
  parseBoolean(env.GORTEX_ENFORCE) ??
155
- (typeof project?.enforce === "boolean" ? project.enforce : undefined) ??
156
- (typeof global?.enforce === "boolean" ? global.enforce : undefined) ??
202
+ (typeof project.enforce === "boolean" ? project.enforce : undefined) ??
203
+ (typeof global.enforce === "boolean" ? global.enforce : undefined) ??
157
204
  true;
158
205
 
159
206
  const toolsPreset =
160
- firstString(env.GORTEX_TOOLS, project?.tools_preset, global?.tools_preset) ?? DEFAULT_TOOLS_PRESET;
207
+ toolsSpec(env.GORTEX_TOOLS) ??
208
+ toolsSpec(firstString(project.tools_preset)) ??
209
+ toolsSpec(firstString(global.tools_preset)) ??
210
+ DEFAULT_TOOLS_PRESET;
161
211
 
162
212
  const nativeMcp =
163
213
  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) ??
214
+ (typeof project.native_mcp === "boolean" ? project.native_mcp : undefined) ??
215
+ (typeof global.native_mcp === "boolean" ? global.native_mcp : undefined) ??
166
216
  true;
167
217
 
168
218
  return { bin, hookArgv, enforce, toolsPreset, nativeMcp };
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",
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@ import type {
11
11
  } from "@earendil-works/pi-coding-agent";
12
12
 
13
13
  import { resolveConfig } from "./config.ts";
14
- import type { GortexConfig } from "./config.ts";
14
+ import type { GortexConfig, ResolveConfigOptions } from "./config.ts";
15
15
  import { watchNativeEdits } from "./diff.ts";
16
16
  import { callHook, normalizeToolCall } from "./hook.ts";
17
17
  import {
@@ -145,22 +145,29 @@ function briefingInContext(ctx: ExtensionContext | undefined): boolean {
145
145
 
146
146
  /** Test seams. Pi itself calls the factory with the `pi` object alone. */
147
147
  export interface GortexExtensionOptions {
148
- /** Pre-resolved configuration; resolveConfig() runs when this is absent. */
148
+ /** Fixed configuration; resolveConfig() runs per session when this is absent. */
149
149
  config?: GortexConfig;
150
+ /** resolveConfig() seams; `cwd` stands in for the process cwd until a session supplies one. */
151
+ resolve?: ResolveConfigOptions;
150
152
  /** Process seam for spawn/execFileSync. */
151
153
  deps?: ProcessDeps;
152
154
  /** Readiness cap override. */
153
155
  readyWaitMs?: number;
154
156
  /** Override for the wait on Pi's built-in MCP connecting the gortex server. */
155
157
  nativeReadyWaitMs?: number;
158
+ /** tools/call timeout override for the extension's own client. */
159
+ callTimeoutMs?: number;
160
+ /** Frame size cap override for the extension's own client. */
161
+ maxFrameBytes?: number;
156
162
  }
157
163
 
158
164
  export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensionOptions = {}): void {
159
- const config = options.config ?? resolveConfig();
160
- // Pi 0.85's ExtensionAPI declares no `cwd`, so the hook envelope reads it
161
- // defensively and falls back to the process cwd. A Pi version that grows the
162
- // field is picked up when it exists.
163
- const piCwd = (): string => (pi as { cwd?: string })?.cwd ?? process.cwd();
165
+ // Resolved again on every session_start from ctx.cwd, the directory the
166
+ // session runs in. The factory has no session, so this first resolution only
167
+ // serves a turn that outruns session_start.
168
+ const configFor = (cwd?: string): GortexConfig =>
169
+ options.config ?? resolveConfig({ ...options.resolve, cwd: cwd ?? options.resolve?.cwd });
170
+ let config = configFor();
164
171
  const deps = options.deps ?? nodeProcessDeps;
165
172
  const readyWaitMs = options.readyWaitMs ?? READY_WAIT_MS;
166
173
  const nativeReadyWaitMs = options.nativeReadyWaitMs ?? NATIVE_READY_WAIT_MS;
@@ -169,12 +176,27 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
169
176
  // (which starts the daemon itself), registers the tools and renders them.
170
177
  // A binary that does not resolve, or a registration Pi refuses, falls back to
171
178
  // the extension's own client, which reports its failure to the model.
179
+ // Registered on session_start only, once the session's config is known: Pi
180
+ // reads a registration made before its own session_start handler, and
181
+ // connects one made after it.
172
182
  let nativeRefusal = "";
173
- let native = ((): boolean => {
174
- if (!config.nativeMcp || !supportsNativeMcp(pi) || !binaryResolves(config.bin)) return false;
183
+ let native = false;
184
+ let registered: GortexConfig | null = null;
185
+ function registerNative(connector: boolean): void {
186
+ if (native) unregisterNativeServer(pi);
187
+ native = false;
188
+ registered = config;
189
+ nativeRefusal = "";
190
+ if (!connector || !config.nativeMcp || !supportsNativeMcp(pi) || !binaryResolves(config.bin)) return;
175
191
  nativeRefusal = registerNativeServer(pi, config);
176
- return nativeRefusal === "";
177
- })();
192
+ native = nativeRefusal === "";
193
+ }
194
+
195
+ // Re-registering an unchanged server would make Pi reconnect it.
196
+ const channelChanged = (): boolean =>
197
+ registered === null ||
198
+ registered.nativeMcp !== config.nativeMcp ||
199
+ registered.toolsPreset !== config.toolsPreset;
178
200
 
179
201
  // Latched for the whole session once the first turn has run the
180
202
  // session_start hook, whether or not it produced anything to inject.
@@ -259,7 +281,10 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
259
281
  // startSession holds the body of session_start so the readiness promise
260
282
  // above settles on every exit path, early returns included.
261
283
  async function startSession(ctx?: ExtensionContext): Promise<void> {
262
- if (native && !mcpConnectorLoaded(pi)) {
284
+ config = configFor(ctx?.cwd);
285
+ const connector = mcpConnectorLoaded(pi);
286
+ if (channelChanged()) registerNative(connector);
287
+ if (native && !connector) {
263
288
  unregisterNativeServer(pi);
264
289
  native = false;
265
290
  }
@@ -269,7 +294,14 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
269
294
  gortexToolNames.clear();
270
295
  withdrawnToolNames.clear();
271
296
  stopBridge();
272
- const c = new MCPStdioClient({ bin: config.bin, toolsPreset: config.toolsPreset, deps });
297
+ const c = new MCPStdioClient({
298
+ bin: config.bin,
299
+ toolsPreset: config.toolsPreset,
300
+ cwd: ctx?.cwd,
301
+ deps,
302
+ callTimeoutMs: options.callTimeoutMs,
303
+ maxFrameBytes: options.maxFrameBytes,
304
+ });
273
305
  // Claim the slot before the first await: an overlapping
274
306
  // session_start (rapid /new or /reload) then sees and stops THIS
275
307
  // bridge instead of leaking its child mid-handshake.
@@ -316,7 +348,7 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
316
348
  const ready = native ? await waitForNativeTools(pi, nativeReadyWaitMs) : sessionDone;
317
349
  const decision = callHook(config.hookArgv, deps, {
318
350
  event: "session_start",
319
- cwd: ctx?.cwd ?? piCwd(),
351
+ cwd: ctx?.cwd ?? process.cwd(),
320
352
  });
321
353
  // Pi's MCP keeps the handshake to itself, so the version comes from the briefing.
322
354
  if (native && decision.orientation) {
@@ -376,7 +408,7 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
376
408
  event: "tool_call",
377
409
  tool_name: norm.tool_name,
378
410
  tool_input: norm.tool_input,
379
- cwd: ctx?.cwd ?? piCwd(),
411
+ cwd: ctx?.cwd ?? process.cwd(),
380
412
  session_id: (ctx as { sessionManager?: { sessionId?: string } })?.sessionManager?.sessionId ?? "",
381
413
  is_gortex_tool: isGortexTool,
382
414
  });
@@ -397,12 +429,13 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
397
429
  }
398
430
  return;
399
431
  };
400
- if (config.enforce) pi.on("tool_call", enforceReadDiscipline);
432
+ // Registered unconditionally, since a session's own sidecar can turn enforcement on.
433
+ pi.on("tool_call", (event, ctx) => (config.enforce ? enforceReadDiscipline(event, ctx) : undefined));
401
434
 
402
435
  // After enforcement, so a blocked call is never snapshotted. Shell calls are
403
436
  // tracked on both channels; edits are snapshotted on Pi's built-in MCP only.
404
437
  watchNativeEdits(pi, () => native);
405
- if (native) {
438
+ if (supportsNativeMcp(pi)) {
406
439
  // Pi < 1.0.1 has no tool renderers; the diffs are still stored.
407
440
  try {
408
441
  (pi as Partial<Pick<ExtensionAPI, "registerToolRenderer">>).registerToolRenderer?.(nativeRenderers);
package/src/mcp-client.ts CHANGED
@@ -46,36 +46,30 @@ export function isBelowVersion(actual: string, floor: string): boolean {
46
46
  return false;
47
47
  }
48
48
 
49
- // Presets the proxy has to be told about. The daemon's own default surface
50
- // already IS core/defer, so those pass nothing. Anything unrecognised (a typo,
51
- // a stale sidecar value) is NOT forwarded: server-side it would parse as a
52
- // one-tool allow-list and silently filter every promoted tool out of the
53
- // session. Fail open to the daemon's default surface instead.
54
- const FORWARDED_PRESETS = new Set(["edit", "nav", "readonly"]);
55
-
56
49
  /**
57
- * The variables `gortex mcp` needs on top of `env` to serve `toolsPreset`.
58
- * The daemon's own default surface already IS core/defer; only a recognised
59
- * non-default preset needs the proxy-side narrowing.
50
+ * The variables `gortex mcp` needs on top of `env` to serve `toolsPreset`, the
51
+ * spec resolveConfig validated (it already folds in the user's GORTEX_TOOLS).
52
+ * Both are always set, so an inherited value resolveConfig rejected never
53
+ * reaches the child; empty leaves the daemon's default surface.
60
54
  */
61
55
  export function presetEnv(
62
56
  toolsPreset: string,
63
57
  env: Record<string, string | undefined>,
64
58
  ): Record<string, string> {
65
- const out: Record<string, string> = {};
66
- const preset = toolsPreset.trim().toLowerCase();
67
- const tools = env.GORTEX_TOOLS || (FORWARDED_PRESETS.has(preset) ? preset : "");
68
- if (tools && !env.GORTEX_TOOLS) out.GORTEX_TOOLS = tools;
59
+ if (toolsPreset === "") return { GORTEX_TOOLS: "", GORTEX_TOOLS_MODE: "" };
69
60
  // Never let a preset hide-block tools promoted later by tools_search.
70
- if (tools && !env.GORTEX_TOOLS_MODE) out.GORTEX_TOOLS_MODE = "defer";
71
- return out;
61
+ return { GORTEX_TOOLS: toolsPreset, GORTEX_TOOLS_MODE: env.GORTEX_TOOLS_MODE?.trim() || "defer" };
72
62
  }
73
63
 
74
64
  export interface MCPStdioClientOptions {
75
65
  bin: string;
76
66
  toolsPreset: string;
67
+ /** The session's directory, which `gortex mcp` scopes the graph to. Default: the process cwd. */
68
+ cwd?: string;
77
69
  deps: ProcessDeps;
78
70
  env?: Record<string, string | undefined>;
71
+ callTimeoutMs?: number;
72
+ maxFrameBytes?: number;
79
73
  }
80
74
 
81
75
  interface PendingRequest {
@@ -84,6 +78,25 @@ interface PendingRequest {
84
78
  timer?: ReturnType<typeof setTimeout>;
85
79
  }
86
80
 
81
+ /** A JSON-RPC error reply, with the server's code and data kept for the model. */
82
+ export class JsonRpcError extends Error {
83
+ readonly code: number | undefined;
84
+ readonly data: unknown;
85
+
86
+ constructor(error: { code?: unknown; message?: unknown; data?: unknown }) {
87
+ const code = typeof error.code === "number" ? error.code : undefined;
88
+ let message = typeof error.message === "string" && error.message ? error.message : "JSON-RPC error";
89
+ if (code !== undefined) message += ` (${code})`;
90
+ if (error.data !== undefined) {
91
+ message += `: ${typeof error.data === "string" ? error.data : JSON.stringify(error.data)}`;
92
+ }
93
+ super(message);
94
+ this.name = "JsonRpcError";
95
+ this.code = code;
96
+ this.data = error.data;
97
+ }
98
+ }
99
+
87
100
  export interface ToolDescriptor {
88
101
  name: string;
89
102
  description?: string;
@@ -102,6 +115,7 @@ export function ensureDaemon(bin: string, deps: ProcessDeps): void {
102
115
  const child = deps.spawn(bin, ["daemon", "start", "--detach"], {
103
116
  stdio: "ignore",
104
117
  detached: true,
118
+ windowsHide: true,
105
119
  });
106
120
  child.on("error", () => {}); // binary missing / spawn failure: swallow
107
121
  // No teardown counterpart, by design: the daemon is shared, long-lived
@@ -127,8 +141,10 @@ export function textFromResult(result: unknown): string {
127
141
 
128
142
  export class MCPStdioClient {
129
143
  private child: BridgeChild | null = null;
130
- private buffer = "";
131
- private searchStart = 0;
144
+ // Raw bytes of the frame still being read. Frames are cut at the 0x0A byte
145
+ // and decoded whole, so a character split across chunks survives.
146
+ private chunks: Buffer[] = [];
147
+ private bufferedBytes = 0;
132
148
  private nextId = 1;
133
149
  private pending = new Map<number, PendingRequest>();
134
150
  private exited = true;
@@ -136,8 +152,11 @@ export class MCPStdioClient {
136
152
 
137
153
  private readonly bin: string;
138
154
  private readonly toolsPreset: string;
155
+ private readonly cwd: string | undefined;
139
156
  private readonly deps: ProcessDeps;
140
157
  private readonly env: Record<string, string | undefined>;
158
+ private readonly callTimeoutMs: number;
159
+ private readonly maxFrameBytes: number;
141
160
 
142
161
  // Fired on notifications/tools/list_changed (server-side promotion).
143
162
  onToolsListChanged: (() => void) | null = null;
@@ -150,8 +169,11 @@ export class MCPStdioClient {
150
169
  constructor(options: MCPStdioClientOptions) {
151
170
  this.bin = options.bin;
152
171
  this.toolsPreset = options.toolsPreset;
172
+ this.cwd = options.cwd;
153
173
  this.deps = options.deps;
154
174
  this.env = options.env ?? process.env;
175
+ this.callTimeoutMs = options.callTimeoutMs ?? CALL_TIMEOUT_MS;
176
+ this.maxFrameBytes = options.maxFrameBytes ?? MAX_BUFFER_BYTES;
155
177
  }
156
178
 
157
179
  private childEnv(): Record<string, string | undefined> {
@@ -161,11 +183,13 @@ export class MCPStdioClient {
161
183
  private spawnChild(): void {
162
184
  const child = this.deps.spawn(this.bin, ["mcp"], {
163
185
  stdio: ["pipe", "pipe", "pipe"],
186
+ cwd: this.cwd,
164
187
  env: this.childEnv(),
188
+ windowsHide: true,
165
189
  });
166
190
  this.child = child;
167
- this.buffer = "";
168
- this.searchStart = 0;
191
+ this.chunks = [];
192
+ this.bufferedBytes = 0;
169
193
  this.exited = false;
170
194
  child.stdout?.on("data", (chunk: unknown) => this.onData(chunk as Buffer));
171
195
  // Consume stderr so the child never blocks on a full pipe; routing it
@@ -185,68 +209,93 @@ export class MCPStdioClient {
185
209
  if (this.exited && this.pending.size === 0) return;
186
210
  this.exited = true;
187
211
  this.child = null;
188
- for (const [, p] of this.pending) {
189
- if (p.timer) clearTimeout(p.timer);
190
- p.reject(err);
191
- }
212
+ const pending = [...this.pending.values()];
192
213
  this.pending.clear();
214
+ for (const p of pending) p.reject(err);
193
215
  }
194
216
 
195
217
  private onData(chunk: Buffer): void {
196
- this.buffer += chunk.toString("utf8");
218
+ let start = 0;
197
219
  let nl: number;
198
- // Resume the newline scan where the previous chunk left off, so a
199
- // large frame split across many chunks does not re-scan the whole
200
- // growing buffer from index 0 on every data event.
201
- while ((nl = this.buffer.indexOf("\n", this.searchStart)) >= 0) {
202
- const line = this.buffer.slice(0, nl).trim();
203
- this.buffer = this.buffer.slice(nl + 1);
204
- this.searchStart = 0;
205
- if (!line) continue;
206
- let msg: unknown;
207
- try {
208
- msg = JSON.parse(line);
209
- } catch {
210
- continue; // non-JSON noise, ignore
211
- }
212
- this.dispatch(msg);
220
+ while ((nl = chunk.indexOf(0x0a, start)) >= 0) {
221
+ if (this.bufferedBytes + nl - start > this.maxFrameBytes) return this.overflow();
222
+ const tail = chunk.subarray(start, nl);
223
+ const frame = this.chunks.length > 0 ? Buffer.concat([...this.chunks, tail]) : tail;
224
+ this.chunks = [];
225
+ this.bufferedBytes = 0;
226
+ start = nl + 1;
227
+ this.onFrame(frame.toString("utf8").trim());
213
228
  }
214
- this.searchStart = this.buffer.length;
215
- if (this.buffer.length > MAX_BUFFER_BYTES) {
216
- // One frame past the cap is fatal for this child (no way to
217
- // resync mid-frame): pending requests reject, the next call
218
- // respawns a fresh child.
219
- this.buffer = "";
220
- this.searchStart = 0;
221
- const child = this.child;
222
- this.markExited(new Error(`gortex mcp frame exceeded ${MAX_BUFFER_BYTES} bytes`));
223
- try {
224
- child?.kill();
225
- } catch {
226
- // already gone
227
- }
229
+ if (start >= chunk.length) return;
230
+ this.chunks.push(start === 0 ? chunk : chunk.subarray(start));
231
+ this.bufferedBytes += chunk.length - start;
232
+ if (this.bufferedBytes > this.maxFrameBytes) this.overflow();
233
+ }
234
+
235
+ // One frame past the cap is fatal for this child (no way to resync
236
+ // mid-frame): pending requests reject, the next call respawns a fresh child.
237
+ private overflow(): void {
238
+ this.chunks = [];
239
+ this.bufferedBytes = 0;
240
+ const child = this.child;
241
+ this.markExited(new Error(`gortex mcp frame exceeded ${this.maxFrameBytes} bytes`));
242
+ try {
243
+ child?.kill();
244
+ } catch {
245
+ // already gone
228
246
  }
229
247
  }
230
248
 
231
- private dispatch(msg: unknown): void {
232
- const frame = msg as { id?: unknown; error?: { code?: number; message?: string }; result?: unknown; method?: unknown };
233
- if (frame && typeof frame.id === "number" && this.pending.has(frame.id)) {
234
- const p = this.pending.get(frame.id)!;
235
- this.pending.delete(frame.id);
236
- if (p.timer) clearTimeout(p.timer);
237
- if (frame.error) {
238
- p.reject(new Error(frame.error.message || `JSON-RPC error ${frame.error.code}`));
239
- } else {
240
- p.resolve(frame.result);
249
+ private onFrame(line: string): void {
250
+ if (!line) return;
251
+ let msg: unknown;
252
+ try {
253
+ msg = JSON.parse(line);
254
+ } catch {
255
+ return; // non-JSON noise, ignore
256
+ }
257
+ if (msg && typeof msg === "object") this.dispatch(msg as Record<string, unknown>);
258
+ }
259
+
260
+ private dispatch(frame: Record<string, unknown>): void {
261
+ const { id, method } = frame;
262
+ if (typeof method === "string") {
263
+ if (typeof id === "number" || typeof id === "string") this.answerServerRequest(id, method);
264
+ else if (method === "notifications/tools/list_changed") {
265
+ try {
266
+ this.onToolsListChanged?.();
267
+ } catch {
268
+ // best effort; never break the read loop.
269
+ }
241
270
  }
242
271
  return;
243
272
  }
244
- if (frame && frame.method === "notifications/tools/list_changed") {
245
- try {
246
- this.onToolsListChanged?.();
247
- } catch {
248
- // best effort; never break the read loop.
249
- }
273
+ const error = frame.error as Record<string, unknown> | undefined;
274
+ if (id === null && error) {
275
+ // The server could not tell which request this answers, so none of
276
+ // them will get a reply.
277
+ const pending = [...this.pending.values()];
278
+ this.pending.clear();
279
+ for (const p of pending) p.reject(new JsonRpcError(error));
280
+ return;
281
+ }
282
+ if (typeof id !== "number") return;
283
+ const p = this.pending.get(id);
284
+ if (!p) return;
285
+ this.pending.delete(id);
286
+ if (error) p.reject(new JsonRpcError(error));
287
+ else p.resolve(frame.result);
288
+ }
289
+
290
+ private answerServerRequest(id: number | string, method: string): void {
291
+ const reply =
292
+ method === "ping"
293
+ ? { jsonrpc: "2.0", id, result: {} }
294
+ : { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } };
295
+ try {
296
+ this.send(reply);
297
+ } catch {
298
+ // the child is gone; nothing is waiting on the answer.
250
299
  }
251
300
  }
252
301
 
@@ -270,24 +319,54 @@ export class MCPStdioClient {
270
319
  return this.respawnPromise;
271
320
  }
272
321
 
273
- async request(method: string, params: unknown, timeoutMs: number = RPC_TIMEOUT_MS): Promise<unknown> {
322
+ /**
323
+ * Sends one request. On abort or timeout the call is dropped and the server
324
+ * is sent notifications/cancelled, so a late reply is ignored.
325
+ */
326
+ async request(
327
+ method: string,
328
+ params: unknown,
329
+ timeoutMs: number = RPC_TIMEOUT_MS,
330
+ signal?: AbortSignal,
331
+ ): Promise<unknown> {
332
+ const aborted = () => new Error(`${method} aborted`);
333
+ if (signal?.aborted) throw aborted();
274
334
  if (this.exited) await this.respawn();
335
+ if (signal?.aborted) throw aborted();
275
336
  const id = this.nextId++;
276
337
  return new Promise((resolve, reject) => {
277
- const entry: PendingRequest = { resolve, reject };
338
+ const onAbort = () => abandon(aborted());
339
+ const settled = () => {
340
+ if (entry.timer) clearTimeout(entry.timer);
341
+ signal?.removeEventListener("abort", onAbort);
342
+ };
343
+ const entry: PendingRequest = {
344
+ resolve: (value) => {
345
+ settled();
346
+ resolve(value);
347
+ },
348
+ reject: (err) => {
349
+ settled();
350
+ reject(err);
351
+ },
352
+ };
353
+ const abandon = (err: Error) => {
354
+ if (this.pending.get(id) !== entry) return;
355
+ this.pending.delete(id);
356
+ entry.reject(err);
357
+ // The MCP spec forbids cancelling initialize.
358
+ if (method !== "initialize") this.notify("notifications/cancelled", { requestId: id, reason: err.message });
359
+ };
278
360
  if (Number.isFinite(timeoutMs)) {
279
- entry.timer = setTimeout(() => {
280
- this.pending.delete(id);
281
- reject(new Error(`${method} timed out after ${timeoutMs}ms`));
282
- }, timeoutMs);
361
+ entry.timer = setTimeout(() => abandon(new Error(`${method} timed out after ${timeoutMs}ms`)), timeoutMs);
283
362
  }
363
+ signal?.addEventListener("abort", onAbort, { once: true });
284
364
  this.pending.set(id, entry);
285
365
  try {
286
366
  this.send({ jsonrpc: "2.0", id, method, params });
287
367
  } catch (err) {
288
368
  this.pending.delete(id);
289
- if (entry.timer) clearTimeout(entry.timer);
290
- reject(err instanceof Error ? err : new Error(String(err)));
369
+ entry.reject(err instanceof Error ? err : new Error(String(err)));
291
370
  }
292
371
  });
293
372
  }
@@ -336,8 +415,8 @@ export class MCPStdioClient {
336
415
  return Array.isArray(res?.tools) ? (res.tools as ToolDescriptor[]) : [];
337
416
  }
338
417
 
339
- async callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
340
- return this.request("tools/call", { name, arguments: args ?? {} }, CALL_TIMEOUT_MS);
418
+ async callTool(name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
419
+ return this.request("tools/call", { name, arguments: args ?? {} }, this.callTimeoutMs, signal);
341
420
  }
342
421
 
343
422
  stop(): void {
package/src/native.ts CHANGED
@@ -18,7 +18,7 @@ const SERVER_DESCRIPTION =
18
18
  "Gortex code graph of the indexed repositories: symbol search, source reads, usages, " +
19
19
  "call chains, change impact and graph-aware edits.";
20
20
 
21
- // Pi 0.87 declares none of the MCP members, so they are read structurally.
21
+ // Read structurally, since a Pi before 0.99 has none of these members.
22
22
  type NativeMcpApi = Pick<ExtensionAPI, "registerMcpServer" | "unregisterMcpServer" | "getAllTools" | "getCommands">;
23
23
 
24
24
  export function supportsNativeMcp(pi: ExtensionAPI): boolean {
package/src/runtime.ts CHANGED
@@ -32,7 +32,9 @@ export interface BridgeChild {
32
32
  export interface SpawnOpts {
33
33
  stdio?: "ignore" | ("pipe" | "ignore")[];
34
34
  detached?: boolean;
35
+ cwd?: string;
35
36
  env?: Record<string, string | undefined>;
37
+ windowsHide?: boolean;
36
38
  }
37
39
 
38
40
  export interface ExecFileSyncOpts {
@@ -40,6 +42,7 @@ export interface ExecFileSyncOpts {
40
42
  encoding?: "utf8";
41
43
  maxBuffer?: number;
42
44
  timeout?: number;
45
+ windowsHide?: boolean;
43
46
  }
44
47
 
45
48
  export interface ProcessDeps {
package/src/tools.ts CHANGED
@@ -95,11 +95,11 @@ export function registerOneTool(pi: ExtensionAPI, desc: ToolDescriptor): void {
95
95
  label: name,
96
96
  description: piAliasedDescription(name, piAliasName(name), (desc.description || name).trim()),
97
97
  parameters,
98
- async execute(_id, params, _signal, _onUpdate, ctx) {
98
+ async execute(_id, params, signal, _onUpdate, ctx) {
99
99
  const client = getClient();
100
100
  if (!client) throw new Error(`gortex ${name}: MCP bridge is not connected`);
101
101
  const args = params ?? {};
102
- const callTool = () => client.callTool(name, args);
102
+ const callTool = () => client.callTool(name, args, signal);
103
103
  let result: unknown;
104
104
  let diffs: FileDiff[] = [];
105
105
  try {