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/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
- ContextEvent,
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
- /** Pre-resolved configuration; resolveConfig() runs when this is absent. */
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
- const config = options.config ?? resolveConfig();
126
- // Pi 0.85's ExtensionAPI declares no `cwd`, so the hook envelope reads it
127
- // defensively and falls back to the process cwd. A Pi version that grows the
128
- // field is picked up when it exists.
129
- const piCwd = (): string => (pi as { cwd?: string })?.cwd ?? process.cwd();
165
+ // Resolved again on every session_start from ctx.cwd, the directory the
166
+ // session runs in. The factory has no session, so this first resolution only
167
+ // serves a turn that outruns session_start.
168
+ const configFor = (cwd?: string): GortexConfig =>
169
+ options.config ?? resolveConfig({ ...options.resolve, cwd: cwd ?? options.resolve?.cwd });
170
+ let config = configFor();
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 = ((): boolean => {
140
- if (!config.nativeMcp || !supportsNativeMcp(pi) || !binaryResolves(config.bin)) return false;
183
+ let native = false;
184
+ let registered: GortexConfig | null = null;
185
+ function registerNative(connector: boolean): void {
186
+ if (native) unregisterNativeServer(pi);
187
+ native = false;
188
+ registered = config;
189
+ nativeRefusal = "";
190
+ if (!connector || !config.nativeMcp || !supportsNativeMcp(pi) || !binaryResolves(config.bin)) return;
141
191
  nativeRefusal = registerNativeServer(pi, config);
142
- return nativeRefusal === "";
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
- // Orientation awaiting injection into the next LLM call, computed once per
149
- // session. The `context` hook appends it as a tail user message, because a
150
- // systemPrompt change sits at messages[0] and invalidates prefix caching.
151
- let pendingOrientation = "";
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
- pendingOrientation = "";
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
- if (native && !mcpConnectorLoaded(pi)) {
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({ bin: config.bin, toolsPreset: config.toolsPreset, deps });
297
+ const c = new MCPStdioClient({
298
+ bin: config.bin,
299
+ toolsPreset: config.toolsPreset,
300
+ cwd: ctx?.cwd,
301
+ deps,
302
+ callTimeoutMs: options.callTimeoutMs,
303
+ maxFrameBytes: options.maxFrameBytes,
304
+ });
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
- // Fires before the agent loop's first LLM call. It can't mutate messages
262
- // itself, so it just parks the orientation for the `context` hook.
263
- pi.on("before_agent_start", async (_event, ctx) => {
264
- if (startupHookRan) return;
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 ?? piCwd(),
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
- if (decision.orientation) parts.push(decision.orientation);
304
- if (parts.length > 0) pendingOrientation = parts.join("\n\n");
305
- return;
306
- });
307
-
308
- // Fires before each LLM call with a mutable message array. Appends the
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
- pi.on("tool_call", async (event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | void> => {
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 ?? piCwd(),
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
- * The daemon's own default surface already IS core/defer; only a recognised
59
- * non-default preset needs the proxy-side narrowing.
50
+ * The variables `gortex mcp` needs on top of `env` to serve `toolsPreset`, the
51
+ * spec resolveConfig validated (it already folds in the user's GORTEX_TOOLS).
52
+ * Both are always set, so an inherited value resolveConfig rejected never
53
+ * reaches the child; empty leaves the daemon's default surface.
60
54
  */
61
55
  export function presetEnv(
62
56
  toolsPreset: string,
63
57
  env: Record<string, string | undefined>,
64
58
  ): Record<string, string> {
65
- const out: Record<string, string> = {};
66
- const preset = toolsPreset.trim().toLowerCase();
67
- const tools = env.GORTEX_TOOLS || (FORWARDED_PRESETS.has(preset) ? preset : "");
68
- if (tools && !env.GORTEX_TOOLS) out.GORTEX_TOOLS = tools;
59
+ if (toolsPreset === "") return { GORTEX_TOOLS: "", GORTEX_TOOLS_MODE: "" };
69
60
  // Never let a preset hide-block tools promoted later by tools_search.
70
- if (tools && !env.GORTEX_TOOLS_MODE) out.GORTEX_TOOLS_MODE = "defer";
71
- return out;
61
+ return { GORTEX_TOOLS: toolsPreset, GORTEX_TOOLS_MODE: env.GORTEX_TOOLS_MODE?.trim() || "defer" };
72
62
  }
73
63
 
74
64
  export interface MCPStdioClientOptions {
75
65
  bin: string;
76
66
  toolsPreset: string;
67
+ /** The session's directory, which `gortex mcp` scopes the graph to. Default: the process cwd. */
68
+ cwd?: string;
77
69
  deps: ProcessDeps;
78
70
  env?: Record<string, string | undefined>;
71
+ callTimeoutMs?: number;
72
+ maxFrameBytes?: number;
79
73
  }
80
74
 
81
75
  interface PendingRequest {
@@ -84,6 +78,25 @@ interface PendingRequest {
84
78
  timer?: ReturnType<typeof setTimeout>;
85
79
  }
86
80
 
81
+ /** A JSON-RPC error reply, with the server's code and data kept for the model. */
82
+ export class JsonRpcError extends Error {
83
+ readonly code: number | undefined;
84
+ readonly data: unknown;
85
+
86
+ constructor(error: { code?: unknown; message?: unknown; data?: unknown }) {
87
+ const code = typeof error.code === "number" ? error.code : undefined;
88
+ let message = typeof error.message === "string" && error.message ? error.message : "JSON-RPC error";
89
+ if (code !== undefined) message += ` (${code})`;
90
+ if (error.data !== undefined) {
91
+ message += `: ${typeof error.data === "string" ? error.data : JSON.stringify(error.data)}`;
92
+ }
93
+ super(message);
94
+ this.name = "JsonRpcError";
95
+ this.code = code;
96
+ this.data = error.data;
97
+ }
98
+ }
99
+
87
100
  export interface ToolDescriptor {
88
101
  name: string;
89
102
  description?: string;
@@ -102,6 +115,7 @@ export function ensureDaemon(bin: string, deps: ProcessDeps): void {
102
115
  const child = deps.spawn(bin, ["daemon", "start", "--detach"], {
103
116
  stdio: "ignore",
104
117
  detached: true,
118
+ windowsHide: true,
105
119
  });
106
120
  child.on("error", () => {}); // binary missing / spawn failure: swallow
107
121
  // No teardown counterpart, by design: the daemon is shared, long-lived
@@ -127,8 +141,10 @@ export function textFromResult(result: unknown): string {
127
141
 
128
142
  export class MCPStdioClient {
129
143
  private child: BridgeChild | null = null;
130
- private buffer = "";
131
- private searchStart = 0;
144
+ // Raw bytes of the frame still being read. Frames are cut at the 0x0A byte
145
+ // and decoded whole, so a character split across chunks survives.
146
+ private chunks: Buffer[] = [];
147
+ private bufferedBytes = 0;
132
148
  private nextId = 1;
133
149
  private pending = new Map<number, PendingRequest>();
134
150
  private exited = true;
@@ -136,8 +152,11 @@ export class MCPStdioClient {
136
152
 
137
153
  private readonly bin: string;
138
154
  private readonly toolsPreset: string;
155
+ private readonly cwd: string | undefined;
139
156
  private readonly deps: ProcessDeps;
140
157
  private readonly env: Record<string, string | undefined>;
158
+ private readonly callTimeoutMs: number;
159
+ private readonly maxFrameBytes: number;
141
160
 
142
161
  // Fired on notifications/tools/list_changed (server-side promotion).
143
162
  onToolsListChanged: (() => void) | null = null;
@@ -150,8 +169,11 @@ export class MCPStdioClient {
150
169
  constructor(options: MCPStdioClientOptions) {
151
170
  this.bin = options.bin;
152
171
  this.toolsPreset = options.toolsPreset;
172
+ this.cwd = options.cwd;
153
173
  this.deps = options.deps;
154
174
  this.env = options.env ?? process.env;
175
+ this.callTimeoutMs = options.callTimeoutMs ?? CALL_TIMEOUT_MS;
176
+ this.maxFrameBytes = options.maxFrameBytes ?? MAX_BUFFER_BYTES;
155
177
  }
156
178
 
157
179
  private childEnv(): Record<string, string | undefined> {
@@ -161,11 +183,13 @@ export class MCPStdioClient {
161
183
  private spawnChild(): void {
162
184
  const child = this.deps.spawn(this.bin, ["mcp"], {
163
185
  stdio: ["pipe", "pipe", "pipe"],
186
+ cwd: this.cwd,
164
187
  env: this.childEnv(),
188
+ windowsHide: true,
165
189
  });
166
190
  this.child = child;
167
- this.buffer = "";
168
- this.searchStart = 0;
191
+ this.chunks = [];
192
+ this.bufferedBytes = 0;
169
193
  this.exited = false;
170
194
  child.stdout?.on("data", (chunk: unknown) => this.onData(chunk as Buffer));
171
195
  // Consume stderr so the child never blocks on a full pipe; routing it
@@ -185,68 +209,93 @@ export class MCPStdioClient {
185
209
  if (this.exited && this.pending.size === 0) return;
186
210
  this.exited = true;
187
211
  this.child = null;
188
- for (const [, p] of this.pending) {
189
- if (p.timer) clearTimeout(p.timer);
190
- p.reject(err);
191
- }
212
+ const pending = [...this.pending.values()];
192
213
  this.pending.clear();
214
+ for (const p of pending) p.reject(err);
193
215
  }
194
216
 
195
217
  private onData(chunk: Buffer): void {
196
- this.buffer += chunk.toString("utf8");
218
+ let start = 0;
197
219
  let nl: number;
198
- // Resume the newline scan where the previous chunk left off, so a
199
- // large frame split across many chunks does not re-scan the whole
200
- // growing buffer from index 0 on every data event.
201
- while ((nl = this.buffer.indexOf("\n", this.searchStart)) >= 0) {
202
- const line = this.buffer.slice(0, nl).trim();
203
- this.buffer = this.buffer.slice(nl + 1);
204
- this.searchStart = 0;
205
- if (!line) continue;
206
- let msg: unknown;
207
- try {
208
- msg = JSON.parse(line);
209
- } catch {
210
- continue; // non-JSON noise, ignore
211
- }
212
- this.dispatch(msg);
220
+ while ((nl = chunk.indexOf(0x0a, start)) >= 0) {
221
+ if (this.bufferedBytes + nl - start > this.maxFrameBytes) return this.overflow();
222
+ const tail = chunk.subarray(start, nl);
223
+ const frame = this.chunks.length > 0 ? Buffer.concat([...this.chunks, tail]) : tail;
224
+ this.chunks = [];
225
+ this.bufferedBytes = 0;
226
+ start = nl + 1;
227
+ this.onFrame(frame.toString("utf8").trim());
213
228
  }
214
- this.searchStart = this.buffer.length;
215
- if (this.buffer.length > MAX_BUFFER_BYTES) {
216
- // One frame past the cap is fatal for this child (no way to
217
- // resync mid-frame): pending requests reject, the next call
218
- // respawns a fresh child.
219
- this.buffer = "";
220
- this.searchStart = 0;
221
- const child = this.child;
222
- this.markExited(new Error(`gortex mcp frame exceeded ${MAX_BUFFER_BYTES} bytes`));
223
- try {
224
- child?.kill();
225
- } catch {
226
- // already gone
227
- }
229
+ if (start >= chunk.length) return;
230
+ this.chunks.push(start === 0 ? chunk : chunk.subarray(start));
231
+ this.bufferedBytes += chunk.length - start;
232
+ if (this.bufferedBytes > this.maxFrameBytes) this.overflow();
233
+ }
234
+
235
+ // One frame past the cap is fatal for this child (no way to resync
236
+ // mid-frame): pending requests reject, the next call respawns a fresh child.
237
+ private overflow(): void {
238
+ this.chunks = [];
239
+ this.bufferedBytes = 0;
240
+ const child = this.child;
241
+ this.markExited(new Error(`gortex mcp frame exceeded ${this.maxFrameBytes} bytes`));
242
+ try {
243
+ child?.kill();
244
+ } catch {
245
+ // already gone
228
246
  }
229
247
  }
230
248
 
231
- private dispatch(msg: unknown): void {
232
- const frame = msg as { id?: unknown; error?: { code?: number; message?: string }; result?: unknown; method?: unknown };
233
- if (frame && typeof frame.id === "number" && this.pending.has(frame.id)) {
234
- const p = this.pending.get(frame.id)!;
235
- this.pending.delete(frame.id);
236
- if (p.timer) clearTimeout(p.timer);
237
- if (frame.error) {
238
- p.reject(new Error(frame.error.message || `JSON-RPC error ${frame.error.code}`));
239
- } else {
240
- p.resolve(frame.result);
249
+ private onFrame(line: string): void {
250
+ if (!line) return;
251
+ let msg: unknown;
252
+ try {
253
+ msg = JSON.parse(line);
254
+ } catch {
255
+ return; // non-JSON noise, ignore
256
+ }
257
+ if (msg && typeof msg === "object") this.dispatch(msg as Record<string, unknown>);
258
+ }
259
+
260
+ private dispatch(frame: Record<string, unknown>): void {
261
+ const { id, method } = frame;
262
+ if (typeof method === "string") {
263
+ if (typeof id === "number" || typeof id === "string") this.answerServerRequest(id, method);
264
+ else if (method === "notifications/tools/list_changed") {
265
+ try {
266
+ this.onToolsListChanged?.();
267
+ } catch {
268
+ // best effort; never break the read loop.
269
+ }
241
270
  }
242
271
  return;
243
272
  }
244
- if (frame && frame.method === "notifications/tools/list_changed") {
245
- try {
246
- this.onToolsListChanged?.();
247
- } catch {
248
- // best effort; never break the read loop.
249
- }
273
+ const error = frame.error as Record<string, unknown> | undefined;
274
+ if (id === null && error) {
275
+ // The server could not tell which request this answers, so none of
276
+ // them will get a reply.
277
+ const pending = [...this.pending.values()];
278
+ this.pending.clear();
279
+ for (const p of pending) p.reject(new JsonRpcError(error));
280
+ return;
281
+ }
282
+ if (typeof id !== "number") return;
283
+ const p = this.pending.get(id);
284
+ if (!p) return;
285
+ this.pending.delete(id);
286
+ if (error) p.reject(new JsonRpcError(error));
287
+ else p.resolve(frame.result);
288
+ }
289
+
290
+ private answerServerRequest(id: number | string, method: string): void {
291
+ const reply =
292
+ method === "ping"
293
+ ? { jsonrpc: "2.0", id, result: {} }
294
+ : { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } };
295
+ try {
296
+ this.send(reply);
297
+ } catch {
298
+ // the child is gone; nothing is waiting on the answer.
250
299
  }
251
300
  }
252
301
 
@@ -270,24 +319,54 @@ export class MCPStdioClient {
270
319
  return this.respawnPromise;
271
320
  }
272
321
 
273
- async request(method: string, params: unknown, timeoutMs: number = RPC_TIMEOUT_MS): Promise<unknown> {
322
+ /**
323
+ * Sends one request. On abort or timeout the call is dropped and the server
324
+ * is sent notifications/cancelled, so a late reply is ignored.
325
+ */
326
+ async request(
327
+ method: string,
328
+ params: unknown,
329
+ timeoutMs: number = RPC_TIMEOUT_MS,
330
+ signal?: AbortSignal,
331
+ ): Promise<unknown> {
332
+ const aborted = () => new Error(`${method} aborted`);
333
+ if (signal?.aborted) throw aborted();
274
334
  if (this.exited) await this.respawn();
335
+ if (signal?.aborted) throw aborted();
275
336
  const id = this.nextId++;
276
337
  return new Promise((resolve, reject) => {
277
- const entry: PendingRequest = { resolve, reject };
338
+ const onAbort = () => abandon(aborted());
339
+ const settled = () => {
340
+ if (entry.timer) clearTimeout(entry.timer);
341
+ signal?.removeEventListener("abort", onAbort);
342
+ };
343
+ const entry: PendingRequest = {
344
+ resolve: (value) => {
345
+ settled();
346
+ resolve(value);
347
+ },
348
+ reject: (err) => {
349
+ settled();
350
+ reject(err);
351
+ },
352
+ };
353
+ const abandon = (err: Error) => {
354
+ if (this.pending.get(id) !== entry) return;
355
+ this.pending.delete(id);
356
+ entry.reject(err);
357
+ // The MCP spec forbids cancelling initialize.
358
+ if (method !== "initialize") this.notify("notifications/cancelled", { requestId: id, reason: err.message });
359
+ };
278
360
  if (Number.isFinite(timeoutMs)) {
279
- entry.timer = setTimeout(() => {
280
- this.pending.delete(id);
281
- reject(new Error(`${method} timed out after ${timeoutMs}ms`));
282
- }, timeoutMs);
361
+ entry.timer = setTimeout(() => abandon(new Error(`${method} timed out after ${timeoutMs}ms`)), timeoutMs);
283
362
  }
363
+ signal?.addEventListener("abort", onAbort, { once: true });
284
364
  this.pending.set(id, entry);
285
365
  try {
286
366
  this.send({ jsonrpc: "2.0", id, method, params });
287
367
  } catch (err) {
288
368
  this.pending.delete(id);
289
- if (entry.timer) clearTimeout(entry.timer);
290
- reject(err instanceof Error ? err : new Error(String(err)));
369
+ entry.reject(err instanceof Error ? err : new Error(String(err)));
291
370
  }
292
371
  });
293
372
  }
@@ -336,8 +415,8 @@ export class MCPStdioClient {
336
415
  return Array.isArray(res?.tools) ? (res.tools as ToolDescriptor[]) : [];
337
416
  }
338
417
 
339
- async callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
340
- return this.request("tools/call", { name, arguments: args ?? {} }, CALL_TIMEOUT_MS);
418
+ async callTool(name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
419
+ return this.request("tools/call", { name, arguments: args ?? {} }, this.callTimeoutMs, signal);
341
420
  }
342
421
 
343
422
  stop(): void {
package/src/native.ts CHANGED
@@ -18,7 +18,7 @@ const SERVER_DESCRIPTION =
18
18
  "Gortex code graph of the indexed repositories: symbol search, source reads, usages, " +
19
19
  "call chains, change impact and graph-aware edits.";
20
20
 
21
- // Pi 0.87 declares none of the MCP members, so they are read structurally.
21
+ // Read structurally, since a Pi before 0.99 has none of these members.
22
22
  type NativeMcpApi = Pick<ExtensionAPI, "registerMcpServer" | "unregisterMcpServer" | "getAllTools" | "getCommands">;
23
23
 
24
24
  export function supportsNativeMcp(pi: ExtensionAPI): boolean {