pi-gortex 0.1.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.
@@ -0,0 +1,338 @@
1
+ // MCP stdio client: newline-delimited JSON-RPC 2.0 over one `gortex mcp` child
2
+ // per session.
3
+
4
+ import type { BridgeChild, ProcessDeps } from "./runtime.ts";
5
+
6
+ export const INIT_TIMEOUT_MS = 60_000;
7
+ export const RPC_TIMEOUT_MS = 30_000;
8
+ // tools/call cap: generous enough for long analyzers, finite so a wedged
9
+ // daemon behind a still-alive child can't hang the agent turn forever.
10
+ export const CALL_TIMEOUT_MS = 600_000;
11
+ // A single JSON-RPC frame past this cap kills the child: the stream
12
+ // can't be resynced mid-frame.
13
+ export const MAX_BUFFER_BYTES = 64 * 1024 * 1024;
14
+
15
+ // Identity reported as MCP clientInfo, plus the wire formats this client can
16
+ // decode: the daemon reads the capability and sends list-shaped results compact
17
+ // without needing a client-name allowlist entry.
18
+ export const CLIENT_NAME = "pi";
19
+ export const CLIENT_VERSION = "1.0.0";
20
+ export const WIRE_FORMATS: string[] = ["gcx"];
21
+ export const PROTOCOL_VERSION = "2025-06-18";
22
+
23
+ // Floor for the daemon. gortex v0.61.2 is where `gortex mcp` first served the
24
+ // persistent bridge this client dials; older daemons expose a fixed tool facade
25
+ // with nothing on the other end of the handshake.
26
+ export const MIN_GORTEX_VERSION = "0.61.2";
27
+
28
+ /**
29
+ * Dotted-numeric comparison, prerelease and build suffixes ignored, since the
30
+ * daemon reports "0.64.4" while its CLI prints "v0.64.4+2b5480bf". Unparseable
31
+ * segments count as 0, so a version this cannot read never trips the warning.
32
+ */
33
+ export function isBelowVersion(actual: string, floor: string): boolean {
34
+ const parse = (v: string): number[] =>
35
+ v
36
+ .replace(/^v/, "")
37
+ .split(/[+-]/)[0]!
38
+ .split(".")
39
+ .map((part) => Number.parseInt(part, 10) || 0);
40
+ const a = parse(actual);
41
+ const b = parse(floor);
42
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
43
+ const delta = (a[i] ?? 0) - (b[i] ?? 0);
44
+ if (delta !== 0) return delta < 0;
45
+ }
46
+ return false;
47
+ }
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
+ export interface MCPStdioClientOptions {
57
+ bin: string;
58
+ toolsPreset: string;
59
+ deps: ProcessDeps;
60
+ env?: Record<string, string | undefined>;
61
+ }
62
+
63
+ interface PendingRequest {
64
+ resolve: (value: unknown) => void;
65
+ reject: (err: Error) => void;
66
+ timer?: ReturnType<typeof setTimeout>;
67
+ }
68
+
69
+ export interface ToolDescriptor {
70
+ name: string;
71
+ description?: string;
72
+ inputSchema?: unknown;
73
+ }
74
+
75
+ /**
76
+ * ensureDaemon brings the shared Gortex daemon up before the MCP child dials
77
+ * it. Idempotent, fire-and-forget (we don't block on the launcher's <=60s
78
+ * readiness poll), and never throws, since a missing binary or spawn failure
79
+ * must not take the extension down. The bridge's initialize retry absorbs the
80
+ * warm-up window.
81
+ */
82
+ export function ensureDaemon(bin: string, deps: ProcessDeps): void {
83
+ try {
84
+ const child = deps.spawn(bin, ["daemon", "start", "--detach"], {
85
+ stdio: "ignore",
86
+ detached: true,
87
+ });
88
+ child.on("error", () => {}); // binary missing / spawn failure: swallow
89
+ // No teardown counterpart, by design: the daemon is shared, long-lived
90
+ // infrastructure that outlives the session.
91
+ child.unref?.();
92
+ } catch {
93
+ // never fatal
94
+ }
95
+ }
96
+
97
+ /** textFromResult joins the text parts of an MCP tools/call result. */
98
+ export function textFromResult(result: unknown): string {
99
+ const content = (result as { content?: unknown })?.content;
100
+ const parts = Array.isArray(content) ? content : [];
101
+ return parts
102
+ .filter((c): c is { type: string; text: string } =>
103
+ Boolean(c) && (c as { type?: unknown }).type === "text" &&
104
+ typeof (c as { text?: unknown }).text === "string",
105
+ )
106
+ .map((c) => c.text)
107
+ .join("\n");
108
+ }
109
+
110
+ export class MCPStdioClient {
111
+ private child: BridgeChild | null = null;
112
+ private buffer = "";
113
+ private searchStart = 0;
114
+ private nextId = 1;
115
+ private pending = new Map<number, PendingRequest>();
116
+ private exited = true;
117
+ private respawnPromise: Promise<void> | null = null;
118
+
119
+ private readonly bin: string;
120
+ private readonly toolsPreset: string;
121
+ private readonly deps: ProcessDeps;
122
+ private readonly env: Record<string, string | undefined>;
123
+
124
+ // Fired on notifications/tools/list_changed (server-side promotion).
125
+ onToolsListChanged: (() => void) | null = null;
126
+
127
+ // Read off the handshake reply. Empty when the daemon withheld the field,
128
+ // which reads as "unknown" everywhere and never as a mismatch.
129
+ serverVersion = "";
130
+ negotiatedProtocol = "";
131
+
132
+ constructor(options: MCPStdioClientOptions) {
133
+ this.bin = options.bin;
134
+ this.toolsPreset = options.toolsPreset;
135
+ this.deps = options.deps;
136
+ this.env = options.env ?? process.env;
137
+ }
138
+
139
+ 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;
150
+ }
151
+
152
+ private spawnChild(): void {
153
+ const child = this.deps.spawn(this.bin, ["mcp"], {
154
+ stdio: ["pipe", "pipe", "pipe"],
155
+ env: this.childEnv(),
156
+ });
157
+ this.child = child;
158
+ this.buffer = "";
159
+ this.searchStart = 0;
160
+ this.exited = false;
161
+ child.stdout?.on("data", (chunk: unknown) => this.onData(chunk as Buffer));
162
+ // Consume stderr so the child never blocks on a full pipe; routing it
163
+ // to the terminal would corrupt Pi's TUI.
164
+ child.stderr?.on("data", () => {});
165
+ child.stdin?.on("error", () => {}); // EPIPE race: child may exit before stdin.write() finishes
166
+ child.on("error", () => this.markExited(new Error("gortex mcp spawn failed")));
167
+ child.on("exit", () => this.markExited(new Error("gortex mcp exited")));
168
+ }
169
+
170
+ private markExited(err: Error): void {
171
+ if (this.exited && this.pending.size === 0) return;
172
+ this.exited = true;
173
+ this.child = null;
174
+ for (const [, p] of this.pending) {
175
+ if (p.timer) clearTimeout(p.timer);
176
+ p.reject(err);
177
+ }
178
+ this.pending.clear();
179
+ }
180
+
181
+ private onData(chunk: Buffer): void {
182
+ this.buffer += chunk.toString("utf8");
183
+ let nl: number;
184
+ // Resume the newline scan where the previous chunk left off, so a
185
+ // large frame split across many chunks does not re-scan the whole
186
+ // growing buffer from index 0 on every data event.
187
+ while ((nl = this.buffer.indexOf("\n", this.searchStart)) >= 0) {
188
+ const line = this.buffer.slice(0, nl).trim();
189
+ this.buffer = this.buffer.slice(nl + 1);
190
+ this.searchStart = 0;
191
+ if (!line) continue;
192
+ let msg: unknown;
193
+ try {
194
+ msg = JSON.parse(line);
195
+ } catch {
196
+ continue; // non-JSON noise, ignore
197
+ }
198
+ this.dispatch(msg);
199
+ }
200
+ this.searchStart = this.buffer.length;
201
+ if (this.buffer.length > MAX_BUFFER_BYTES) {
202
+ // One frame past the cap is fatal for this child (no way to
203
+ // resync mid-frame): pending requests reject, the next call
204
+ // respawns a fresh child.
205
+ this.buffer = "";
206
+ this.searchStart = 0;
207
+ const child = this.child;
208
+ this.markExited(new Error(`gortex mcp frame exceeded ${MAX_BUFFER_BYTES} bytes`));
209
+ try {
210
+ child?.kill();
211
+ } catch {
212
+ // already gone
213
+ }
214
+ }
215
+ }
216
+
217
+ private dispatch(msg: unknown): void {
218
+ const frame = msg as { id?: unknown; error?: { code?: number; message?: string }; result?: unknown; method?: unknown };
219
+ if (frame && typeof frame.id === "number" && this.pending.has(frame.id)) {
220
+ const p = this.pending.get(frame.id)!;
221
+ this.pending.delete(frame.id);
222
+ if (p.timer) clearTimeout(p.timer);
223
+ if (frame.error) {
224
+ p.reject(new Error(frame.error.message || `JSON-RPC error ${frame.error.code}`));
225
+ } else {
226
+ p.resolve(frame.result);
227
+ }
228
+ return;
229
+ }
230
+ if (frame && frame.method === "notifications/tools/list_changed") {
231
+ try {
232
+ this.onToolsListChanged?.();
233
+ } catch {
234
+ // best effort; never break the read loop.
235
+ }
236
+ }
237
+ }
238
+
239
+ private send(obj: Record<string, unknown>): void {
240
+ if (!this.child || this.exited) throw new Error("gortex mcp is not running");
241
+ this.child.stdin?.write(JSON.stringify(obj) + "\n");
242
+ }
243
+
244
+ // A single shared respawn: concurrent callers of request() against a
245
+ // dead child all await the same spawn+initialize, so a burst of tool
246
+ // calls can never fork multiple children.
247
+ private respawn(): Promise<void> {
248
+ if (!this.respawnPromise) {
249
+ this.respawnPromise = (async () => {
250
+ this.spawnChild();
251
+ await this.initialize();
252
+ })().finally(() => {
253
+ this.respawnPromise = null;
254
+ });
255
+ }
256
+ return this.respawnPromise;
257
+ }
258
+
259
+ async request(method: string, params: unknown, timeoutMs: number = RPC_TIMEOUT_MS): Promise<unknown> {
260
+ if (this.exited) await this.respawn();
261
+ const id = this.nextId++;
262
+ return new Promise((resolve, reject) => {
263
+ const entry: PendingRequest = { resolve, reject };
264
+ if (Number.isFinite(timeoutMs)) {
265
+ entry.timer = setTimeout(() => {
266
+ this.pending.delete(id);
267
+ reject(new Error(`${method} timed out after ${timeoutMs}ms`));
268
+ }, timeoutMs);
269
+ }
270
+ this.pending.set(id, entry);
271
+ try {
272
+ this.send({ jsonrpc: "2.0", id, method, params });
273
+ } catch (err) {
274
+ this.pending.delete(id);
275
+ if (entry.timer) clearTimeout(entry.timer);
276
+ reject(err instanceof Error ? err : new Error(String(err)));
277
+ }
278
+ });
279
+ }
280
+
281
+ private notify(method: string, params: unknown): void {
282
+ try {
283
+ this.send({ jsonrpc: "2.0", method, params });
284
+ } catch {
285
+ // notifications are best-effort.
286
+ }
287
+ }
288
+
289
+ private async initialize(): Promise<void> {
290
+ const result = (await this.request(
291
+ "initialize",
292
+ {
293
+ protocolVersion: PROTOCOL_VERSION,
294
+ capabilities: { experimental: { "gortex/wire": WIRE_FORMATS } },
295
+ clientInfo: { name: CLIENT_NAME, version: CLIENT_VERSION },
296
+ },
297
+ INIT_TIMEOUT_MS,
298
+ )) as { protocolVersion?: unknown; serverInfo?: { version?: unknown } } | null;
299
+ const version = result?.serverInfo?.version;
300
+ this.serverVersion = typeof version === "string" ? version : "";
301
+ this.negotiatedProtocol =
302
+ typeof result?.protocolVersion === "string" ? result.protocolVersion : "";
303
+ this.notify("notifications/initialized", {});
304
+ }
305
+
306
+ // start spawns the child and runs the MCP handshake, retrying once
307
+ // after a 1s backoff, since the daemon may still be warming up right
308
+ // after ensureDaemon() kicked it off.
309
+ async start(): Promise<void> {
310
+ this.spawnChild();
311
+ try {
312
+ await this.initialize();
313
+ } catch {
314
+ await new Promise((r) => setTimeout(r, 1_000));
315
+ if (this.exited) this.spawnChild();
316
+ await this.initialize();
317
+ }
318
+ }
319
+
320
+ async listTools(): Promise<ToolDescriptor[]> {
321
+ const res = (await this.request("tools/list", {})) as { tools?: unknown };
322
+ return Array.isArray(res?.tools) ? (res.tools as ToolDescriptor[]) : [];
323
+ }
324
+
325
+ async callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
326
+ return this.request("tools/call", { name, arguments: args ?? {} }, CALL_TIMEOUT_MS);
327
+ }
328
+
329
+ stop(): void {
330
+ const child = this.child;
331
+ this.markExited(new Error("gortex mcp bridge stopped"));
332
+ try {
333
+ child?.kill();
334
+ } catch {
335
+ // already gone
336
+ }
337
+ }
338
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,58 @@
1
+ // The process seam: everything the extension does to the outside world goes
2
+ // through `spawn` and `execFileSync`, injectable so the suite can drive the
3
+ // bridge against a fake `gortex mcp` child. The types below describe only what
4
+ // the bridge uses, so a double stays a small object.
5
+
6
+ import { execFileSync as nodeExecFileSync, spawn as nodeSpawn } from "node:child_process";
7
+ import type { SpawnOptions } from "node:child_process";
8
+
9
+ export interface ChildStream {
10
+ on(event: string, listener: (chunk: unknown) => void): unknown;
11
+ }
12
+
13
+ export interface ChildStdin {
14
+ on(event: string, listener: (err: unknown) => void): unknown;
15
+ write(chunk: string): unknown;
16
+ }
17
+
18
+ /** The subset of ChildProcess the MCP bridge and the daemon launcher touch. */
19
+ export interface BridgeChild {
20
+ stdout?: ChildStream;
21
+ stderr?: ChildStream;
22
+ stdin?: ChildStdin;
23
+ on(event: string, listener: (...args: unknown[]) => void): unknown;
24
+ kill(): unknown;
25
+ unref?(): unknown;
26
+ }
27
+
28
+ export interface SpawnOpts {
29
+ stdio?: "ignore" | ("pipe" | "ignore")[];
30
+ detached?: boolean;
31
+ env?: Record<string, string | undefined>;
32
+ }
33
+
34
+ export interface ExecFileSyncOpts {
35
+ input?: string;
36
+ encoding?: "utf8";
37
+ maxBuffer?: number;
38
+ timeout?: number;
39
+ }
40
+
41
+ export interface ProcessDeps {
42
+ spawn(command: string, args: string[], options?: SpawnOpts): BridgeChild;
43
+ execFileSync(command: string, args: string[], options?: ExecFileSyncOpts): string;
44
+ }
45
+
46
+ /**
47
+ * The real implementations. The two casts are the whole impedance mismatch
48
+ * between node's overloaded signatures and the narrow surface above; every
49
+ * consumer downstream stays strictly typed.
50
+ */
51
+ export const nodeProcessDeps: ProcessDeps = {
52
+ spawn(command, args, options) {
53
+ return nodeSpawn(command, args, (options ?? {}) as SpawnOptions) as unknown as BridgeChild;
54
+ },
55
+ execFileSync(command, args, options) {
56
+ return String(nodeExecFileSync(command, args, { ...options, encoding: "utf8" }));
57
+ },
58
+ };
package/src/state.ts ADDED
@@ -0,0 +1,56 @@
1
+ // Module-level session state.
2
+ //
3
+ // Shared at module level on purpose: a new invocation's session_start stops the
4
+ // previous invocation's bridge child, which works only while both invocations
5
+ // see the same `client`. Move this into the factory closure and every `/new`
6
+ // leaks a `gortex mcp` process.
7
+
8
+ import type { MCPStdioClient } from "./mcp-client.ts";
9
+
10
+ /** The session's live bridge, or null when the handshake failed. */
11
+ let client: MCPStdioClient | null = null;
12
+
13
+ /**
14
+ * Why the bridge is down this session (empty when it is up). Surfaced once
15
+ * through the orientation injection so the user learns that /reload retries
16
+ * the handshake.
17
+ */
18
+ let bridgeError = "";
19
+
20
+ /**
21
+ * How the daemon is out of step with this extension (empty when it is not).
22
+ * Surfaced once to the model alongside the orientation; the user learns about
23
+ * it through ctx.ui.notify at session_start, which never reaches the model.
24
+ */
25
+ let versionWarning = "";
26
+
27
+ /**
28
+ * Names the Gortex tools are registered under in Pi. Usually the bare daemon
29
+ * name, except for a few aliased to dodge Pi's built-ins (see piAliasName).
30
+ * Tracks the post-alias name.
31
+ */
32
+ export const gortexToolNames = new Set<string>();
33
+
34
+ export function getClient(): MCPStdioClient | null {
35
+ return client;
36
+ }
37
+
38
+ export function setClient(next: MCPStdioClient | null): void {
39
+ client = next;
40
+ }
41
+
42
+ export function getBridgeError(): string {
43
+ return bridgeError;
44
+ }
45
+
46
+ export function setBridgeError(message: string): void {
47
+ bridgeError = message;
48
+ }
49
+
50
+ export function getVersionWarning(): string {
51
+ return versionWarning;
52
+ }
53
+
54
+ export function setVersionWarning(message: string): void {
55
+ versionWarning = message;
56
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,180 @@
1
+ // Tool registration: each tool the bridge lists becomes a native Pi tool whose
2
+ // execute() forwards to tools/call.
3
+
4
+ import { createRequire } from "node:module";
5
+
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+
8
+ import { textFromResult } from "./mcp-client.ts";
9
+ import type { ToolDescriptor } from "./mcp-client.ts";
10
+ import { getClient, gortexToolNames } from "./state.ts";
11
+ import { toolNameMap } from "./hook.ts";
12
+
13
+ // Pi's reserved built-in names (mirrors toolNameMap's keys). A bridge tool that
14
+ // reuses one would silently replace the built-in, breaking Pi's own rendering
15
+ // and anything hooked to it. Only "edit"/"read" collide today; guarding all
16
+ // seven covers built-ins Pi adds later.
17
+ const PI_RESERVED_TOOL_NAMES = new Set(Object.keys(toolNameMap));
18
+ const PI_ALIAS_PREFIX = "gortex_";
19
+
20
+ /**
21
+ * piAliasName is unchanged unless name collides with a built-in above, in
22
+ * which case it's registered under a `gortex_`-prefixed alias instead.
23
+ */
24
+ export function piAliasName(name: string): string {
25
+ return PI_RESERVED_TOOL_NAMES.has(name) ? PI_ALIAS_PREFIX + name : name;
26
+ }
27
+
28
+ /** piAliasedDescription front-loads the rename into the tool's own description. */
29
+ export function piAliasedDescription(bare: string, aliased: string, description: string): string {
30
+ if (bare === aliased) return description;
31
+ return (
32
+ `Gortex's \`${bare}\` tool, registered as \`${aliased}\` because Pi has a built-in ` +
33
+ `\`${bare}\`. Gortex guidance and denial messages that name \`${bare}\` mean this tool.` +
34
+ `\n\n${description}`
35
+ );
36
+ }
37
+
38
+ // Pi's own tool definitions are TypeBox-typed; the bridge's parameters are the
39
+ // daemon's raw JSON Schema, passed through untouched. This is the one place
40
+ // the two vocabularies meet.
41
+ type RegisterableTool = Parameters<ExtensionAPI["registerTool"]>[0];
42
+
43
+ interface BridgeToolDefinition {
44
+ name: string;
45
+ label: string;
46
+ description: string;
47
+ parameters: unknown;
48
+ execute(toolCallId: string, params: Record<string, unknown>): Promise<unknown>;
49
+ renderResult?: (result: unknown, options: { expanded?: boolean }) => unknown;
50
+ }
51
+
52
+ /**
53
+ * safeRegister registers under the alias name (piAliasName). registerTool is a
54
+ * map insert, so a repeat registration is harmless; the catch is for a late
55
+ * registration from a superseded session, which throws once Pi has invalidated
56
+ * that extension instance. Returns the name actually registered.
57
+ */
58
+ export function safeRegister(pi: ExtensionAPI, def: BridgeToolDefinition): string {
59
+ def.name = piAliasName(def.name);
60
+ def.label = def.name;
61
+ try {
62
+ pi.registerTool(def as unknown as RegisterableTool);
63
+ } catch {
64
+ // already live this session, or this instance has been invalidated: the
65
+ // existing registration stands.
66
+ }
67
+ return def.name;
68
+ }
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
+ /**
90
+ * Registers one Gortex tool under its bare daemon name (or its alias).
91
+ * Idempotent: a name already registered is skipped. Adds the name to
92
+ * gortexToolNames so the read-discipline postures treat a call to it as a graph
93
+ * 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
+ */
98
+ export function registerOneTool(pi: ExtensionAPI, desc: ToolDescriptor): void {
99
+ const name = desc.name;
100
+ if (!name) return;
101
+ if (gortexToolNames.has(piAliasName(name))) return;
102
+ const parameters =
103
+ desc.inputSchema && typeof desc.inputSchema === "object"
104
+ ? desc.inputSchema
105
+ : { type: "object", properties: {} };
106
+ const def: BridgeToolDefinition = {
107
+ name,
108
+ label: name,
109
+ description: piAliasedDescription(name, piAliasName(name), (desc.description || name).trim()),
110
+ parameters,
111
+ async execute(_id: string, params: Record<string, unknown>) {
112
+ const client = getClient();
113
+ if (!client) throw new Error(`gortex ${name}: MCP bridge is not connected`);
114
+ let result: unknown;
115
+ try {
116
+ result = await client.callTool(name, params ?? {});
117
+ } catch (err) {
118
+ throw new Error(`gortex ${name} failed: ${err instanceof Error ? err.message : String(err)}`);
119
+ }
120
+ if (name === "tools_search") {
121
+ // The daemon just promoted the matches and fired list_changed;
122
+ // register them NOW (bypassing the debounce) so every tool the
123
+ // reply cites is already callable when the model reads it.
124
+ await syncTools(pi);
125
+ }
126
+ const text = resultText(result);
127
+ if ((result as { isError?: boolean })?.isError) throw new Error(text || `gortex ${name} failed`);
128
+ return { content: [{ type: "text", text }], details: {} };
129
+ },
130
+ };
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
+ gortexToolNames.add(safeRegister(pi, def));
139
+ }
140
+
141
+ /** Registers the daemon's eager tool surface, discovery tool included. */
142
+ export async function registerGortexTools(pi: ExtensionAPI): Promise<void> {
143
+ const client = getClient();
144
+ if (!client) return;
145
+ for (const desc of await client.listTools()) {
146
+ if (!desc) continue;
147
+ registerOneTool(pi, desc);
148
+ }
149
+ }
150
+
151
+ /** Re-fetches the tool list after a list_changed and registers anything new. */
152
+ export async function syncTools(pi: ExtensionAPI): Promise<void> {
153
+ try {
154
+ await registerGortexTools(pi);
155
+ } catch {
156
+ // transient; the next promotion or session retries.
157
+ }
158
+ }
159
+
160
+ // The daemon fires one list_changed per promoted tool, so a tools_search sweep
161
+ // arrives as a burst; debounce to a single trailing re-fetch. (The tools_search
162
+ // execute() path awaits syncTools directly, bypassing this timer.)
163
+ const SYNC_DEBOUNCE_MS = 200;
164
+ let syncTimer: ReturnType<typeof setTimeout> | null = null;
165
+
166
+ export function scheduleSyncTools(pi: ExtensionAPI): void {
167
+ if (syncTimer) clearTimeout(syncTimer);
168
+ syncTimer = setTimeout(() => {
169
+ syncTimer = null;
170
+ void syncTools(pi);
171
+ }, SYNC_DEBOUNCE_MS);
172
+ }
173
+
174
+ /** Cancels a pending re-sync; a new session re-registers from scratch. */
175
+ export function clearSyncTimer(): void {
176
+ if (syncTimer) {
177
+ clearTimeout(syncTimer);
178
+ syncTimer = null;
179
+ }
180
+ }