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.
- package/LICENSE +201 -0
- package/README.md +102 -0
- package/docs/architecture.md +126 -0
- package/docs/tests.md +108 -0
- package/index.ts +3 -0
- package/package.json +63 -0
- package/src/config.ts +158 -0
- package/src/hook.ts +97 -0
- package/src/index.ts +313 -0
- package/src/mcp-client.ts +338 -0
- package/src/runtime.ts +58 -0
- package/src/state.ts +56 -0
- package/src/tools.ts +180 -0
|
@@ -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
|
+
}
|