pi-gortex 0.2.0 → 0.3.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
@@ -66,6 +66,7 @@ environment -> `<cwd>/.pi/gortex.json` -> `<agent dir>/extensions/gortex.json`
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
@@ -64,6 +99,11 @@ own way, while Gortex's classifier switches on canonical tool names and input
64
99
  keys. That translation lives on this side of the bridge, so the Go side keeps
65
100
  one vocabulary for every agent it serves.
66
101
 
102
+ A call to a Gortex tool is flagged as a graph call and sent under the daemon's
103
+ own tool name, which is what the postures key off. It skips the translation, so
104
+ Gortex's `read` never passes for Pi's. On Pi's built-in MCP the same hook also
105
+ sees each tool a codemode script calls.
106
+
67
107
  Postures are resolved by Gortex. Keeping that decision there is what keeps the
68
108
  behaviour identical across hosts.
69
109
 
@@ -98,9 +138,13 @@ matter:
98
138
  If the cap expires first, that turn is told the tools may not be callable yet
99
139
  and pointed at a reload. Exactly one turn reports it.
100
140
 
141
+ On Pi's built-in MCP the session hook registers nothing, and the turn waits for
142
+ the server's tools instead (see Channel 1 on Pi 0.99+).
143
+
101
144
  ## Session state
102
145
 
103
- The live bridge, the last bridge error and the registered tool names live at
146
+ This applies to the extension's own client; on Pi's built-in MCP, Pi owns the
147
+ child. The live bridge, the last bridge error and the registered tool names live at
104
148
  module scope, and that is load bearing. A reload re-imports the module, so the
105
149
  state resets with it. A new session, a resume and a fork re-invoke the extension
106
150
  factory against the cached module, and the new invocation stops the previous
@@ -110,6 +154,12 @@ invocation that overlaps it stops that bridge instead of leaving the child
110
154
  behind. State held per instance would leak a Gortex child on every new
111
155
  session.
112
156
 
157
+ Process exit has no next invocation, so the extension also stops the bridge
158
+ from Pi's `session_shutdown` event, which fires on quit and on every
159
+ replacement path. The child is spawned unref'd, pipes included: they must
160
+ never be what holds the host's event loop open, or a print or JSON run that
161
+ finished its work hangs at exit instead of draining.
162
+
113
163
  ## Failure posture
114
164
 
115
165
  Fail open, everywhere. A missing binary, a daemon that is down, a handshake that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gortex",
3
- "version": "0.2.0",
3
+ "version": "0.3.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
@@ -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. */
@@ -154,5 +158,11 @@ export function resolveConfig(opts: ResolveConfigOptions = {}): GortexConfig {
154
158
  const toolsPreset =
155
159
  firstString(env.GORTEX_TOOLS, project?.tools_preset, global?.tools_preset) ?? DEFAULT_TOOLS_PRESET;
156
160
 
157
- return { bin, hookArgv, enforce, toolsPreset };
161
+ const nativeMcp =
162
+ parseBoolean(env.GORTEX_NATIVE_MCP) ??
163
+ (typeof project?.native_mcp === "boolean" ? project.native_mcp : undefined) ??
164
+ (typeof global?.native_mcp === "boolean" ? global.native_mcp : undefined) ??
165
+ true;
166
+
167
+ return { bin, hookArgv, enforce, toolsPreset, nativeMcp };
158
168
  }
package/src/index.ts CHANGED
@@ -20,6 +20,18 @@ import {
20
20
  ensureDaemon,
21
21
  isBelowVersion,
22
22
  } from "./mcp-client.ts";
23
+ import {
24
+ NATIVE_READY_WAIT_MS,
25
+ bareToolName,
26
+ binaryResolves,
27
+ daemonVersionFromBriefing,
28
+ isNativeGortexTool,
29
+ mcpConnectorLoaded,
30
+ registerNativeServer,
31
+ unregisterNativeServer,
32
+ supportsNativeMcp,
33
+ waitForNativeTools,
34
+ } from "./native.ts";
23
35
  import { nodeProcessDeps } from "./runtime.ts";
24
36
  import type { ProcessDeps } from "./runtime.ts";
25
37
  import {
@@ -82,12 +94,18 @@ function reportVersionSkew(
82
94
  `run \`gortex upgrade\`.`,
83
95
  );
84
96
 
85
- // A Pi build without a UI (print/JSON mode) or without notify must not take
86
- // the session down over an advisory message.
97
+ notifyUser(ctx, user);
98
+ }
99
+
100
+ /**
101
+ * A warning for the user only. A Pi build without a UI (print/JSON mode) or
102
+ * without notify must not take the session down over an advisory message.
103
+ */
104
+ function notifyUser(ctx: ExtensionContext | undefined, message: string): void {
87
105
  try {
88
- if (ctx?.hasUI) ctx.ui.notify(user, "warning");
106
+ if (ctx?.hasUI) ctx.ui.notify(message, "warning");
89
107
  } catch {
90
- // the model still gets the orientation line above
108
+ // advisory only
91
109
  }
92
110
  }
93
111
 
@@ -99,6 +117,8 @@ export interface GortexExtensionOptions {
99
117
  deps?: ProcessDeps;
100
118
  /** Readiness cap override. */
101
119
  readyWaitMs?: number;
120
+ /** Override for the wait on Pi's built-in MCP connecting the gortex server. */
121
+ nativeReadyWaitMs?: number;
102
122
  }
103
123
 
104
124
  export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensionOptions = {}): void {
@@ -109,6 +129,18 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
109
129
  const piCwd = (): string => (pi as { cwd?: string })?.cwd ?? process.cwd();
110
130
  const deps = options.deps ?? nodeProcessDeps;
111
131
  const readyWaitMs = options.readyWaitMs ?? READY_WAIT_MS;
132
+ const nativeReadyWaitMs = options.nativeReadyWaitMs ?? NATIVE_READY_WAIT_MS;
133
+
134
+ // On a Pi with built-in MCP, Pi owns the tool channel: it spawns `gortex mcp`
135
+ // (which starts the daemon itself), registers the tools and renders them.
136
+ // A binary that does not resolve, or a registration Pi refuses, falls back to
137
+ // the extension's own client, which reports its failure to the model.
138
+ let nativeRefusal = "";
139
+ let native = ((): boolean => {
140
+ if (!config.nativeMcp || !supportsNativeMcp(pi) || !binaryResolves(config.bin)) return false;
141
+ nativeRefusal = registerNativeServer(pi, config);
142
+ return nativeRefusal === "";
143
+ })();
112
144
 
113
145
  // Latched for the whole session once the first turn has run the
114
146
  // session_start hook, whether or not it produced anything to inject.
@@ -178,17 +210,29 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
178
210
  }
179
211
  });
180
212
 
181
- // startSession holds the body of session_start so the readiness promise
182
- // above settles on every exit path, early returns included.
183
- async function startSession(ctx?: ExtensionContext): Promise<void> {
184
- ensureDaemon(config.bin, deps);
185
- gortexToolNames.clear();
213
+ // Stops the live bridge, if any, and clears the slot. Shared by
214
+ // session_start and session_shutdown so both paths stay in sync.
215
+ function stopBridge(): void {
186
216
  clearSyncTimer();
187
217
  const previous = getClient();
188
218
  if (previous) {
189
219
  previous.stop();
190
220
  setClient(null);
191
221
  }
222
+ }
223
+
224
+ // startSession holds the body of session_start so the readiness promise
225
+ // above settles on every exit path, early returns included.
226
+ async function startSession(ctx?: ExtensionContext): Promise<void> {
227
+ if (native && !mcpConnectorLoaded(pi)) {
228
+ unregisterNativeServer(pi);
229
+ native = false;
230
+ }
231
+ if (native) return;
232
+ if (nativeRefusal) notifyUser(ctx, `Pi refused the gortex MCP server (${nativeRefusal}), so the extension runs its own client.`);
233
+ ensureDaemon(config.bin, deps);
234
+ gortexToolNames.clear();
235
+ stopBridge();
192
236
  const c = new MCPStdioClient({ bin: config.bin, toolsPreset: config.toolsPreset, deps });
193
237
  // Claim the slot before the first await: an overlapping
194
238
  // session_start (rapid /new or /reload) then sees and stops THIS
@@ -216,16 +260,22 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
216
260
 
217
261
  // Fires before the agent loop's first LLM call. It can't mutate messages
218
262
  // itself, so it just parks the orientation for the `context` hook.
219
- pi.on("before_agent_start", async () => {
263
+ pi.on("before_agent_start", async (_event, ctx) => {
220
264
  if (startupHookRan) return;
221
265
  startupHookRan = true; // before the first await, so a second turn can't race in
222
266
  // Pi awaits each listener, so this holds the turn until the tools are
223
267
  // registered and bridgeError reflects the handshake (or the cap expires).
224
- const ready = await waitForSession(readyWaitMs);
268
+ // session_start settles `native` first: it falls back when nothing connects MCP.
269
+ const sessionDone = await waitForSession(readyWaitMs);
270
+ const ready = native ? await waitForNativeTools(pi, nativeReadyWaitMs) : sessionDone;
225
271
  const decision = callHook(config.hookArgv, deps, {
226
272
  event: "session_start",
227
- cwd: piCwd(),
273
+ cwd: ctx?.cwd ?? piCwd(),
228
274
  });
275
+ // Pi's MCP keeps the handshake to itself, so the version comes from the briefing.
276
+ if (native && decision.orientation) {
277
+ reportVersionSkew(ctx, daemonVersionFromBriefing(decision.orientation), "");
278
+ }
229
279
  const parts: string[] = [];
230
280
  const bridgeError = getBridgeError();
231
281
  if (bridgeError) {
@@ -276,15 +326,23 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
276
326
  return;
277
327
  });
278
328
 
329
+ // Never let the bridge child outlive the session.
330
+ pi.on("session_shutdown", async () => {
331
+ stopBridge();
332
+ });
333
+
279
334
  if (!config.enforce) return;
280
335
 
281
336
  // Enforcement: every non-Gortex tool call is checked against the Go hook.
282
337
  pi.on("tool_call", async (event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | void> => {
283
338
  const piName: string = (event as { toolName?: string })?.toolName ?? "";
284
339
  const piInput: Record<string, unknown> = (event?.input as Record<string, unknown>) ?? {};
285
- const isGortexTool = gortexToolNames.has(piName);
340
+ const isGortexTool = native ? isNativeGortexTool(piName) : gortexToolNames.has(piName);
286
341
 
287
- const norm = normalizeToolCall(piName, piInput);
342
+ // A graph tool keeps its own name: Gortex's `read` is no Pi `read`.
343
+ const norm = isGortexTool
344
+ ? { tool_name: bareToolName(piName), tool_input: piInput }
345
+ : normalizeToolCall(piName, piInput);
288
346
  const decision = callHook(config.hookArgv, deps, {
289
347
  event: "tool_call",
290
348
  tool_name: norm.tool_name,
package/src/mcp-client.ts CHANGED
@@ -53,6 +53,24 @@ export function isBelowVersion(actual: string, floor: string): boolean {
53
53
  // session. Fail open to the daemon's default surface instead.
54
54
  const FORWARDED_PRESETS = new Set(["edit", "nav", "readonly"]);
55
55
 
56
+ /**
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.
60
+ */
61
+ export function presetEnv(
62
+ toolsPreset: string,
63
+ env: Record<string, string | undefined>,
64
+ ): 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;
69
+ // 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;
72
+ }
73
+
56
74
  export interface MCPStdioClientOptions {
57
75
  bin: string;
58
76
  toolsPreset: string;
@@ -137,16 +155,7 @@ export class MCPStdioClient {
137
155
  }
138
156
 
139
157
  private childEnv(): Record<string, string | undefined> {
140
- const env: Record<string, string | undefined> = { ...this.env };
141
- const preset = this.toolsPreset.trim().toLowerCase();
142
- // The daemon's own default surface already IS core/defer; only a
143
- // recognised non-default preset needs the proxy-side narrowing.
144
- if (!env.GORTEX_TOOLS && FORWARDED_PRESETS.has(preset)) {
145
- env.GORTEX_TOOLS = preset;
146
- }
147
- // Never let a preset hide-block tools promoted later by tools_search.
148
- if (env.GORTEX_TOOLS && !env.GORTEX_TOOLS_MODE) env.GORTEX_TOOLS_MODE = "defer";
149
- return env;
158
+ return { ...this.env, ...presetEnv(this.toolsPreset, this.env) };
150
159
  }
151
160
 
152
161
  private spawnChild(): void {
@@ -165,6 +174,11 @@ export class MCPStdioClient {
165
174
  child.stdin?.on("error", () => {}); // EPIPE race: child may exit before stdin.write() finishes
166
175
  child.on("error", () => this.markExited(new Error("gortex mcp spawn failed")));
167
176
  child.on("exit", () => this.markExited(new Error("gortex mcp exited")));
177
+ // The child and its pipes must never keep the event loop alive.
178
+ child.unref?.();
179
+ child.stdin?.unref?.();
180
+ child.stdout?.unref?.();
181
+ child.stderr?.unref?.();
168
182
  }
169
183
 
170
184
  private markExited(err: Error): void {
package/src/native.ts ADDED
@@ -0,0 +1,141 @@
1
+ // The tool channel on Pi's built-in MCP (Pi >= 0.99): the extension registers
2
+ // `gortex mcp` with pi.registerMcpServer() and Pi owns the connection, the tool
3
+ // registry and the rendering. See docs/architecture.md.
4
+
5
+ import { accessSync, constants, statSync } from "node:fs";
6
+ import { delimiter, join } from "node:path";
7
+
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+
10
+ import type { GortexConfig } from "./config.ts";
11
+ import { CALL_TIMEOUT_MS, presetEnv } from "./mcp-client.ts";
12
+
13
+ export const MCP_SERVER_NAME = "gortex";
14
+ /** Pi names a server's tools `mcp__<server>__<tool>`. */
15
+ export const MCP_TOOL_PREFIX = `mcp__${MCP_SERVER_NAME}__`;
16
+
17
+ const SERVER_DESCRIPTION =
18
+ "Gortex code graph of the indexed repositories: symbol search, source reads, usages, " +
19
+ "call chains, change impact and graph-aware edits.";
20
+
21
+ // Pi 0.87 declares none of the MCP members, so they are read structurally.
22
+ type NativeMcpApi = Pick<ExtensionAPI, "registerMcpServer" | "unregisterMcpServer" | "getAllTools" | "getCommands">;
23
+
24
+ export function supportsNativeMcp(pi: ExtensionAPI): boolean {
25
+ return typeof (pi as Partial<NativeMcpApi>).registerMcpServer === "function";
26
+ }
27
+
28
+ export function nativeServerConfig(
29
+ config: GortexConfig,
30
+ env: Record<string, string | undefined> = process.env,
31
+ ): Parameters<ExtensionAPI["registerMcpServer"]>[1] {
32
+ return {
33
+ command: config.bin,
34
+ args: ["mcp"],
35
+ env: presetEnv(config.toolsPreset, env),
36
+ // Declared to the model like built-in tools, and the first prompt waits
37
+ // for the connection. Codemode would hand scripts gcx text to parse.
38
+ exposure: "direct",
39
+ timeout: CALL_TIMEOUT_MS / 1000,
40
+ description: SERVER_DESCRIPTION,
41
+ };
42
+ }
43
+
44
+ /** Registers the server; returns why it failed, or "" on success. */
45
+ export function registerNativeServer(pi: ExtensionAPI, config: GortexConfig): string {
46
+ try {
47
+ (pi as NativeMcpApi).registerMcpServer(MCP_SERVER_NAME, nativeServerConfig(config));
48
+ return "";
49
+ } catch (err) {
50
+ return err instanceof Error ? err.message : String(err);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Whether `bin` names an executable file, directly or through PATH. Pi reports
56
+ * no connection failure to extensions, so a server that cannot spawn would
57
+ * read as one still connecting.
58
+ */
59
+ export function binaryResolves(bin: string, env: Record<string, string | undefined> = process.env): boolean {
60
+ const exts = process.platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
61
+ const candidates = /[\\/]/.test(bin)
62
+ ? [bin]
63
+ : (env.PATH ?? "").split(delimiter).filter(Boolean).map((dir) => join(dir, bin));
64
+ return candidates.some((path) =>
65
+ exts.some((ext) => {
66
+ try {
67
+ accessSync(path + ext, constants.X_OK);
68
+ return statSync(path + ext).isFile();
69
+ } catch {
70
+ return false;
71
+ }
72
+ }),
73
+ );
74
+ }
75
+
76
+ /**
77
+ * The daemon version the session_start briefing reports, or "" when it names
78
+ * none. The briefing is the only place it reaches the extension on Pi's MCP.
79
+ */
80
+ export function daemonVersionFromBriefing(briefing: string): string {
81
+ return /Gortex daemon [^(\n]*\(v(\d[^,\s)]*)/.exec(briefing)?.[1] ?? "";
82
+ }
83
+
84
+ /**
85
+ * Whether an extension that connects MCP servers is loaded. Pi's own MCP
86
+ * extension and the ones that replace it register `/mcp`; `--no-mcp` and
87
+ * `-ne` without `builtin:mcp` leave a registration with nothing to connect it.
88
+ * Read on session_start, once every extension has loaded. A Pi that cannot
89
+ * list commands is trusted to connect the server.
90
+ */
91
+ export function mcpConnectorLoaded(pi: ExtensionAPI): boolean {
92
+ try {
93
+ return (pi as NativeMcpApi).getCommands().some((command) => command.name === "mcp");
94
+ } catch {
95
+ return true;
96
+ }
97
+ }
98
+
99
+ /** Withdraws the registration, which also keeps Pi from reporting it as unconnected. */
100
+ export function unregisterNativeServer(pi: ExtensionAPI): void {
101
+ try {
102
+ (pi as NativeMcpApi).unregisterMcpServer(MCP_SERVER_NAME);
103
+ } catch {
104
+ // already gone, or this instance has been invalidated
105
+ }
106
+ }
107
+
108
+ export function isNativeGortexTool(name: string): boolean {
109
+ return name.startsWith(MCP_TOOL_PREFIX);
110
+ }
111
+
112
+ /** The daemon's own name for a native tool, as Gortex guidance spells it. */
113
+ export function bareToolName(name: string): string {
114
+ return isNativeGortexTool(name) ? name.slice(MCP_TOOL_PREFIX.length) : name;
115
+ }
116
+
117
+ /** True once Pi has registered at least one tool from the gortex server. */
118
+ export function nativeToolsReady(pi: ExtensionAPI): boolean {
119
+ try {
120
+ return (pi as NativeMcpApi).getAllTools().some((tool) => isNativeGortexTool(tool.name));
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * How long the first turn waits for the gortex tools on top of Pi's own wait.
128
+ * Pi's MCP extension already holds the first prompt for up to 10s on a direct
129
+ * server, so this only covers a daemon still warming up after that.
130
+ */
131
+ export const NATIVE_READY_WAIT_MS = 5_000;
132
+
133
+ /** Resolves true once the gortex tools are registered, false when `ms` passes first. */
134
+ export async function waitForNativeTools(pi: ExtensionAPI, ms: number, pollMs = 100): Promise<boolean> {
135
+ const deadline = Date.now() + ms;
136
+ while (!nativeToolsReady(pi)) {
137
+ if (Date.now() >= deadline) return false;
138
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
139
+ }
140
+ return true;
141
+ }
package/src/runtime.ts CHANGED
@@ -8,11 +8,15 @@ import type { SpawnOptions } from "node:child_process";
8
8
 
9
9
  export interface ChildStream {
10
10
  on(event: string, listener: (chunk: unknown) => void): unknown;
11
+ /** Drops the event-loop reference; I/O keeps working. */
12
+ unref?(): unknown;
11
13
  }
12
14
 
13
15
  export interface ChildStdin {
14
16
  on(event: string, listener: (err: unknown) => void): unknown;
15
17
  write(chunk: string): unknown;
18
+ /** Drops the event-loop reference; I/O keeps working. */
19
+ unref?(): unknown;
16
20
  }
17
21
 
18
22
  /** The subset of ChildProcess the MCP bridge and the daemon launcher touch. */
package/src/state.ts CHANGED
@@ -3,7 +3,9 @@
3
3
  // Shared at module level on purpose: a new invocation's session_start stops the
4
4
  // previous invocation's bridge child, which works only while both invocations
5
5
  // see the same `client`. Move this into the factory closure and every `/new`
6
- // leaks a `gortex mcp` process.
6
+ // leaks a `gortex mcp` process. Reload is the exception: Pi re-imports the
7
+ // extension with a fresh module graph, so there the old instance's
8
+ // session_shutdown handler is what stops its bridge.
7
9
 
8
10
  import type { MCPStdioClient } from "./mcp-client.ts";
9
11