pi-gortex 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/docs/architecture.md +86 -14
- package/package.json +5 -4
- package/src/config.ts +15 -4
- package/src/diff.ts +404 -0
- package/src/index.ts +149 -49
- package/src/mcp-client.ts +24 -10
- package/src/native.ts +141 -0
- package/src/render.ts +65 -8
- package/src/runtime.ts +4 -0
- package/src/state.ts +10 -1
- 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,
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
|
|
13
13
|
import { resolveConfig } from "./config.ts";
|
|
14
14
|
import type { GortexConfig } from "./config.ts";
|
|
15
|
+
import { watchNativeEdits } from "./diff.ts";
|
|
15
16
|
import { callHook, normalizeToolCall } from "./hook.ts";
|
|
16
17
|
import {
|
|
17
18
|
MCPStdioClient,
|
|
@@ -20,6 +21,19 @@ import {
|
|
|
20
21
|
ensureDaemon,
|
|
21
22
|
isBelowVersion,
|
|
22
23
|
} from "./mcp-client.ts";
|
|
24
|
+
import {
|
|
25
|
+
NATIVE_READY_WAIT_MS,
|
|
26
|
+
bareToolName,
|
|
27
|
+
binaryResolves,
|
|
28
|
+
daemonVersionFromBriefing,
|
|
29
|
+
isNativeGortexTool,
|
|
30
|
+
mcpConnectorLoaded,
|
|
31
|
+
registerNativeServer,
|
|
32
|
+
unregisterNativeServer,
|
|
33
|
+
supportsNativeMcp,
|
|
34
|
+
waitForNativeTools,
|
|
35
|
+
} from "./native.ts";
|
|
36
|
+
import { nativeRenderers } from "./render.ts";
|
|
23
37
|
import { nodeProcessDeps } from "./runtime.ts";
|
|
24
38
|
import type { ProcessDeps } from "./runtime.ts";
|
|
25
39
|
import {
|
|
@@ -35,6 +49,7 @@ import {
|
|
|
35
49
|
setBridgeError,
|
|
36
50
|
setClient,
|
|
37
51
|
setVersionWarning,
|
|
52
|
+
withdrawnToolNames,
|
|
38
53
|
} from "./state.ts";
|
|
39
54
|
|
|
40
55
|
export type { GortexConfig, SidecarConfig } from "./config.ts";
|
|
@@ -82,12 +97,49 @@ function reportVersionSkew(
|
|
|
82
97
|
`run \`gortex upgrade\`.`,
|
|
83
98
|
);
|
|
84
99
|
|
|
85
|
-
|
|
86
|
-
|
|
100
|
+
notifyUser(ctx, user);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A warning for the user only. A Pi build without a UI (print/JSON mode) or
|
|
105
|
+
* without notify must not take the session down over an advisory message.
|
|
106
|
+
*/
|
|
107
|
+
function notifyUser(ctx: ExtensionContext | undefined, message: string): void {
|
|
87
108
|
try {
|
|
88
|
-
if (ctx?.hasUI) ctx.ui.notify(
|
|
109
|
+
if (ctx?.hasUI) ctx.ui.notify(message, "warning");
|
|
89
110
|
} catch {
|
|
90
|
-
//
|
|
111
|
+
// advisory only
|
|
112
|
+
}
|
|
113
|
+
}
|
|
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;
|
|
91
143
|
}
|
|
92
144
|
}
|
|
93
145
|
|
|
@@ -99,6 +151,8 @@ export interface GortexExtensionOptions {
|
|
|
99
151
|
deps?: ProcessDeps;
|
|
100
152
|
/** Readiness cap override. */
|
|
101
153
|
readyWaitMs?: number;
|
|
154
|
+
/** Override for the wait on Pi's built-in MCP connecting the gortex server. */
|
|
155
|
+
nativeReadyWaitMs?: number;
|
|
102
156
|
}
|
|
103
157
|
|
|
104
158
|
export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensionOptions = {}): void {
|
|
@@ -109,14 +163,26 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
109
163
|
const piCwd = (): string => (pi as { cwd?: string })?.cwd ?? process.cwd();
|
|
110
164
|
const deps = options.deps ?? nodeProcessDeps;
|
|
111
165
|
const readyWaitMs = options.readyWaitMs ?? READY_WAIT_MS;
|
|
166
|
+
const nativeReadyWaitMs = options.nativeReadyWaitMs ?? NATIVE_READY_WAIT_MS;
|
|
167
|
+
|
|
168
|
+
// On a Pi with built-in MCP, Pi owns the tool channel: it spawns `gortex mcp`
|
|
169
|
+
// (which starts the daemon itself), registers the tools and renders them.
|
|
170
|
+
// A binary that does not resolve, or a registration Pi refuses, falls back to
|
|
171
|
+
// the extension's own client, which reports its failure to the model.
|
|
172
|
+
let nativeRefusal = "";
|
|
173
|
+
let native = ((): boolean => {
|
|
174
|
+
if (!config.nativeMcp || !supportsNativeMcp(pi) || !binaryResolves(config.bin)) return false;
|
|
175
|
+
nativeRefusal = registerNativeServer(pi, config);
|
|
176
|
+
return nativeRefusal === "";
|
|
177
|
+
})();
|
|
112
178
|
|
|
113
179
|
// Latched for the whole session once the first turn has run the
|
|
114
180
|
// session_start hook, whether or not it produced anything to inject.
|
|
115
181
|
let startupHookRan = false;
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
let
|
|
182
|
+
// This session's briefing, kept so a compaction that summarises it away can
|
|
183
|
+
// send it again on the next turn.
|
|
184
|
+
let briefing = "";
|
|
185
|
+
let briefingCompacted = false;
|
|
120
186
|
|
|
121
187
|
// Settles when the current session_start handler is done: bridge up and
|
|
122
188
|
// tools registered, or the handshake failed. Never rejects.
|
|
@@ -166,7 +232,8 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
166
232
|
// session's bridge child (if any) is stopped before a fresh handshake.
|
|
167
233
|
pi.on("session_start", async (_event, ctx) => {
|
|
168
234
|
startupHookRan = false;
|
|
169
|
-
|
|
235
|
+
briefing = "";
|
|
236
|
+
briefingCompacted = false;
|
|
170
237
|
setBridgeError("");
|
|
171
238
|
setVersionWarning("");
|
|
172
239
|
armSessionReady(); // no-op when a turn is already parked on this session
|
|
@@ -178,17 +245,30 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
178
245
|
}
|
|
179
246
|
});
|
|
180
247
|
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
ensureDaemon(config.bin, deps);
|
|
185
|
-
gortexToolNames.clear();
|
|
248
|
+
// Stops the live bridge, if any, and clears the slot. Shared by
|
|
249
|
+
// session_start and session_shutdown so both paths stay in sync.
|
|
250
|
+
function stopBridge(): void {
|
|
186
251
|
clearSyncTimer();
|
|
187
252
|
const previous = getClient();
|
|
188
253
|
if (previous) {
|
|
189
254
|
previous.stop();
|
|
190
255
|
setClient(null);
|
|
191
256
|
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// startSession holds the body of session_start so the readiness promise
|
|
260
|
+
// above settles on every exit path, early returns included.
|
|
261
|
+
async function startSession(ctx?: ExtensionContext): Promise<void> {
|
|
262
|
+
if (native && !mcpConnectorLoaded(pi)) {
|
|
263
|
+
unregisterNativeServer(pi);
|
|
264
|
+
native = false;
|
|
265
|
+
}
|
|
266
|
+
if (native) return;
|
|
267
|
+
if (nativeRefusal) notifyUser(ctx, `Pi refused the gortex MCP server (${nativeRefusal}), so the extension runs its own client.`);
|
|
268
|
+
ensureDaemon(config.bin, deps);
|
|
269
|
+
gortexToolNames.clear();
|
|
270
|
+
withdrawnToolNames.clear();
|
|
271
|
+
stopBridge();
|
|
192
272
|
const c = new MCPStdioClient({ bin: config.bin, toolsPreset: config.toolsPreset, deps });
|
|
193
273
|
// Claim the slot before the first await: an overlapping
|
|
194
274
|
// session_start (rapid /new or /reload) then sees and stops THIS
|
|
@@ -214,18 +294,34 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
214
294
|
}
|
|
215
295
|
}
|
|
216
296
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
297
|
+
pi.on("session_compact", () => {
|
|
298
|
+
briefingCompacted = true;
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Fires before the agent loop's first LLM call. The returned message is
|
|
302
|
+
// stored in the session after the prompt, so every later LLM call carries it
|
|
303
|
+
// and the system prompt, which prefix caching keys on, stays untouched.
|
|
304
|
+
pi.on("before_agent_start", async (_event, ctx): Promise<BeforeAgentStartEventResult | void> => {
|
|
305
|
+
if (startupHookRan) {
|
|
306
|
+
if (!briefingCompacted) return;
|
|
307
|
+
briefingCompacted = false;
|
|
308
|
+
if (!briefing || briefingInContext(ctx)) return;
|
|
309
|
+
return gortexMessage(briefing, true);
|
|
310
|
+
}
|
|
221
311
|
startupHookRan = true; // before the first await, so a second turn can't race in
|
|
222
312
|
// Pi awaits each listener, so this holds the turn until the tools are
|
|
223
313
|
// registered and bridgeError reflects the handshake (or the cap expires).
|
|
224
|
-
|
|
314
|
+
// session_start settles `native` first: it falls back when nothing connects MCP.
|
|
315
|
+
const sessionDone = await waitForSession(readyWaitMs);
|
|
316
|
+
const ready = native ? await waitForNativeTools(pi, nativeReadyWaitMs) : sessionDone;
|
|
225
317
|
const decision = callHook(config.hookArgv, deps, {
|
|
226
318
|
event: "session_start",
|
|
227
|
-
cwd: piCwd(),
|
|
319
|
+
cwd: ctx?.cwd ?? piCwd(),
|
|
228
320
|
});
|
|
321
|
+
// Pi's MCP keeps the handshake to itself, so the version comes from the briefing.
|
|
322
|
+
if (native && decision.orientation) {
|
|
323
|
+
reportVersionSkew(ctx, daemonVersionFromBriefing(decision.orientation), "");
|
|
324
|
+
}
|
|
229
325
|
const parts: string[] = [];
|
|
230
326
|
const bridgeError = getBridgeError();
|
|
231
327
|
if (bridgeError) {
|
|
@@ -250,41 +346,32 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
250
346
|
parts.push(versionWarning);
|
|
251
347
|
setVersionWarning("");
|
|
252
348
|
}
|
|
253
|
-
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// parked orientation once, clearing it only after the push lands so the
|
|
260
|
-
// orientation survives a context shape this hook can't append to.
|
|
261
|
-
// Pi exports ContextEvent but not its result type, so it is spelled out here.
|
|
262
|
-
pi.on("context", (event: ContextEvent): { messages?: ContextEvent["messages"] } | void => {
|
|
263
|
-
if (!pendingOrientation) return;
|
|
264
|
-
try {
|
|
265
|
-
const messages = (event as { messages?: unknown })?.messages;
|
|
266
|
-
if (Array.isArray(messages)) {
|
|
267
|
-
// Pi's AgentMessage also carries a `timestamp`; only the two fields
|
|
268
|
-
// Pi reads for a tail user turn are pushed.
|
|
269
|
-
messages.push({ role: "user", content: pendingOrientation } as ContextEvent["messages"][number]);
|
|
270
|
-
pendingOrientation = "";
|
|
271
|
-
return { messages: messages as ContextEvent["messages"] };
|
|
272
|
-
}
|
|
273
|
-
} catch {
|
|
274
|
-
// best effort; never break context assembly.
|
|
349
|
+
let briefed = false;
|
|
350
|
+
if (decision.orientation) {
|
|
351
|
+
briefing = decision.orientation;
|
|
352
|
+
// A resumed or reloaded session already holds it.
|
|
353
|
+
briefed = !briefingInContext(ctx);
|
|
354
|
+
if (briefed) parts.push(decision.orientation);
|
|
275
355
|
}
|
|
276
|
-
return;
|
|
356
|
+
if (parts.length === 0) return;
|
|
357
|
+
return gortexMessage(parts.join("\n\n"), briefed);
|
|
277
358
|
});
|
|
278
359
|
|
|
279
|
-
|
|
360
|
+
// Never let the bridge child outlive the session.
|
|
361
|
+
pi.on("session_shutdown", async () => {
|
|
362
|
+
stopBridge();
|
|
363
|
+
});
|
|
280
364
|
|
|
281
365
|
// Enforcement: every non-Gortex tool call is checked against the Go hook.
|
|
282
|
-
|
|
366
|
+
const enforceReadDiscipline = async (event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | void> => {
|
|
283
367
|
const piName: string = (event as { toolName?: string })?.toolName ?? "";
|
|
284
368
|
const piInput: Record<string, unknown> = (event?.input as Record<string, unknown>) ?? {};
|
|
285
|
-
const isGortexTool = gortexToolNames.has(piName);
|
|
369
|
+
const isGortexTool = native ? isNativeGortexTool(piName) : gortexToolNames.has(piName);
|
|
286
370
|
|
|
287
|
-
|
|
371
|
+
// A graph tool keeps its own name: Gortex's `read` is no Pi `read`.
|
|
372
|
+
const norm = isGortexTool
|
|
373
|
+
? { tool_name: bareToolName(piName), tool_input: piInput }
|
|
374
|
+
: normalizeToolCall(piName, piInput);
|
|
288
375
|
const decision = callHook(config.hookArgv, deps, {
|
|
289
376
|
event: "tool_call",
|
|
290
377
|
tool_name: norm.tool_name,
|
|
@@ -309,5 +396,18 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
309
396
|
}
|
|
310
397
|
}
|
|
311
398
|
return;
|
|
312
|
-
}
|
|
399
|
+
};
|
|
400
|
+
if (config.enforce) pi.on("tool_call", enforceReadDiscipline);
|
|
401
|
+
|
|
402
|
+
// After enforcement, so a blocked call is never snapshotted. Shell calls are
|
|
403
|
+
// tracked on both channels; edits are snapshotted on Pi's built-in MCP only.
|
|
404
|
+
watchNativeEdits(pi, () => native);
|
|
405
|
+
if (native) {
|
|
406
|
+
// Pi < 1.0.1 has no tool renderers; the diffs are still stored.
|
|
407
|
+
try {
|
|
408
|
+
(pi as Partial<Pick<ExtensionAPI, "registerToolRenderer">>).registerToolRenderer?.(nativeRenderers);
|
|
409
|
+
} catch {
|
|
410
|
+
// drawing diffs is optional
|
|
411
|
+
}
|
|
412
|
+
}
|
|
313
413
|
}
|
package/src/mcp-client.ts
CHANGED
|
@@ -53,6 +53,24 @@ export function isBelowVersion(actual: string, floor: string): boolean {
|
|
|
53
53
|
// session. Fail open to the daemon's default surface instead.
|
|
54
54
|
const FORWARDED_PRESETS = new Set(["edit", "nav", "readonly"]);
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The variables `gortex mcp` needs on top of `env` to serve `toolsPreset`.
|
|
58
|
+
* The daemon's own default surface already IS core/defer; only a recognised
|
|
59
|
+
* non-default preset needs the proxy-side narrowing.
|
|
60
|
+
*/
|
|
61
|
+
export function presetEnv(
|
|
62
|
+
toolsPreset: string,
|
|
63
|
+
env: Record<string, string | undefined>,
|
|
64
|
+
): Record<string, string> {
|
|
65
|
+
const out: Record<string, string> = {};
|
|
66
|
+
const preset = toolsPreset.trim().toLowerCase();
|
|
67
|
+
const tools = env.GORTEX_TOOLS || (FORWARDED_PRESETS.has(preset) ? preset : "");
|
|
68
|
+
if (tools && !env.GORTEX_TOOLS) out.GORTEX_TOOLS = tools;
|
|
69
|
+
// Never let a preset hide-block tools promoted later by tools_search.
|
|
70
|
+
if (tools && !env.GORTEX_TOOLS_MODE) out.GORTEX_TOOLS_MODE = "defer";
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
56
74
|
export interface MCPStdioClientOptions {
|
|
57
75
|
bin: string;
|
|
58
76
|
toolsPreset: string;
|
|
@@ -137,16 +155,7 @@ export class MCPStdioClient {
|
|
|
137
155
|
}
|
|
138
156
|
|
|
139
157
|
private childEnv(): Record<string, string | undefined> {
|
|
140
|
-
|
|
141
|
-
const preset = this.toolsPreset.trim().toLowerCase();
|
|
142
|
-
// The daemon's own default surface already IS core/defer; only a
|
|
143
|
-
// recognised non-default preset needs the proxy-side narrowing.
|
|
144
|
-
if (!env.GORTEX_TOOLS && FORWARDED_PRESETS.has(preset)) {
|
|
145
|
-
env.GORTEX_TOOLS = preset;
|
|
146
|
-
}
|
|
147
|
-
// Never let a preset hide-block tools promoted later by tools_search.
|
|
148
|
-
if (env.GORTEX_TOOLS && !env.GORTEX_TOOLS_MODE) env.GORTEX_TOOLS_MODE = "defer";
|
|
149
|
-
return env;
|
|
158
|
+
return { ...this.env, ...presetEnv(this.toolsPreset, this.env) };
|
|
150
159
|
}
|
|
151
160
|
|
|
152
161
|
private spawnChild(): void {
|
|
@@ -165,6 +174,11 @@ export class MCPStdioClient {
|
|
|
165
174
|
child.stdin?.on("error", () => {}); // EPIPE race: child may exit before stdin.write() finishes
|
|
166
175
|
child.on("error", () => this.markExited(new Error("gortex mcp spawn failed")));
|
|
167
176
|
child.on("exit", () => this.markExited(new Error("gortex mcp exited")));
|
|
177
|
+
// The child and its pipes must never keep the event loop alive.
|
|
178
|
+
child.unref?.();
|
|
179
|
+
child.stdin?.unref?.();
|
|
180
|
+
child.stdout?.unref?.();
|
|
181
|
+
child.stderr?.unref?.();
|
|
168
182
|
}
|
|
169
183
|
|
|
170
184
|
private markExited(err: Error): void {
|
package/src/native.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// The tool channel on Pi's built-in MCP (Pi >= 0.99): the extension registers
|
|
2
|
+
// `gortex mcp` with pi.registerMcpServer() and Pi owns the connection, the tool
|
|
3
|
+
// registry and the rendering. See docs/architecture.md.
|
|
4
|
+
|
|
5
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
6
|
+
import { delimiter, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
import type { GortexConfig } from "./config.ts";
|
|
11
|
+
import { CALL_TIMEOUT_MS, presetEnv } from "./mcp-client.ts";
|
|
12
|
+
|
|
13
|
+
export const MCP_SERVER_NAME = "gortex";
|
|
14
|
+
/** Pi names a server's tools `mcp__<server>__<tool>`. */
|
|
15
|
+
export const MCP_TOOL_PREFIX = `mcp__${MCP_SERVER_NAME}__`;
|
|
16
|
+
|
|
17
|
+
const SERVER_DESCRIPTION =
|
|
18
|
+
"Gortex code graph of the indexed repositories: symbol search, source reads, usages, " +
|
|
19
|
+
"call chains, change impact and graph-aware edits.";
|
|
20
|
+
|
|
21
|
+
// Pi 0.87 declares none of the MCP members, so they are read structurally.
|
|
22
|
+
type NativeMcpApi = Pick<ExtensionAPI, "registerMcpServer" | "unregisterMcpServer" | "getAllTools" | "getCommands">;
|
|
23
|
+
|
|
24
|
+
export function supportsNativeMcp(pi: ExtensionAPI): boolean {
|
|
25
|
+
return typeof (pi as Partial<NativeMcpApi>).registerMcpServer === "function";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function nativeServerConfig(
|
|
29
|
+
config: GortexConfig,
|
|
30
|
+
env: Record<string, string | undefined> = process.env,
|
|
31
|
+
): Parameters<ExtensionAPI["registerMcpServer"]>[1] {
|
|
32
|
+
return {
|
|
33
|
+
command: config.bin,
|
|
34
|
+
args: ["mcp"],
|
|
35
|
+
env: presetEnv(config.toolsPreset, env),
|
|
36
|
+
// Declared to the model like built-in tools, and the first prompt waits
|
|
37
|
+
// for the connection. Codemode would hand scripts gcx text to parse.
|
|
38
|
+
exposure: "direct",
|
|
39
|
+
timeout: CALL_TIMEOUT_MS / 1000,
|
|
40
|
+
description: SERVER_DESCRIPTION,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Registers the server; returns why it failed, or "" on success. */
|
|
45
|
+
export function registerNativeServer(pi: ExtensionAPI, config: GortexConfig): string {
|
|
46
|
+
try {
|
|
47
|
+
(pi as NativeMcpApi).registerMcpServer(MCP_SERVER_NAME, nativeServerConfig(config));
|
|
48
|
+
return "";
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return err instanceof Error ? err.message : String(err);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether `bin` names an executable file, directly or through PATH. Pi reports
|
|
56
|
+
* no connection failure to extensions, so a server that cannot spawn would
|
|
57
|
+
* read as one still connecting.
|
|
58
|
+
*/
|
|
59
|
+
export function binaryResolves(bin: string, env: Record<string, string | undefined> = process.env): boolean {
|
|
60
|
+
const exts = process.platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
|
|
61
|
+
const candidates = /[\\/]/.test(bin)
|
|
62
|
+
? [bin]
|
|
63
|
+
: (env.PATH ?? "").split(delimiter).filter(Boolean).map((dir) => join(dir, bin));
|
|
64
|
+
return candidates.some((path) =>
|
|
65
|
+
exts.some((ext) => {
|
|
66
|
+
try {
|
|
67
|
+
accessSync(path + ext, constants.X_OK);
|
|
68
|
+
return statSync(path + ext).isFile();
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The daemon version the session_start briefing reports, or "" when it names
|
|
78
|
+
* none. The briefing is the only place it reaches the extension on Pi's MCP.
|
|
79
|
+
*/
|
|
80
|
+
export function daemonVersionFromBriefing(briefing: string): string {
|
|
81
|
+
return /Gortex daemon [^(\n]*\(v(\d[^,\s)]*)/.exec(briefing)?.[1] ?? "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether an extension that connects MCP servers is loaded. Pi's own MCP
|
|
86
|
+
* extension and the ones that replace it register `/mcp`; `--no-mcp` and
|
|
87
|
+
* `-ne` without `builtin:mcp` leave a registration with nothing to connect it.
|
|
88
|
+
* Read on session_start, once every extension has loaded. A Pi that cannot
|
|
89
|
+
* list commands is trusted to connect the server.
|
|
90
|
+
*/
|
|
91
|
+
export function mcpConnectorLoaded(pi: ExtensionAPI): boolean {
|
|
92
|
+
try {
|
|
93
|
+
return (pi as NativeMcpApi).getCommands().some((command) => command.name === "mcp");
|
|
94
|
+
} catch {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Withdraws the registration, which also keeps Pi from reporting it as unconnected. */
|
|
100
|
+
export function unregisterNativeServer(pi: ExtensionAPI): void {
|
|
101
|
+
try {
|
|
102
|
+
(pi as NativeMcpApi).unregisterMcpServer(MCP_SERVER_NAME);
|
|
103
|
+
} catch {
|
|
104
|
+
// already gone, or this instance has been invalidated
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function isNativeGortexTool(name: string): boolean {
|
|
109
|
+
return name.startsWith(MCP_TOOL_PREFIX);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The daemon's own name for a native tool, as Gortex guidance spells it. */
|
|
113
|
+
export function bareToolName(name: string): string {
|
|
114
|
+
return isNativeGortexTool(name) ? name.slice(MCP_TOOL_PREFIX.length) : name;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** True once Pi has registered at least one tool from the gortex server. */
|
|
118
|
+
export function nativeToolsReady(pi: ExtensionAPI): boolean {
|
|
119
|
+
try {
|
|
120
|
+
return (pi as NativeMcpApi).getAllTools().some((tool) => isNativeGortexTool(tool.name));
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* How long the first turn waits for the gortex tools on top of Pi's own wait.
|
|
128
|
+
* Pi's MCP extension already holds the first prompt for up to 10s on a direct
|
|
129
|
+
* server, so this only covers a daemon still warming up after that.
|
|
130
|
+
*/
|
|
131
|
+
export const NATIVE_READY_WAIT_MS = 5_000;
|
|
132
|
+
|
|
133
|
+
/** Resolves true once the gortex tools are registered, false when `ms` passes first. */
|
|
134
|
+
export async function waitForNativeTools(pi: ExtensionAPI, ms: number, pollMs = 100): Promise<boolean> {
|
|
135
|
+
const deadline = Date.now() + ms;
|
|
136
|
+
while (!nativeToolsReady(pi)) {
|
|
137
|
+
if (Date.now() >= deadline) return false;
|
|
138
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
package/src/render.ts
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
// TUI rendering for bridged tools: a call line read off Gortex's argument
|
|
2
2
|
// shape (operation, target, options), Pi's own read renderer for a file read,
|
|
3
|
-
// and a result collapsed until the user expands it.
|
|
3
|
+
// an edit's diff, and a result collapsed until the user expands it.
|
|
4
4
|
|
|
5
|
-
import { createReadToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import type { Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import { createReadToolDefinition, renderDiff } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { Theme, ToolDefinition, ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
8
8
|
import type { Component } from "@earendil-works/pi-tui";
|
|
9
9
|
|
|
10
|
+
import type { EditDetails } from "./diff.ts";
|
|
10
11
|
import { textFromResult } from "./mcp-client.ts";
|
|
12
|
+
import { isNativeGortexTool } from "./native.ts";
|
|
11
13
|
|
|
12
14
|
type ToolRenderContext = Parameters<NonNullable<ToolDefinition["renderCall"]>>[2];
|
|
13
15
|
|
|
14
16
|
export type RenderCall = (args: Record<string, unknown>, theme: Theme, context: ToolRenderContext) => Component;
|
|
15
|
-
export type RenderResult = (
|
|
17
|
+
export type RenderResult = (
|
|
18
|
+
result: unknown,
|
|
19
|
+
options: { expanded?: boolean },
|
|
20
|
+
theme?: Theme,
|
|
21
|
+
context?: ToolRenderContext,
|
|
22
|
+
) => Component;
|
|
16
23
|
|
|
17
24
|
const OPERATION_ARGS = ["operation", "kind"];
|
|
18
25
|
const SUBJECT_ARGS = ["target", "query", "question", "task", "path"];
|
|
@@ -144,6 +151,56 @@ export function resultText(result: unknown): string {
|
|
|
144
151
|
return textFromResult(result) || JSON.stringify(structured ?? result ?? {});
|
|
145
152
|
}
|
|
146
153
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
/** diffView draws each file an edit changed with Pi's diff renderer, or nothing when the result has none. */
|
|
155
|
+
function diffView(result: unknown, theme: Theme | undefined, context: ToolRenderContext | undefined): Container | undefined {
|
|
156
|
+
const diffs = (result as { details?: EditDetails })?.details?.diffs;
|
|
157
|
+
if (!diffs?.length || !theme || context?.isError) return undefined;
|
|
158
|
+
const view = new Container();
|
|
159
|
+
for (const { path, diff, note } of diffs) {
|
|
160
|
+
view.addChild(new Spacer(1));
|
|
161
|
+
if (note) {
|
|
162
|
+
view.addChild(new Text(`${theme.fg("accent", path)} ${theme.fg("muted", note)}`, 1, 0));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (diffs.length > 1) view.addChild(new Text(theme.fg("accent", path), 1, 0));
|
|
166
|
+
view.addChild(new Text(renderDiff(diff, { filePath: path }), 1, 0));
|
|
167
|
+
}
|
|
168
|
+
return view;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* renderResult shows the diff of every file an edit changed, the way Pi's own
|
|
173
|
+
* edit does. Gortex's text reply stays collapsed until the user expands it
|
|
174
|
+
* with ctrl+o.
|
|
175
|
+
*/
|
|
176
|
+
export const renderResult: RenderResult = (result, options, theme, context) => {
|
|
177
|
+
const text = new Text(options?.expanded ? resultText(result) : "", 0, 0);
|
|
178
|
+
const view = diffView(result, theme, context);
|
|
179
|
+
if (!view) return text;
|
|
180
|
+
if (options?.expanded) view.addChild(text);
|
|
181
|
+
return view;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* nativeRenderers draws the diffs watchNativeEdits stores above Pi's own MCP
|
|
186
|
+
* result, which shows once expanded or on an error. Every other tool keeps its
|
|
187
|
+
* renderers.
|
|
188
|
+
*/
|
|
189
|
+
export function nativeRenderers(toolName: string, next: () => ToolRenderers | undefined): ToolRenderers | undefined {
|
|
190
|
+
const base = next();
|
|
191
|
+
// Without a base, Pi's MCP extension fills in its own renderers.
|
|
192
|
+
if (!base || !isNativeGortexTool(toolName)) return base;
|
|
193
|
+
const baseResult = base.renderResult;
|
|
194
|
+
return {
|
|
195
|
+
...base,
|
|
196
|
+
renderResult(result, options, theme, context) {
|
|
197
|
+
// Pi's renderer reuses lastComponent as its own and clears it.
|
|
198
|
+
const own = () => baseResult?.(result, options, theme, { ...context, lastComponent: undefined });
|
|
199
|
+
const rest = options.expanded || context.isError ? own() : undefined;
|
|
200
|
+
const view = diffView(result, theme, context);
|
|
201
|
+
if (!view) return rest ?? new Text("", 0, 0);
|
|
202
|
+
if (rest) view.addChild(rest);
|
|
203
|
+
return view;
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -8,11 +8,15 @@ import type { SpawnOptions } from "node:child_process";
|
|
|
8
8
|
|
|
9
9
|
export interface ChildStream {
|
|
10
10
|
on(event: string, listener: (chunk: unknown) => void): unknown;
|
|
11
|
+
/** Drops the event-loop reference; I/O keeps working. */
|
|
12
|
+
unref?(): unknown;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export interface ChildStdin {
|
|
14
16
|
on(event: string, listener: (err: unknown) => void): unknown;
|
|
15
17
|
write(chunk: string): unknown;
|
|
18
|
+
/** Drops the event-loop reference; I/O keeps working. */
|
|
19
|
+
unref?(): unknown;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
/** The subset of ChildProcess the MCP bridge and the daemon launcher touch. */
|
package/src/state.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// Shared at module level on purpose: a new invocation's session_start stops the
|
|
4
4
|
// previous invocation's bridge child, which works only while both invocations
|
|
5
5
|
// see the same `client`. Move this into the factory closure and every `/new`
|
|
6
|
-
// leaks a `gortex mcp` process.
|
|
6
|
+
// leaks a `gortex mcp` process. Reload is the exception: Pi re-imports the
|
|
7
|
+
// extension with a fresh module graph, so there the old instance's
|
|
8
|
+
// session_shutdown handler is what stops its bridge.
|
|
7
9
|
|
|
8
10
|
import type { MCPStdioClient } from "./mcp-client.ts";
|
|
9
11
|
|
|
@@ -31,6 +33,13 @@ let versionWarning = "";
|
|
|
31
33
|
*/
|
|
32
34
|
export const gortexToolNames = new Set<string>();
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Registered names the daemon has since dropped from its list. Pi cannot
|
|
38
|
+
* unregister a tool, so these are switched off in the active set instead, and
|
|
39
|
+
* back on if the daemon lists them again.
|
|
40
|
+
*/
|
|
41
|
+
export const withdrawnToolNames = new Set<string>();
|
|
42
|
+
|
|
34
43
|
export function getClient(): MCPStdioClient | null {
|
|
35
44
|
return client;
|
|
36
45
|
}
|