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 +12 -0
- package/docs/architecture.md +59 -9
- package/package.json +5 -4
- package/src/config.ts +11 -1
- package/src/index.ts +72 -14
- package/src/mcp-client.ts +24 -10
- package/src/native.ts +141 -0
- package/src/runtime.ts +4 -0
- package/src/state.ts +3 -1
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.
|
package/docs/architecture.md
CHANGED
|
@@ -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.
|
|
10
|
-
jobs over two separate
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
19
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
39
|
-
"@earendil-works/pi-tui": "^0.
|
|
40
|
-
"@types/node": "^
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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(
|
|
106
|
+
if (ctx?.hasUI) ctx.ui.notify(message, "warning");
|
|
89
107
|
} catch {
|
|
90
|
-
//
|
|
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
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|