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/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,
@@ -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
- // A Pi build without a UI (print/JSON mode) or without notify must not take
86
- // the session down over an advisory message.
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(user, "warning");
109
+ if (ctx?.hasUI) ctx.ui.notify(message, "warning");
89
110
  } catch {
90
- // the model still gets the orientation line above
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
- // Orientation awaiting injection into the next LLM call, computed once per
117
- // session. The `context` hook appends it as a tail user message, because a
118
- // systemPrompt change sits at messages[0] and invalidates prefix caching.
119
- let pendingOrientation = "";
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
- pendingOrientation = "";
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
- // startSession holds the body of session_start so the readiness promise
182
- // above settles on every exit path, early returns included.
183
- async function startSession(ctx?: ExtensionContext): Promise<void> {
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
- // Fires before the agent loop's first LLM call. It can't mutate messages
218
- // itself, so it just parks the orientation for the `context` hook.
219
- pi.on("before_agent_start", async () => {
220
- if (startupHookRan) return;
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
- const ready = await waitForSession(readyWaitMs);
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
- if (decision.orientation) parts.push(decision.orientation);
254
- if (parts.length > 0) pendingOrientation = parts.join("\n\n");
255
- return;
256
- });
257
-
258
- // Fires before each LLM call with a mutable message array. Appends the
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
- if (!config.enforce) return;
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
- pi.on("tool_call", async (event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | void> => {
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
- const norm = normalizeToolCall(piName, piInput);
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
- 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;
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 = (result: unknown, options: { expanded?: boolean }) => Component;
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
- /** renderResult collapses the result to nothing until the user expands it with ctrl+o. */
148
- export const renderResult: RenderResult = (result, options) =>
149
- new Text(options?.expanded ? resultText(result) : "", 0, 0);
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
  }