pi-gortex 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -12
- package/docs/architecture.md +49 -12
- package/docs/tests.md +4 -4
- package/package.json +3 -3
- package/src/config.ts +73 -22
- package/src/diff.ts +404 -0
- package/src/hook.ts +4 -3
- package/src/index.ts +127 -52
- package/src/mcp-client.ts +159 -80
- package/src/native.ts +1 -1
- package/src/render.ts +65 -8
- package/src/runtime.ts +3 -0
- package/src/state.ts +7 -0
- package/src/tools.ts +48 -8
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// hook bridge. See docs/architecture.md for the design.
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
|
-
|
|
6
|
+
BeforeAgentStartEventResult,
|
|
7
7
|
ExtensionAPI,
|
|
8
8
|
ExtensionContext,
|
|
9
9
|
ToolCallEvent,
|
|
@@ -11,7 +11,8 @@ import type {
|
|
|
11
11
|
} from "@earendil-works/pi-coding-agent";
|
|
12
12
|
|
|
13
13
|
import { resolveConfig } from "./config.ts";
|
|
14
|
-
import type { GortexConfig } from "./config.ts";
|
|
14
|
+
import type { GortexConfig, ResolveConfigOptions } from "./config.ts";
|
|
15
|
+
import { watchNativeEdits } from "./diff.ts";
|
|
15
16
|
import { callHook, normalizeToolCall } from "./hook.ts";
|
|
16
17
|
import {
|
|
17
18
|
MCPStdioClient,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
supportsNativeMcp,
|
|
33
34
|
waitForNativeTools,
|
|
34
35
|
} from "./native.ts";
|
|
36
|
+
import { nativeRenderers } from "./render.ts";
|
|
35
37
|
import { nodeProcessDeps } from "./runtime.ts";
|
|
36
38
|
import type { ProcessDeps } from "./runtime.ts";
|
|
37
39
|
import {
|
|
@@ -47,6 +49,7 @@ import {
|
|
|
47
49
|
setBridgeError,
|
|
48
50
|
setClient,
|
|
49
51
|
setVersionWarning,
|
|
52
|
+
withdrawnToolNames,
|
|
50
53
|
} from "./state.ts";
|
|
51
54
|
|
|
52
55
|
export type { GortexConfig, SidecarConfig } from "./config.ts";
|
|
@@ -109,24 +112,62 @@ function notifyUser(ctx: ExtensionContext | undefined, message: string): void {
|
|
|
109
112
|
}
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
/** Marks the messages this extension stores in the session. */
|
|
116
|
+
const GORTEX_MESSAGE_TYPE = "gortex";
|
|
117
|
+
|
|
118
|
+
interface GortexMessageDetails {
|
|
119
|
+
/** Whether the content includes the session briefing, not only warnings. */
|
|
120
|
+
briefing: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Kept out of the transcript; Pi still sends it to the model. */
|
|
124
|
+
function gortexMessage(content: string, briefing: boolean): BeforeAgentStartEventResult {
|
|
125
|
+
const details: GortexMessageDetails = { briefing };
|
|
126
|
+
return { message: { customType: GORTEX_MESSAGE_TYPE, content, display: false, details } };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether a briefing already sits in what the model sees: after a resume or
|
|
131
|
+
* /reload, or when compaction kept it. Answers false when Pi can't tell.
|
|
132
|
+
*/
|
|
133
|
+
function briefingInContext(ctx: ExtensionContext | undefined): boolean {
|
|
134
|
+
try {
|
|
135
|
+
return (ctx?.sessionManager.buildContextEntries() ?? []).some(
|
|
136
|
+
(entry) =>
|
|
137
|
+
entry.type === "custom_message" &&
|
|
138
|
+
entry.customType === GORTEX_MESSAGE_TYPE &&
|
|
139
|
+
(entry.details as GortexMessageDetails | undefined)?.briefing === true,
|
|
140
|
+
);
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
112
146
|
/** Test seams. Pi itself calls the factory with the `pi` object alone. */
|
|
113
147
|
export interface GortexExtensionOptions {
|
|
114
|
-
/**
|
|
148
|
+
/** Fixed configuration; resolveConfig() runs per session when this is absent. */
|
|
115
149
|
config?: GortexConfig;
|
|
150
|
+
/** resolveConfig() seams; `cwd` stands in for the process cwd until a session supplies one. */
|
|
151
|
+
resolve?: ResolveConfigOptions;
|
|
116
152
|
/** Process seam for spawn/execFileSync. */
|
|
117
153
|
deps?: ProcessDeps;
|
|
118
154
|
/** Readiness cap override. */
|
|
119
155
|
readyWaitMs?: number;
|
|
120
156
|
/** Override for the wait on Pi's built-in MCP connecting the gortex server. */
|
|
121
157
|
nativeReadyWaitMs?: number;
|
|
158
|
+
/** tools/call timeout override for the extension's own client. */
|
|
159
|
+
callTimeoutMs?: number;
|
|
160
|
+
/** Frame size cap override for the extension's own client. */
|
|
161
|
+
maxFrameBytes?: number;
|
|
122
162
|
}
|
|
123
163
|
|
|
124
164
|
export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensionOptions = {}): void {
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
165
|
+
// Resolved again on every session_start from ctx.cwd, the directory the
|
|
166
|
+
// session runs in. The factory has no session, so this first resolution only
|
|
167
|
+
// serves a turn that outruns session_start.
|
|
168
|
+
const configFor = (cwd?: string): GortexConfig =>
|
|
169
|
+
options.config ?? resolveConfig({ ...options.resolve, cwd: cwd ?? options.resolve?.cwd });
|
|
170
|
+
let config = configFor();
|
|
130
171
|
const deps = options.deps ?? nodeProcessDeps;
|
|
131
172
|
const readyWaitMs = options.readyWaitMs ?? READY_WAIT_MS;
|
|
132
173
|
const nativeReadyWaitMs = options.nativeReadyWaitMs ?? NATIVE_READY_WAIT_MS;
|
|
@@ -135,20 +176,35 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
135
176
|
// (which starts the daemon itself), registers the tools and renders them.
|
|
136
177
|
// A binary that does not resolve, or a registration Pi refuses, falls back to
|
|
137
178
|
// the extension's own client, which reports its failure to the model.
|
|
179
|
+
// Registered on session_start only, once the session's config is known: Pi
|
|
180
|
+
// reads a registration made before its own session_start handler, and
|
|
181
|
+
// connects one made after it.
|
|
138
182
|
let nativeRefusal = "";
|
|
139
|
-
let native =
|
|
140
|
-
|
|
183
|
+
let native = false;
|
|
184
|
+
let registered: GortexConfig | null = null;
|
|
185
|
+
function registerNative(connector: boolean): void {
|
|
186
|
+
if (native) unregisterNativeServer(pi);
|
|
187
|
+
native = false;
|
|
188
|
+
registered = config;
|
|
189
|
+
nativeRefusal = "";
|
|
190
|
+
if (!connector || !config.nativeMcp || !supportsNativeMcp(pi) || !binaryResolves(config.bin)) return;
|
|
141
191
|
nativeRefusal = registerNativeServer(pi, config);
|
|
142
|
-
|
|
143
|
-
}
|
|
192
|
+
native = nativeRefusal === "";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Re-registering an unchanged server would make Pi reconnect it.
|
|
196
|
+
const channelChanged = (): boolean =>
|
|
197
|
+
registered === null ||
|
|
198
|
+
registered.nativeMcp !== config.nativeMcp ||
|
|
199
|
+
registered.toolsPreset !== config.toolsPreset;
|
|
144
200
|
|
|
145
201
|
// Latched for the whole session once the first turn has run the
|
|
146
202
|
// session_start hook, whether or not it produced anything to inject.
|
|
147
203
|
let startupHookRan = false;
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
let
|
|
204
|
+
// This session's briefing, kept so a compaction that summarises it away can
|
|
205
|
+
// send it again on the next turn.
|
|
206
|
+
let briefing = "";
|
|
207
|
+
let briefingCompacted = false;
|
|
152
208
|
|
|
153
209
|
// Settles when the current session_start handler is done: bridge up and
|
|
154
210
|
// tools registered, or the handshake failed. Never rejects.
|
|
@@ -198,7 +254,8 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
198
254
|
// session's bridge child (if any) is stopped before a fresh handshake.
|
|
199
255
|
pi.on("session_start", async (_event, ctx) => {
|
|
200
256
|
startupHookRan = false;
|
|
201
|
-
|
|
257
|
+
briefing = "";
|
|
258
|
+
briefingCompacted = false;
|
|
202
259
|
setBridgeError("");
|
|
203
260
|
setVersionWarning("");
|
|
204
261
|
armSessionReady(); // no-op when a turn is already parked on this session
|
|
@@ -224,7 +281,10 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
224
281
|
// startSession holds the body of session_start so the readiness promise
|
|
225
282
|
// above settles on every exit path, early returns included.
|
|
226
283
|
async function startSession(ctx?: ExtensionContext): Promise<void> {
|
|
227
|
-
|
|
284
|
+
config = configFor(ctx?.cwd);
|
|
285
|
+
const connector = mcpConnectorLoaded(pi);
|
|
286
|
+
if (channelChanged()) registerNative(connector);
|
|
287
|
+
if (native && !connector) {
|
|
228
288
|
unregisterNativeServer(pi);
|
|
229
289
|
native = false;
|
|
230
290
|
}
|
|
@@ -232,8 +292,16 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
232
292
|
if (nativeRefusal) notifyUser(ctx, `Pi refused the gortex MCP server (${nativeRefusal}), so the extension runs its own client.`);
|
|
233
293
|
ensureDaemon(config.bin, deps);
|
|
234
294
|
gortexToolNames.clear();
|
|
295
|
+
withdrawnToolNames.clear();
|
|
235
296
|
stopBridge();
|
|
236
|
-
const c = new MCPStdioClient({
|
|
297
|
+
const c = new MCPStdioClient({
|
|
298
|
+
bin: config.bin,
|
|
299
|
+
toolsPreset: config.toolsPreset,
|
|
300
|
+
cwd: ctx?.cwd,
|
|
301
|
+
deps,
|
|
302
|
+
callTimeoutMs: options.callTimeoutMs,
|
|
303
|
+
maxFrameBytes: options.maxFrameBytes,
|
|
304
|
+
});
|
|
237
305
|
// Claim the slot before the first await: an overlapping
|
|
238
306
|
// session_start (rapid /new or /reload) then sees and stops THIS
|
|
239
307
|
// bridge instead of leaking its child mid-handshake.
|
|
@@ -258,10 +326,20 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
258
326
|
}
|
|
259
327
|
}
|
|
260
328
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
329
|
+
pi.on("session_compact", () => {
|
|
330
|
+
briefingCompacted = true;
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// Fires before the agent loop's first LLM call. The returned message is
|
|
334
|
+
// stored in the session after the prompt, so every later LLM call carries it
|
|
335
|
+
// and the system prompt, which prefix caching keys on, stays untouched.
|
|
336
|
+
pi.on("before_agent_start", async (_event, ctx): Promise<BeforeAgentStartEventResult | void> => {
|
|
337
|
+
if (startupHookRan) {
|
|
338
|
+
if (!briefingCompacted) return;
|
|
339
|
+
briefingCompacted = false;
|
|
340
|
+
if (!briefing || briefingInContext(ctx)) return;
|
|
341
|
+
return gortexMessage(briefing, true);
|
|
342
|
+
}
|
|
265
343
|
startupHookRan = true; // before the first await, so a second turn can't race in
|
|
266
344
|
// Pi awaits each listener, so this holds the turn until the tools are
|
|
267
345
|
// registered and bridgeError reflects the handshake (or the cap expires).
|
|
@@ -270,7 +348,7 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
270
348
|
const ready = native ? await waitForNativeTools(pi, nativeReadyWaitMs) : sessionDone;
|
|
271
349
|
const decision = callHook(config.hookArgv, deps, {
|
|
272
350
|
event: "session_start",
|
|
273
|
-
cwd: ctx?.cwd ??
|
|
351
|
+
cwd: ctx?.cwd ?? process.cwd(),
|
|
274
352
|
});
|
|
275
353
|
// Pi's MCP keeps the handshake to itself, so the version comes from the briefing.
|
|
276
354
|
if (native && decision.orientation) {
|
|
@@ -300,30 +378,15 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
300
378
|
parts.push(versionWarning);
|
|
301
379
|
setVersionWarning("");
|
|
302
380
|
}
|
|
303
|
-
|
|
304
|
-
if (
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
// parked orientation once, clearing it only after the push lands so the
|
|
310
|
-
// orientation survives a context shape this hook can't append to.
|
|
311
|
-
// Pi exports ContextEvent but not its result type, so it is spelled out here.
|
|
312
|
-
pi.on("context", (event: ContextEvent): { messages?: ContextEvent["messages"] } | void => {
|
|
313
|
-
if (!pendingOrientation) return;
|
|
314
|
-
try {
|
|
315
|
-
const messages = (event as { messages?: unknown })?.messages;
|
|
316
|
-
if (Array.isArray(messages)) {
|
|
317
|
-
// Pi's AgentMessage also carries a `timestamp`; only the two fields
|
|
318
|
-
// Pi reads for a tail user turn are pushed.
|
|
319
|
-
messages.push({ role: "user", content: pendingOrientation } as ContextEvent["messages"][number]);
|
|
320
|
-
pendingOrientation = "";
|
|
321
|
-
return { messages: messages as ContextEvent["messages"] };
|
|
322
|
-
}
|
|
323
|
-
} catch {
|
|
324
|
-
// best effort; never break context assembly.
|
|
381
|
+
let briefed = false;
|
|
382
|
+
if (decision.orientation) {
|
|
383
|
+
briefing = decision.orientation;
|
|
384
|
+
// A resumed or reloaded session already holds it.
|
|
385
|
+
briefed = !briefingInContext(ctx);
|
|
386
|
+
if (briefed) parts.push(decision.orientation);
|
|
325
387
|
}
|
|
326
|
-
return;
|
|
388
|
+
if (parts.length === 0) return;
|
|
389
|
+
return gortexMessage(parts.join("\n\n"), briefed);
|
|
327
390
|
});
|
|
328
391
|
|
|
329
392
|
// Never let the bridge child outlive the session.
|
|
@@ -331,10 +394,8 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
331
394
|
stopBridge();
|
|
332
395
|
});
|
|
333
396
|
|
|
334
|
-
if (!config.enforce) return;
|
|
335
|
-
|
|
336
397
|
// Enforcement: every non-Gortex tool call is checked against the Go hook.
|
|
337
|
-
|
|
398
|
+
const enforceReadDiscipline = async (event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | void> => {
|
|
338
399
|
const piName: string = (event as { toolName?: string })?.toolName ?? "";
|
|
339
400
|
const piInput: Record<string, unknown> = (event?.input as Record<string, unknown>) ?? {};
|
|
340
401
|
const isGortexTool = native ? isNativeGortexTool(piName) : gortexToolNames.has(piName);
|
|
@@ -347,7 +408,7 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
347
408
|
event: "tool_call",
|
|
348
409
|
tool_name: norm.tool_name,
|
|
349
410
|
tool_input: norm.tool_input,
|
|
350
|
-
cwd: ctx?.cwd ??
|
|
411
|
+
cwd: ctx?.cwd ?? process.cwd(),
|
|
351
412
|
session_id: (ctx as { sessionManager?: { sessionId?: string } })?.sessionManager?.sessionId ?? "",
|
|
352
413
|
is_gortex_tool: isGortexTool,
|
|
353
414
|
});
|
|
@@ -367,5 +428,19 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
367
428
|
}
|
|
368
429
|
}
|
|
369
430
|
return;
|
|
370
|
-
}
|
|
431
|
+
};
|
|
432
|
+
// Registered unconditionally, since a session's own sidecar can turn enforcement on.
|
|
433
|
+
pi.on("tool_call", (event, ctx) => (config.enforce ? enforceReadDiscipline(event, ctx) : undefined));
|
|
434
|
+
|
|
435
|
+
// After enforcement, so a blocked call is never snapshotted. Shell calls are
|
|
436
|
+
// tracked on both channels; edits are snapshotted on Pi's built-in MCP only.
|
|
437
|
+
watchNativeEdits(pi, () => native);
|
|
438
|
+
if (supportsNativeMcp(pi)) {
|
|
439
|
+
// Pi < 1.0.1 has no tool renderers; the diffs are still stored.
|
|
440
|
+
try {
|
|
441
|
+
(pi as Partial<Pick<ExtensionAPI, "registerToolRenderer">>).registerToolRenderer?.(nativeRenderers);
|
|
442
|
+
} catch {
|
|
443
|
+
// drawing diffs is optional
|
|
444
|
+
}
|
|
445
|
+
}
|
|
371
446
|
}
|
package/src/mcp-client.ts
CHANGED
|
@@ -46,36 +46,30 @@ export function isBelowVersion(actual: string, floor: string): boolean {
|
|
|
46
46
|
return false;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
// Presets the proxy has to be told about. The daemon's own default surface
|
|
50
|
-
// already IS core/defer, so those pass nothing. Anything unrecognised (a typo,
|
|
51
|
-
// a stale sidecar value) is NOT forwarded: server-side it would parse as a
|
|
52
|
-
// one-tool allow-list and silently filter every promoted tool out of the
|
|
53
|
-
// session. Fail open to the daemon's default surface instead.
|
|
54
|
-
const FORWARDED_PRESETS = new Set(["edit", "nav", "readonly"]);
|
|
55
|
-
|
|
56
49
|
/**
|
|
57
|
-
* The variables `gortex mcp` needs on top of `env` to serve `toolsPreset
|
|
58
|
-
*
|
|
59
|
-
*
|
|
50
|
+
* The variables `gortex mcp` needs on top of `env` to serve `toolsPreset`, the
|
|
51
|
+
* spec resolveConfig validated (it already folds in the user's GORTEX_TOOLS).
|
|
52
|
+
* Both are always set, so an inherited value resolveConfig rejected never
|
|
53
|
+
* reaches the child; empty leaves the daemon's default surface.
|
|
60
54
|
*/
|
|
61
55
|
export function presetEnv(
|
|
62
56
|
toolsPreset: string,
|
|
63
57
|
env: Record<string, string | undefined>,
|
|
64
58
|
): Record<string, string> {
|
|
65
|
-
|
|
66
|
-
const preset = toolsPreset.trim().toLowerCase();
|
|
67
|
-
const tools = env.GORTEX_TOOLS || (FORWARDED_PRESETS.has(preset) ? preset : "");
|
|
68
|
-
if (tools && !env.GORTEX_TOOLS) out.GORTEX_TOOLS = tools;
|
|
59
|
+
if (toolsPreset === "") return { GORTEX_TOOLS: "", GORTEX_TOOLS_MODE: "" };
|
|
69
60
|
// Never let a preset hide-block tools promoted later by tools_search.
|
|
70
|
-
|
|
71
|
-
return out;
|
|
61
|
+
return { GORTEX_TOOLS: toolsPreset, GORTEX_TOOLS_MODE: env.GORTEX_TOOLS_MODE?.trim() || "defer" };
|
|
72
62
|
}
|
|
73
63
|
|
|
74
64
|
export interface MCPStdioClientOptions {
|
|
75
65
|
bin: string;
|
|
76
66
|
toolsPreset: string;
|
|
67
|
+
/** The session's directory, which `gortex mcp` scopes the graph to. Default: the process cwd. */
|
|
68
|
+
cwd?: string;
|
|
77
69
|
deps: ProcessDeps;
|
|
78
70
|
env?: Record<string, string | undefined>;
|
|
71
|
+
callTimeoutMs?: number;
|
|
72
|
+
maxFrameBytes?: number;
|
|
79
73
|
}
|
|
80
74
|
|
|
81
75
|
interface PendingRequest {
|
|
@@ -84,6 +78,25 @@ interface PendingRequest {
|
|
|
84
78
|
timer?: ReturnType<typeof setTimeout>;
|
|
85
79
|
}
|
|
86
80
|
|
|
81
|
+
/** A JSON-RPC error reply, with the server's code and data kept for the model. */
|
|
82
|
+
export class JsonRpcError extends Error {
|
|
83
|
+
readonly code: number | undefined;
|
|
84
|
+
readonly data: unknown;
|
|
85
|
+
|
|
86
|
+
constructor(error: { code?: unknown; message?: unknown; data?: unknown }) {
|
|
87
|
+
const code = typeof error.code === "number" ? error.code : undefined;
|
|
88
|
+
let message = typeof error.message === "string" && error.message ? error.message : "JSON-RPC error";
|
|
89
|
+
if (code !== undefined) message += ` (${code})`;
|
|
90
|
+
if (error.data !== undefined) {
|
|
91
|
+
message += `: ${typeof error.data === "string" ? error.data : JSON.stringify(error.data)}`;
|
|
92
|
+
}
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = "JsonRpcError";
|
|
95
|
+
this.code = code;
|
|
96
|
+
this.data = error.data;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
export interface ToolDescriptor {
|
|
88
101
|
name: string;
|
|
89
102
|
description?: string;
|
|
@@ -102,6 +115,7 @@ export function ensureDaemon(bin: string, deps: ProcessDeps): void {
|
|
|
102
115
|
const child = deps.spawn(bin, ["daemon", "start", "--detach"], {
|
|
103
116
|
stdio: "ignore",
|
|
104
117
|
detached: true,
|
|
118
|
+
windowsHide: true,
|
|
105
119
|
});
|
|
106
120
|
child.on("error", () => {}); // binary missing / spawn failure: swallow
|
|
107
121
|
// No teardown counterpart, by design: the daemon is shared, long-lived
|
|
@@ -127,8 +141,10 @@ export function textFromResult(result: unknown): string {
|
|
|
127
141
|
|
|
128
142
|
export class MCPStdioClient {
|
|
129
143
|
private child: BridgeChild | null = null;
|
|
130
|
-
|
|
131
|
-
|
|
144
|
+
// Raw bytes of the frame still being read. Frames are cut at the 0x0A byte
|
|
145
|
+
// and decoded whole, so a character split across chunks survives.
|
|
146
|
+
private chunks: Buffer[] = [];
|
|
147
|
+
private bufferedBytes = 0;
|
|
132
148
|
private nextId = 1;
|
|
133
149
|
private pending = new Map<number, PendingRequest>();
|
|
134
150
|
private exited = true;
|
|
@@ -136,8 +152,11 @@ export class MCPStdioClient {
|
|
|
136
152
|
|
|
137
153
|
private readonly bin: string;
|
|
138
154
|
private readonly toolsPreset: string;
|
|
155
|
+
private readonly cwd: string | undefined;
|
|
139
156
|
private readonly deps: ProcessDeps;
|
|
140
157
|
private readonly env: Record<string, string | undefined>;
|
|
158
|
+
private readonly callTimeoutMs: number;
|
|
159
|
+
private readonly maxFrameBytes: number;
|
|
141
160
|
|
|
142
161
|
// Fired on notifications/tools/list_changed (server-side promotion).
|
|
143
162
|
onToolsListChanged: (() => void) | null = null;
|
|
@@ -150,8 +169,11 @@ export class MCPStdioClient {
|
|
|
150
169
|
constructor(options: MCPStdioClientOptions) {
|
|
151
170
|
this.bin = options.bin;
|
|
152
171
|
this.toolsPreset = options.toolsPreset;
|
|
172
|
+
this.cwd = options.cwd;
|
|
153
173
|
this.deps = options.deps;
|
|
154
174
|
this.env = options.env ?? process.env;
|
|
175
|
+
this.callTimeoutMs = options.callTimeoutMs ?? CALL_TIMEOUT_MS;
|
|
176
|
+
this.maxFrameBytes = options.maxFrameBytes ?? MAX_BUFFER_BYTES;
|
|
155
177
|
}
|
|
156
178
|
|
|
157
179
|
private childEnv(): Record<string, string | undefined> {
|
|
@@ -161,11 +183,13 @@ export class MCPStdioClient {
|
|
|
161
183
|
private spawnChild(): void {
|
|
162
184
|
const child = this.deps.spawn(this.bin, ["mcp"], {
|
|
163
185
|
stdio: ["pipe", "pipe", "pipe"],
|
|
186
|
+
cwd: this.cwd,
|
|
164
187
|
env: this.childEnv(),
|
|
188
|
+
windowsHide: true,
|
|
165
189
|
});
|
|
166
190
|
this.child = child;
|
|
167
|
-
this.
|
|
168
|
-
this.
|
|
191
|
+
this.chunks = [];
|
|
192
|
+
this.bufferedBytes = 0;
|
|
169
193
|
this.exited = false;
|
|
170
194
|
child.stdout?.on("data", (chunk: unknown) => this.onData(chunk as Buffer));
|
|
171
195
|
// Consume stderr so the child never blocks on a full pipe; routing it
|
|
@@ -185,68 +209,93 @@ export class MCPStdioClient {
|
|
|
185
209
|
if (this.exited && this.pending.size === 0) return;
|
|
186
210
|
this.exited = true;
|
|
187
211
|
this.child = null;
|
|
188
|
-
|
|
189
|
-
if (p.timer) clearTimeout(p.timer);
|
|
190
|
-
p.reject(err);
|
|
191
|
-
}
|
|
212
|
+
const pending = [...this.pending.values()];
|
|
192
213
|
this.pending.clear();
|
|
214
|
+
for (const p of pending) p.reject(err);
|
|
193
215
|
}
|
|
194
216
|
|
|
195
217
|
private onData(chunk: Buffer): void {
|
|
196
|
-
|
|
218
|
+
let start = 0;
|
|
197
219
|
let nl: number;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
this.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
let msg: unknown;
|
|
207
|
-
try {
|
|
208
|
-
msg = JSON.parse(line);
|
|
209
|
-
} catch {
|
|
210
|
-
continue; // non-JSON noise, ignore
|
|
211
|
-
}
|
|
212
|
-
this.dispatch(msg);
|
|
220
|
+
while ((nl = chunk.indexOf(0x0a, start)) >= 0) {
|
|
221
|
+
if (this.bufferedBytes + nl - start > this.maxFrameBytes) return this.overflow();
|
|
222
|
+
const tail = chunk.subarray(start, nl);
|
|
223
|
+
const frame = this.chunks.length > 0 ? Buffer.concat([...this.chunks, tail]) : tail;
|
|
224
|
+
this.chunks = [];
|
|
225
|
+
this.bufferedBytes = 0;
|
|
226
|
+
start = nl + 1;
|
|
227
|
+
this.onFrame(frame.toString("utf8").trim());
|
|
213
228
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
229
|
+
if (start >= chunk.length) return;
|
|
230
|
+
this.chunks.push(start === 0 ? chunk : chunk.subarray(start));
|
|
231
|
+
this.bufferedBytes += chunk.length - start;
|
|
232
|
+
if (this.bufferedBytes > this.maxFrameBytes) this.overflow();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// One frame past the cap is fatal for this child (no way to resync
|
|
236
|
+
// mid-frame): pending requests reject, the next call respawns a fresh child.
|
|
237
|
+
private overflow(): void {
|
|
238
|
+
this.chunks = [];
|
|
239
|
+
this.bufferedBytes = 0;
|
|
240
|
+
const child = this.child;
|
|
241
|
+
this.markExited(new Error(`gortex mcp frame exceeded ${this.maxFrameBytes} bytes`));
|
|
242
|
+
try {
|
|
243
|
+
child?.kill();
|
|
244
|
+
} catch {
|
|
245
|
+
// already gone
|
|
228
246
|
}
|
|
229
247
|
}
|
|
230
248
|
|
|
231
|
-
private
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
249
|
+
private onFrame(line: string): void {
|
|
250
|
+
if (!line) return;
|
|
251
|
+
let msg: unknown;
|
|
252
|
+
try {
|
|
253
|
+
msg = JSON.parse(line);
|
|
254
|
+
} catch {
|
|
255
|
+
return; // non-JSON noise, ignore
|
|
256
|
+
}
|
|
257
|
+
if (msg && typeof msg === "object") this.dispatch(msg as Record<string, unknown>);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private dispatch(frame: Record<string, unknown>): void {
|
|
261
|
+
const { id, method } = frame;
|
|
262
|
+
if (typeof method === "string") {
|
|
263
|
+
if (typeof id === "number" || typeof id === "string") this.answerServerRequest(id, method);
|
|
264
|
+
else if (method === "notifications/tools/list_changed") {
|
|
265
|
+
try {
|
|
266
|
+
this.onToolsListChanged?.();
|
|
267
|
+
} catch {
|
|
268
|
+
// best effort; never break the read loop.
|
|
269
|
+
}
|
|
241
270
|
}
|
|
242
271
|
return;
|
|
243
272
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
273
|
+
const error = frame.error as Record<string, unknown> | undefined;
|
|
274
|
+
if (id === null && error) {
|
|
275
|
+
// The server could not tell which request this answers, so none of
|
|
276
|
+
// them will get a reply.
|
|
277
|
+
const pending = [...this.pending.values()];
|
|
278
|
+
this.pending.clear();
|
|
279
|
+
for (const p of pending) p.reject(new JsonRpcError(error));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (typeof id !== "number") return;
|
|
283
|
+
const p = this.pending.get(id);
|
|
284
|
+
if (!p) return;
|
|
285
|
+
this.pending.delete(id);
|
|
286
|
+
if (error) p.reject(new JsonRpcError(error));
|
|
287
|
+
else p.resolve(frame.result);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private answerServerRequest(id: number | string, method: string): void {
|
|
291
|
+
const reply =
|
|
292
|
+
method === "ping"
|
|
293
|
+
? { jsonrpc: "2.0", id, result: {} }
|
|
294
|
+
: { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } };
|
|
295
|
+
try {
|
|
296
|
+
this.send(reply);
|
|
297
|
+
} catch {
|
|
298
|
+
// the child is gone; nothing is waiting on the answer.
|
|
250
299
|
}
|
|
251
300
|
}
|
|
252
301
|
|
|
@@ -270,24 +319,54 @@ export class MCPStdioClient {
|
|
|
270
319
|
return this.respawnPromise;
|
|
271
320
|
}
|
|
272
321
|
|
|
273
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Sends one request. On abort or timeout the call is dropped and the server
|
|
324
|
+
* is sent notifications/cancelled, so a late reply is ignored.
|
|
325
|
+
*/
|
|
326
|
+
async request(
|
|
327
|
+
method: string,
|
|
328
|
+
params: unknown,
|
|
329
|
+
timeoutMs: number = RPC_TIMEOUT_MS,
|
|
330
|
+
signal?: AbortSignal,
|
|
331
|
+
): Promise<unknown> {
|
|
332
|
+
const aborted = () => new Error(`${method} aborted`);
|
|
333
|
+
if (signal?.aborted) throw aborted();
|
|
274
334
|
if (this.exited) await this.respawn();
|
|
335
|
+
if (signal?.aborted) throw aborted();
|
|
275
336
|
const id = this.nextId++;
|
|
276
337
|
return new Promise((resolve, reject) => {
|
|
277
|
-
const
|
|
338
|
+
const onAbort = () => abandon(aborted());
|
|
339
|
+
const settled = () => {
|
|
340
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
341
|
+
signal?.removeEventListener("abort", onAbort);
|
|
342
|
+
};
|
|
343
|
+
const entry: PendingRequest = {
|
|
344
|
+
resolve: (value) => {
|
|
345
|
+
settled();
|
|
346
|
+
resolve(value);
|
|
347
|
+
},
|
|
348
|
+
reject: (err) => {
|
|
349
|
+
settled();
|
|
350
|
+
reject(err);
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
const abandon = (err: Error) => {
|
|
354
|
+
if (this.pending.get(id) !== entry) return;
|
|
355
|
+
this.pending.delete(id);
|
|
356
|
+
entry.reject(err);
|
|
357
|
+
// The MCP spec forbids cancelling initialize.
|
|
358
|
+
if (method !== "initialize") this.notify("notifications/cancelled", { requestId: id, reason: err.message });
|
|
359
|
+
};
|
|
278
360
|
if (Number.isFinite(timeoutMs)) {
|
|
279
|
-
entry.timer = setTimeout(() => {
|
|
280
|
-
this.pending.delete(id);
|
|
281
|
-
reject(new Error(`${method} timed out after ${timeoutMs}ms`));
|
|
282
|
-
}, timeoutMs);
|
|
361
|
+
entry.timer = setTimeout(() => abandon(new Error(`${method} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
283
362
|
}
|
|
363
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
284
364
|
this.pending.set(id, entry);
|
|
285
365
|
try {
|
|
286
366
|
this.send({ jsonrpc: "2.0", id, method, params });
|
|
287
367
|
} catch (err) {
|
|
288
368
|
this.pending.delete(id);
|
|
289
|
-
|
|
290
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
369
|
+
entry.reject(err instanceof Error ? err : new Error(String(err)));
|
|
291
370
|
}
|
|
292
371
|
});
|
|
293
372
|
}
|
|
@@ -336,8 +415,8 @@ export class MCPStdioClient {
|
|
|
336
415
|
return Array.isArray(res?.tools) ? (res.tools as ToolDescriptor[]) : [];
|
|
337
416
|
}
|
|
338
417
|
|
|
339
|
-
async callTool(name: string, args: Record<string, unknown
|
|
340
|
-
return this.request("tools/call", { name, arguments: args ?? {} },
|
|
418
|
+
async callTool(name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
|
|
419
|
+
return this.request("tools/call", { name, arguments: args ?? {} }, this.callTimeoutMs, signal);
|
|
341
420
|
}
|
|
342
421
|
|
|
343
422
|
stop(): void {
|
package/src/native.ts
CHANGED
|
@@ -18,7 +18,7 @@ const SERVER_DESCRIPTION =
|
|
|
18
18
|
"Gortex code graph of the indexed repositories: symbol search, source reads, usages, " +
|
|
19
19
|
"call chains, change impact and graph-aware edits.";
|
|
20
20
|
|
|
21
|
-
// Pi 0.
|
|
21
|
+
// Read structurally, since a Pi before 0.99 has none of these members.
|
|
22
22
|
type NativeMcpApi = Pick<ExtensionAPI, "registerMcpServer" | "unregisterMcpServer" | "getAllTools" | "getCommands">;
|
|
23
23
|
|
|
24
24
|
export function supportsNativeMcp(pi: ExtensionAPI): boolean {
|