pi-gortex 0.1.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.
@@ -88,7 +100,8 @@ npm run typecheck
88
100
  npm run check # both
89
101
  ```
90
102
 
91
- Zero runtime dependencies. Pi's types are imported as types only and erased, so
103
+ Zero runtime dependencies. Pi's extension loader resolves Pi's own packages to
104
+ the running Pi, so they are imported freely and kept in `devDependencies`, and
92
105
  the package also loads as a bare directory extension with no `node_modules`
93
106
  beside it.
94
107
 
@@ -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.1.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,8 +35,10 @@
35
35
  "prepublishOnly": "npm run check"
36
36
  },
37
37
  "devDependencies": {
38
- "@earendil-works/pi-coding-agent": "~0.85.1",
39
- "@types/node": "^26.6.2",
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",
40
42
  "typescript": "^7.0.2"
41
43
  },
42
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/render.ts ADDED
@@ -0,0 +1,149 @@
1
+ // TUI rendering for bridged tools: a call line read off Gortex's argument
2
+ // shape (operation, target, options), Pi's own read renderer for a file read,
3
+ // and a result collapsed until the user expands it.
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";
8
+ import type { Component } from "@earendil-works/pi-tui";
9
+
10
+ import { textFromResult } from "./mcp-client.ts";
11
+
12
+ type ToolRenderContext = Parameters<NonNullable<ToolDefinition["renderCall"]>>[2];
13
+
14
+ export type RenderCall = (args: Record<string, unknown>, theme: Theme, context: ToolRenderContext) => Component;
15
+ export type RenderResult = (result: unknown, options: { expanded?: boolean }) => Component;
16
+
17
+ const OPERATION_ARGS = ["operation", "kind"];
18
+ const SUBJECT_ARGS = ["target", "query", "question", "task", "path"];
19
+ const HIDDEN_ARGS = new Set(["output", "view", "context", "to"]);
20
+ const NESTED_ARGS = new Set(["options", "arguments"]);
21
+ const HIDDEN_OPTIONS = new Set(["new_user_task"]);
22
+ const SCOPE_OPTIONS = ["repo", "project", "path", "scope", "kind", "lang"];
23
+ const MAX_VALUE_CHARS = 60;
24
+
25
+ /** CallLine renders on one row cut to the terminal width, unlike Text which wraps. */
26
+ class CallLine implements Component {
27
+ text = "";
28
+ setText(text: string): void {
29
+ this.text = text;
30
+ }
31
+ invalidate(): void {}
32
+ render(width: number): string[] {
33
+ return [truncateToWidth(this.text, width)];
34
+ }
35
+ }
36
+
37
+ function isSet(value: unknown): boolean {
38
+ if (value === undefined || value === null || value === "") return false;
39
+ if (typeof value === "object" && !Array.isArray(value)) return Object.keys(value).length > 0;
40
+ return true;
41
+ }
42
+
43
+ function asRecord(value: unknown): Record<string, unknown> {
44
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
45
+ }
46
+
47
+ /** selector unwraps a target-shaped object ({file}, {symbol}, ...) to the value it selects. */
48
+ function selector(value: unknown): unknown {
49
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
50
+ const entry = Object.values(value).find(isSet);
51
+ return entry ?? value;
52
+ }
53
+
54
+ /** lineNumber keeps a positive finite number; Gortex reads 0 as unset. */
55
+ function lineNumber(value: unknown): number | undefined {
56
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
57
+ }
58
+
59
+ function formatValue(value: unknown, full: boolean): string {
60
+ let s: string;
61
+ if (typeof value === "string") s = value;
62
+ else if (Array.isArray(value) && value.every((v) => typeof v !== "object")) s = value.join(",");
63
+ else if (typeof value === "object") s = JSON.stringify(value);
64
+ else s = String(value);
65
+ s = s.replace(/\s+/g, " ").trim();
66
+ return !full && s.length > MAX_VALUE_CHARS ? s.slice(0, MAX_VALUE_CHARS - 1) + "…" : s;
67
+ }
68
+
69
+ /**
70
+ * callRenderer renders `<tool> <operation> <subject> to <other> key=value...`.
71
+ * Scope options come first so they survive the cut to the terminal width;
72
+ * expanded, the line wraps with every value in full.
73
+ */
74
+ export function callRenderer(name: string): RenderCall {
75
+ return (rawArgs, theme, context) => {
76
+ const args = asRecord(rawArgs);
77
+ const full = context.expanded;
78
+ const parts = [theme.fg("toolTitle", theme.bold(name))];
79
+ const operationKey = OPERATION_ARGS.find((k) => isSet(args[k]));
80
+ if (operationKey) parts.push(theme.fg("toolTitle", formatValue(args[operationKey], full)));
81
+ const subjectKey = SUBJECT_ARGS.find((k) => isSet(args[k]));
82
+ if (subjectKey) parts.push(theme.fg("accent", formatValue(selector(args[subjectKey]), full)));
83
+ if (isSet(args.to)) parts.push(theme.fg("dim", "to"), theme.fg("accent", formatValue(selector(args.to), full)));
84
+
85
+ const pairs: [string, unknown][] = [];
86
+ for (const [k, v] of Object.entries(args)) {
87
+ if (HIDDEN_ARGS.has(k) || k === operationKey || k === subjectKey) continue;
88
+ if (NESTED_ARGS.has(k)) {
89
+ const nested = Object.entries(asRecord(v)).filter(([nk]) => !HIDDEN_OPTIONS.has(nk));
90
+ const rank = ([nk]: [string, unknown]) => (SCOPE_OPTIONS.includes(nk) ? 0 : 1);
91
+ pairs.push(...nested.sort((a, b) => rank(a) - rank(b)));
92
+ } else {
93
+ pairs.push([k, v]);
94
+ }
95
+ }
96
+ for (const [k, v] of pairs) {
97
+ if (isSet(v)) parts.push(theme.fg("dim", `${k}=`) + theme.fg("muted", formatValue(v, full)));
98
+ }
99
+
100
+ const line = full ? new Text("", 0, 0) : new CallLine();
101
+ line.setText(parts.join(" "));
102
+ return line;
103
+ };
104
+ }
105
+
106
+ /** GortexText marks a line rendered by Pi's own renderer as Gortex's. */
107
+ class GortexText extends Text {
108
+ theme: Theme | undefined;
109
+ override setText(text: string): void {
110
+ super.setText(this.theme ? this.theme.fg("dim", "gortex ") + text : text);
111
+ }
112
+ }
113
+
114
+ let piRead: ReturnType<typeof createReadToolDefinition> | undefined;
115
+
116
+ /**
117
+ * readCallRenderer hands a file read to Pi's read renderer, so it shows the
118
+ * same path, line range and skill / docs / resource labels as Pi's own read.
119
+ * Any other read operation falls through to the generic call line.
120
+ */
121
+ export function readCallRenderer(name: string): RenderCall {
122
+ const generic = callRenderer(name);
123
+ return (rawArgs, theme, context) => {
124
+ const args = asRecord(rawArgs);
125
+ const file = asRecord(args.target).file;
126
+ if (typeof file !== "string" || (args.operation !== undefined && args.operation !== "file")) {
127
+ return generic(rawArgs, theme, context);
128
+ }
129
+ piRead ??= createReadToolDefinition(context.cwd);
130
+ const text = context.lastComponent instanceof GortexText ? context.lastComponent : new GortexText("", 0, 0);
131
+ text.theme = theme;
132
+ const options = asRecord(args.options);
133
+ const readArgs = {
134
+ path: file,
135
+ offset: lineNumber(options.offset),
136
+ limit: lineNumber(options.limit),
137
+ };
138
+ return piRead.renderCall!(readArgs, theme, { ...context, args: readArgs, lastComponent: text });
139
+ };
140
+ }
141
+
142
+ export function resultText(result: unknown): string {
143
+ const structured = (result as { structuredContent?: unknown })?.structuredContent;
144
+ return textFromResult(result) || JSON.stringify(structured ?? result ?? {});
145
+ }
146
+
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);
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
 
package/src/tools.ts CHANGED
@@ -1,12 +1,11 @@
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 { createRequire } from "node:module";
5
-
6
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
5
 
8
- import { textFromResult } from "./mcp-client.ts";
9
6
  import type { ToolDescriptor } from "./mcp-client.ts";
7
+ import { callRenderer, readCallRenderer, renderResult, resultText } from "./render.ts";
8
+ import type { RenderCall, RenderResult } from "./render.ts";
10
9
  import { getClient, gortexToolNames } from "./state.ts";
11
10
  import { toolNameMap } from "./hook.ts";
12
11
 
@@ -46,7 +45,8 @@ interface BridgeToolDefinition {
46
45
  description: string;
47
46
  parameters: unknown;
48
47
  execute(toolCallId: string, params: Record<string, unknown>): Promise<unknown>;
49
- renderResult?: (result: unknown, options: { expanded?: boolean }) => unknown;
48
+ renderCall: RenderCall;
49
+ renderResult: RenderResult;
50
50
  }
51
51
 
52
52
  /**
@@ -67,33 +67,11 @@ export function safeRegister(pi: ExtensionAPI, def: BridgeToolDefinition): strin
67
67
  return def.name;
68
68
  }
69
69
 
70
- // Lazily resolved TUI Text component, used only to collapse a gortex tool's
71
- // result to nothing until the user expands it (ctrl+o). Resolved through
72
- // createRequire rather than a static import so a Pi packaging that can't
73
- // provide @earendil-works/pi-tui degrades to Pi's default renderer instead of
74
- // throwing at load and taking tool registration down with it.
75
- type TextComponent = new (text: string, x: number, y: number) => unknown;
76
- let TuiText: TextComponent | undefined;
77
- try {
78
- const require = createRequire(import.meta.url);
79
- TuiText = (require("@earendil-works/pi-tui") as { Text: TextComponent }).Text;
80
- } catch {
81
- TuiText = undefined;
82
- }
83
-
84
- function resultText(result: unknown): string {
85
- const structured = (result as { structuredContent?: unknown })?.structuredContent;
86
- return textFromResult(result) || JSON.stringify(structured ?? result ?? {});
87
- }
88
-
89
70
  /**
90
71
  * Registers one Gortex tool under its bare daemon name (or its alias).
91
72
  * Idempotent: a name already registered is skipped. Adds the name to
92
73
  * gortexToolNames so the read-discipline postures treat a call to it as a graph
93
74
  * query.
94
- *
95
- * renderResult collapses the result to nothing until the user expands it with
96
- * ctrl+o; Pi's fallback renderCall already shows the tool name.
97
75
  */
98
76
  export function registerOneTool(pi: ExtensionAPI, desc: ToolDescriptor): void {
99
77
  const name = desc.name;
@@ -127,14 +105,9 @@ export function registerOneTool(pi: ExtensionAPI, desc: ToolDescriptor): void {
127
105
  if ((result as { isError?: boolean })?.isError) throw new Error(text || `gortex ${name} failed`);
128
106
  return { content: [{ type: "text", text }], details: {} };
129
107
  },
108
+ renderCall: (name === "read" ? readCallRenderer : callRenderer)(piAliasName(name)),
109
+ renderResult,
130
110
  };
131
- if (TuiText) {
132
- const Text = TuiText;
133
- def.renderResult = (result: unknown, options: { expanded?: boolean }) => {
134
- if (!options?.expanded) return new Text("", 0, 0);
135
- return new Text(resultText(result), 0, 0);
136
- };
137
- }
138
111
  gortexToolNames.add(safeRegister(pi, def));
139
112
  }
140
113