pi-gortex 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -12
- package/docs/architecture.md +23 -8
- package/docs/tests.md +4 -4
- package/package.json +1 -1
- package/src/config.ts +70 -20
- package/src/hook.ts +4 -3
- package/src/index.ts +50 -17
- package/src/mcp-client.ts +159 -80
- package/src/native.ts +1 -1
- package/src/runtime.ts +3 -0
- package/src/tools.ts +2 -2
package/README.md
CHANGED
|
@@ -13,13 +13,16 @@ need instead of reading whole files around it, which is where the token
|
|
|
13
13
|
savings come from. It is a single static binary with no dependency chain,
|
|
14
14
|
and it covers 257 languages through tree-sitter.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
This extension connects the two. It hands `gortex mcp` to Pi's built-in MCP,
|
|
17
|
+
or runs its own MCP client with `native_mcp` off. Read discipline goes through
|
|
18
|
+
`gortex hook` on both.
|
|
17
19
|
|
|
18
20
|
## What it does
|
|
19
21
|
|
|
20
|
-
- **Registers Gortex's graph tools as native Pi tools**,
|
|
21
|
-
`gortex mcp` child per session. The
|
|
22
|
-
daemon's own `tools_search`, and
|
|
22
|
+
- **Registers Gortex's graph tools as native Pi tools**, through Pi's
|
|
23
|
+
built-in MCP or over the extension's own `gortex mcp` child per session. The
|
|
24
|
+
deferred catalogue is reached through the daemon's own `tools_search`, and
|
|
25
|
+
promoted tools appear in Pi's registry.
|
|
23
26
|
- **Applies read discipline.** Pi's tool calls are forwarded to
|
|
24
27
|
`gortex hook --agent=pi`, which answers block / soft guidance / nothing
|
|
25
28
|
according to the posture you configured. Gortex owns that logic, so the
|
|
@@ -31,7 +34,7 @@ See [docs/architecture.md](docs/architecture.md) for how it works.
|
|
|
31
34
|
|
|
32
35
|
## Requirements
|
|
33
36
|
|
|
34
|
-
- **Node 24+**, **Pi 0
|
|
37
|
+
- **Node 24+**, **Pi 1.0+**
|
|
35
38
|
- **The `gortex` binary** on `PATH`, **v0.61.2 or newer**. See
|
|
36
39
|
[Gortex installation](https://github.com/zzet/gortex/blob/main/docs/installation.md).
|
|
37
40
|
The extension starts the shared daemon itself and never blocks Pi's startup
|
|
@@ -57,16 +60,16 @@ the absolute path, or a single run with `pi -e /path/to/pi-gortex`.
|
|
|
57
60
|
## Configure
|
|
58
61
|
|
|
59
62
|
Zero configuration required. Every value resolves at runtime, first hit wins:
|
|
60
|
-
environment -> `<cwd>/.pi/gortex.json` -> `<agent dir>/extensions/gortex.json`
|
|
63
|
+
environment -> `<session cwd>/.pi/gortex.json` -> `<agent dir>/extensions/gortex.json`
|
|
61
64
|
(agent dir is `$PI_CODING_AGENT_DIR`, else `~/.pi/agent`) -> defaults.
|
|
62
65
|
|
|
63
66
|
| Sidecar key | Environment | Default | Meaning |
|
|
64
67
|
|----------------|--------------------|--------------------|---------|
|
|
65
|
-
| `bin` | `GORTEX_BIN` | `gortex` on `PATH` |
|
|
66
|
-
| `hook_mode` | `GORTEX_HOOK_MODE` | `deny` | Posture: `deny`, `enrich`, `consult-unlock`, `nudge
|
|
68
|
+
| `bin` | `GORTEX_BIN` | `gortex` on `PATH` | The gortex binary. Read from the global sidecar only; a project file cannot set it. `~` expands, and a relative path resolves against the sidecar's directory (the launch directory for `GORTEX_BIN`). |
|
|
69
|
+
| `hook_mode` | `GORTEX_HOOK_MODE` | `deny` | Posture: `deny`, `enrich`, `consult-unlock`, `nudge` (`adaptive-nudge` is an alias). |
|
|
67
70
|
| `enforce` | `GORTEX_ENFORCE` | `true` | `false` keeps the graph tools and the briefing, and wires no enforcement. |
|
|
68
|
-
| `tools_preset` | `GORTEX_TOOLS` |
|
|
69
|
-
| `native_mcp` | `GORTEX_NATIVE_MCP` | `true` |
|
|
71
|
+
| `tools_preset` | `GORTEX_TOOLS` | daemon's default | Eager MCP surface: a Gortex preset (`core`, `full`, `agent`, `edit`, `nav`, `readonly`, `localization`), optionally followed by comma-separated tool names and `+tool`/`-tool` changes, e.g. `nav,+edit_file`. A value that does not open with a preset is ignored. |
|
|
72
|
+
| `native_mcp` | `GORTEX_NATIVE_MCP` | `true` | Register `gortex mcp` with Pi's built-in MCP. `false` keeps the extension's own client. |
|
|
70
73
|
|
|
71
74
|
```json
|
|
72
75
|
{
|
|
@@ -76,8 +79,8 @@ environment -> `<cwd>/.pi/gortex.json` -> `<agent dir>/extensions/gortex.json`
|
|
|
76
79
|
}
|
|
77
80
|
```
|
|
78
81
|
|
|
79
|
-
The extension reads its configuration
|
|
80
|
-
effect
|
|
82
|
+
The extension reads its configuration at every session start, from the
|
|
83
|
+
directory the session runs in, so a change takes effect on `/reload` or `/new`.
|
|
81
84
|
|
|
82
85
|
**Pi's own `mcp.json` wins.** With `native_mcp` on, the extension registers a
|
|
83
86
|
server named `gortex`. A `gortex` entry in `~/.pi/agent/mcp.json` or
|
package/docs/architecture.md
CHANGED
|
@@ -68,15 +68,15 @@ implementation forwards the call down that channel.
|
|
|
68
68
|
arrive in bursts, so the re-sync is debounced; a search call awaits its own
|
|
69
69
|
re-sync directly, so every tool its reply names is callable by the time the
|
|
70
70
|
model reads it.
|
|
71
|
-
- **Framing.**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
call respawns it.
|
|
71
|
+
- **Framing.** Frames are cut at the newline byte and decoded whole, so a
|
|
72
|
+
character split across chunks arrives intact. A frame past a generous byte
|
|
73
|
+
cap kills the child, because a stream cannot be resynced mid-frame, and the
|
|
74
|
+
next call respawns it.
|
|
76
75
|
- **Timeouts.** Every request is bounded: a short cap for ordinary requests, a
|
|
77
76
|
longer one for the handshake, the longest for tool calls. The last is generous
|
|
78
77
|
enough for analyzers that legitimately run for minutes, and finite so a wedged
|
|
79
|
-
daemon cannot hang an agent turn forever.
|
|
78
|
+
daemon cannot hang an agent turn forever. A call dropped on timeout or on
|
|
79
|
+
Pi's abort tells the daemon to cancel it.
|
|
80
80
|
|
|
81
81
|
### Tool-name aliasing
|
|
82
82
|
|
|
@@ -115,7 +115,8 @@ keys. That translation lives on this side of the bridge, so the Go side keeps
|
|
|
115
115
|
one vocabulary for every agent it serves.
|
|
116
116
|
|
|
117
117
|
A call to a Gortex tool is flagged as a graph call and sent under the daemon's
|
|
118
|
-
own tool name, which is what the postures key off.
|
|
118
|
+
own tool name, which is what the postures key off. On Pi's built-in MCP that
|
|
119
|
+
means the `mcp__gortex__` prefix is stripped. It skips the translation, so
|
|
119
120
|
Gortex's `read` never passes for Pi's. On Pi's built-in MCP the same hook also
|
|
120
121
|
sees each tool a codemode script calls.
|
|
121
122
|
|
|
@@ -193,6 +194,20 @@ project file and a global one to defaults. See the table in the
|
|
|
193
194
|
[README](../README.md#configure). Runtime resolution is what lets one artifact
|
|
194
195
|
serve both the npm package and a directory install.
|
|
195
196
|
|
|
196
|
-
|
|
197
|
+
Resolution runs on every `session_start`, against the session's own directory.
|
|
198
|
+
A resumed session runs where it was created, which need not be where Pi was
|
|
199
|
+
launched. So the native server is registered on `session_start` rather than
|
|
200
|
+
when Pi loads the extension, and again only when the session's preset or
|
|
201
|
+
channel differ, since Pi reconnects a re-registered server. Pi reads a
|
|
202
|
+
registration made before its own `session_start` handler and connects one made
|
|
203
|
+
after it. The extension's own `gortex mcp` is spawned in the session's
|
|
204
|
+
directory, which is what the proxy scopes the graph to.
|
|
205
|
+
|
|
206
|
+
A tool preset reaches `gortex mcp` only when it opens with a preset Gortex
|
|
207
|
+
knows. Gortex reads anything else as a bare allow-list, so a typo would strand
|
|
208
|
+
the session; it falls through to the next layer instead. The resolved value
|
|
209
|
+
always overrides an inherited `GORTEX_TOOLS`.
|
|
210
|
+
|
|
211
|
+
The project file sits in whatever repository the session runs in, so it cannot
|
|
197
212
|
set `bin`. Otherwise a cloned repository could choose the executable the
|
|
198
213
|
extension spawns. The binary comes from `GORTEX_BIN`, the global file or `PATH`.
|
package/docs/tests.md
CHANGED
|
@@ -88,15 +88,15 @@ point in the directory when the manifest resolves to nothing.
|
|
|
88
88
|
## Where the double stops
|
|
89
89
|
|
|
90
90
|
The double models what the suites need and no more. Behaviour past that edge,
|
|
91
|
-
such as promotion re-sync under real notification traffic,
|
|
92
|
-
|
|
91
|
+
such as promotion re-sync under real notification traffic, a real model turn or
|
|
92
|
+
a live daemon, is unverified here. A change
|
|
93
93
|
in that territory is verified by hand against a running Gortex, and the suite
|
|
94
94
|
says as much rather than implying coverage it does not have.
|
|
95
95
|
|
|
96
96
|
## Pinning
|
|
97
97
|
|
|
98
|
-
The Pi devDependency is pinned tight on purpose. Pi
|
|
99
|
-
|
|
98
|
+
The Pi devDependency is pinned tight on purpose. Pi's runner's method surface
|
|
99
|
+
has been renamed without a changelog entry before, so a tight pin
|
|
100
100
|
turns that into a red build rather than a silently skipped suite. The runtime
|
|
101
101
|
peer dependency stays wide.
|
|
102
102
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-gortex",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Gortex for Pi: high-performance code-intelligence engine for AI agents, based on graph. Exposes only the information the agent needs, cutting token usage up to 50x. 100% local.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/config.ts
CHANGED
|
@@ -7,17 +7,17 @@
|
|
|
7
7
|
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
10
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
11
11
|
|
|
12
12
|
/** The sidecar file's on-disk shape. `gortex init` is its writer. */
|
|
13
13
|
export interface SidecarConfig {
|
|
14
|
-
/**
|
|
14
|
+
/** The gortex binary. Global sidecar only; relative to the sidecar's directory. Default: resolved from PATH. */
|
|
15
15
|
bin?: string;
|
|
16
16
|
/** Hook posture: deny | enrich | consult-unlock | nudge. */
|
|
17
17
|
hook_mode?: string;
|
|
18
18
|
/** false mirrors `gortex init --no-hooks`: orientation only, no enforcement. */
|
|
19
19
|
enforce?: boolean;
|
|
20
|
-
/** Eager MCP tool preset
|
|
20
|
+
/** Eager MCP tool preset (core | full | agent | edit | nav | readonly | localization) or a tool spec. */
|
|
21
21
|
tools_preset?: string;
|
|
22
22
|
/** false keeps the extension's own MCP client on a Pi that has built-in MCP. */
|
|
23
23
|
native_mcp?: boolean;
|
|
@@ -31,7 +31,7 @@ export interface GortexConfig {
|
|
|
31
31
|
hookArgv: string[];
|
|
32
32
|
/** Whether to wire read-discipline enforcement onto tool_call. */
|
|
33
33
|
enforce: boolean;
|
|
34
|
-
/**
|
|
34
|
+
/** GORTEX_TOOLS for `gortex mcp`; empty leaves the daemon's default surface. */
|
|
35
35
|
toolsPreset: string;
|
|
36
36
|
/** Whether to hand the tool channel to Pi's built-in MCP when Pi has one. */
|
|
37
37
|
nativeMcp: boolean;
|
|
@@ -47,7 +47,15 @@ export interface ResolveConfigOptions {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export const DEFAULT_BIN = "gortex";
|
|
50
|
-
|
|
50
|
+
/** No preset: `gortex mcp` serves the daemon's default surface. */
|
|
51
|
+
export const DEFAULT_TOOLS_PRESET = "";
|
|
52
|
+
|
|
53
|
+
/** Expands a leading `~`, `~/` or `~\` to the home directory. */
|
|
54
|
+
export function expandHome(path: string, home: string = homedir()): string {
|
|
55
|
+
if (path === "~") return home;
|
|
56
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
|
|
57
|
+
return path;
|
|
58
|
+
}
|
|
51
59
|
|
|
52
60
|
/**
|
|
53
61
|
* Pi's agent directory, resolved the way Pi itself resolves it
|
|
@@ -59,15 +67,46 @@ export const DEFAULT_TOOLS_PRESET = "core";
|
|
|
59
67
|
*/
|
|
60
68
|
export function defaultAgentDir(env: Record<string, string | undefined> = process.env): string {
|
|
61
69
|
const override = env.PI_CODING_AGENT_DIR;
|
|
62
|
-
if (override && override.trim() !== "")
|
|
63
|
-
const path = override.trim();
|
|
64
|
-
if (path === "~") return homedir();
|
|
65
|
-
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
|
66
|
-
return path;
|
|
67
|
-
}
|
|
70
|
+
if (override && override.trim() !== "") return expandHome(override.trim());
|
|
68
71
|
return join(homedir(), ".pi", "agent");
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
/**
|
|
75
|
+
* A `bin` setting as spawn needs it: `~` expanded, a relative path anchored at
|
|
76
|
+
* `baseDir`, a bare name left for PATH lookup.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveBin(bin: string, baseDir: string): string {
|
|
79
|
+
const expanded = expandHome(bin);
|
|
80
|
+
if (!/[\\/]/.test(expanded) || isAbsolute(expanded)) return expanded;
|
|
81
|
+
return resolve(baseDir, expanded);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Preset names and aliases Gortex accepts (internal/mcp/tool_presets.go).
|
|
85
|
+
const KNOWN_PRESETS = new Set([
|
|
86
|
+
"full", "all",
|
|
87
|
+
"core", "default", "classic",
|
|
88
|
+
"agent", "coding-agent",
|
|
89
|
+
"facade-v1", "agent-v2", "compact", "facade",
|
|
90
|
+
"readonly", "read-only", "read_only",
|
|
91
|
+
"edit", "editor", "edit-harness",
|
|
92
|
+
"nav", "navigate", "explore",
|
|
93
|
+
"localization", "locate", "find",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A tools_preset / GORTEX_TOOLS value Gortex should see, or undefined. Gortex
|
|
98
|
+
* reads a spec that does not open with a preset as a bare allow-list, so a
|
|
99
|
+
* typo (or `+tool` alone) would strip the session down to a tool or two. Only
|
|
100
|
+
* a spec opening with a known preset passes, optionally followed by
|
|
101
|
+
* comma-separated tool names and `+tool`/`-tool` changes.
|
|
102
|
+
*/
|
|
103
|
+
export function toolsSpec(value: string | undefined): string | undefined {
|
|
104
|
+
const tokens = (value ?? "").split(",").map((token) => token.trim()).filter(Boolean);
|
|
105
|
+
const preset = tokens[0]?.toLowerCase();
|
|
106
|
+
if (preset === undefined || !KNOWN_PRESETS.has(preset)) return undefined;
|
|
107
|
+
return [preset, ...tokens.slice(1)].join(",");
|
|
108
|
+
}
|
|
109
|
+
|
|
71
110
|
/** Sidecar paths, nearest first. A project file wins over the global one. */
|
|
72
111
|
export function sidecarPaths(cwd: string, agentDir: string): string[] {
|
|
73
112
|
return [join(cwd, ".pi", "gortex.json"), join(agentDir, "extensions", "gortex.json")];
|
|
@@ -139,30 +178,41 @@ export function resolveConfig(opts: ResolveConfigOptions = {}): GortexConfig {
|
|
|
139
178
|
const agentDir = opts.agentDir ?? defaultAgentDir(env);
|
|
140
179
|
const readFile = opts.readFile ?? ((path: string) => readFileSync(path, "utf8"));
|
|
141
180
|
|
|
142
|
-
const [
|
|
181
|
+
const [projectPath, globalPath] = sidecarPaths(cwd, agentDir) as [string, string];
|
|
182
|
+
const project = readSidecar(projectPath, readFile);
|
|
183
|
+
const global = readSidecar(globalPath, readFile);
|
|
143
184
|
|
|
144
185
|
// A checked-out repository must never pick the executable, so the project
|
|
145
186
|
// sidecar has no say in `bin`. `gortex` unqualified is resolved against PATH.
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
const
|
|
187
|
+
const envBin = firstString(env.GORTEX_BIN);
|
|
188
|
+
const globalBin = firstString(global.bin);
|
|
189
|
+
const bin = envBin
|
|
190
|
+
? resolveBin(envBin, process.cwd())
|
|
191
|
+
: globalBin
|
|
192
|
+
? resolveBin(globalBin, dirname(globalPath))
|
|
193
|
+
: DEFAULT_BIN;
|
|
194
|
+
|
|
195
|
+
const mode = normalizeMode(firstString(env.GORTEX_HOOK_MODE, project.hook_mode, global.hook_mode));
|
|
149
196
|
const hookArgv = [bin, "hook", "--agent=pi"];
|
|
150
197
|
// "deny" is the hook's own default, so it rides as an absent flag.
|
|
151
198
|
if (mode !== "deny") hookArgv.push(`--mode=${mode}`);
|
|
152
199
|
|
|
153
200
|
const enforce =
|
|
154
201
|
parseBoolean(env.GORTEX_ENFORCE) ??
|
|
155
|
-
(typeof project
|
|
156
|
-
(typeof global
|
|
202
|
+
(typeof project.enforce === "boolean" ? project.enforce : undefined) ??
|
|
203
|
+
(typeof global.enforce === "boolean" ? global.enforce : undefined) ??
|
|
157
204
|
true;
|
|
158
205
|
|
|
159
206
|
const toolsPreset =
|
|
160
|
-
|
|
207
|
+
toolsSpec(env.GORTEX_TOOLS) ??
|
|
208
|
+
toolsSpec(firstString(project.tools_preset)) ??
|
|
209
|
+
toolsSpec(firstString(global.tools_preset)) ??
|
|
210
|
+
DEFAULT_TOOLS_PRESET;
|
|
161
211
|
|
|
162
212
|
const nativeMcp =
|
|
163
213
|
parseBoolean(env.GORTEX_NATIVE_MCP) ??
|
|
164
|
-
(typeof project
|
|
165
|
-
(typeof global
|
|
214
|
+
(typeof project.native_mcp === "boolean" ? project.native_mcp : undefined) ??
|
|
215
|
+
(typeof global.native_mcp === "boolean" ? global.native_mcp : undefined) ??
|
|
166
216
|
true;
|
|
167
217
|
|
|
168
218
|
return { bin, hookArgv, enforce, toolsPreset, nativeMcp };
|
package/src/hook.ts
CHANGED
|
@@ -26,6 +26,7 @@ const HOOK_EXEC_OPTS: ExecFileSyncOpts = {
|
|
|
26
26
|
encoding: "utf8",
|
|
27
27
|
maxBuffer: 16 * 1024 * 1024,
|
|
28
28
|
timeout: 5_000,
|
|
29
|
+
windowsHide: true,
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -55,9 +56,9 @@ export function callHook(argv: string[], deps: ProcessDeps, envelope: PiEnvelope
|
|
|
55
56
|
// The Go classifier switches on Claude-Code tool names and canonical input
|
|
56
57
|
// keys ("file_path"/"pattern"/"command"), so the translation lives here.
|
|
57
58
|
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
59
|
+
// The built-in tool names mapped here are pinned to Pi's documented API
|
|
60
|
+
// (packages/coding-agent/docs/extensions.md); if an upgrade renames one, adjust
|
|
61
|
+
// this small map; the Go wire contract stays unchanged.
|
|
61
62
|
export const toolNameMap: Record<string, string> = {
|
|
62
63
|
read: "Read",
|
|
63
64
|
grep: "Grep",
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
} from "@earendil-works/pi-coding-agent";
|
|
12
12
|
|
|
13
13
|
import { resolveConfig } from "./config.ts";
|
|
14
|
-
import type { GortexConfig } from "./config.ts";
|
|
14
|
+
import type { GortexConfig, ResolveConfigOptions } from "./config.ts";
|
|
15
15
|
import { watchNativeEdits } from "./diff.ts";
|
|
16
16
|
import { callHook, normalizeToolCall } from "./hook.ts";
|
|
17
17
|
import {
|
|
@@ -145,22 +145,29 @@ function briefingInContext(ctx: ExtensionContext | undefined): boolean {
|
|
|
145
145
|
|
|
146
146
|
/** Test seams. Pi itself calls the factory with the `pi` object alone. */
|
|
147
147
|
export interface GortexExtensionOptions {
|
|
148
|
-
/**
|
|
148
|
+
/** Fixed configuration; resolveConfig() runs per session when this is absent. */
|
|
149
149
|
config?: GortexConfig;
|
|
150
|
+
/** resolveConfig() seams; `cwd` stands in for the process cwd until a session supplies one. */
|
|
151
|
+
resolve?: ResolveConfigOptions;
|
|
150
152
|
/** Process seam for spawn/execFileSync. */
|
|
151
153
|
deps?: ProcessDeps;
|
|
152
154
|
/** Readiness cap override. */
|
|
153
155
|
readyWaitMs?: number;
|
|
154
156
|
/** Override for the wait on Pi's built-in MCP connecting the gortex server. */
|
|
155
157
|
nativeReadyWaitMs?: number;
|
|
158
|
+
/** tools/call timeout override for the extension's own client. */
|
|
159
|
+
callTimeoutMs?: number;
|
|
160
|
+
/** Frame size cap override for the extension's own client. */
|
|
161
|
+
maxFrameBytes?: number;
|
|
156
162
|
}
|
|
157
163
|
|
|
158
164
|
export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensionOptions = {}): void {
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
// Resolved again on every session_start from ctx.cwd, the directory the
|
|
166
|
+
// session runs in. The factory has no session, so this first resolution only
|
|
167
|
+
// serves a turn that outruns session_start.
|
|
168
|
+
const configFor = (cwd?: string): GortexConfig =>
|
|
169
|
+
options.config ?? resolveConfig({ ...options.resolve, cwd: cwd ?? options.resolve?.cwd });
|
|
170
|
+
let config = configFor();
|
|
164
171
|
const deps = options.deps ?? nodeProcessDeps;
|
|
165
172
|
const readyWaitMs = options.readyWaitMs ?? READY_WAIT_MS;
|
|
166
173
|
const nativeReadyWaitMs = options.nativeReadyWaitMs ?? NATIVE_READY_WAIT_MS;
|
|
@@ -169,12 +176,27 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
169
176
|
// (which starts the daemon itself), registers the tools and renders them.
|
|
170
177
|
// A binary that does not resolve, or a registration Pi refuses, falls back to
|
|
171
178
|
// the extension's own client, which reports its failure to the model.
|
|
179
|
+
// Registered on session_start only, once the session's config is known: Pi
|
|
180
|
+
// reads a registration made before its own session_start handler, and
|
|
181
|
+
// connects one made after it.
|
|
172
182
|
let nativeRefusal = "";
|
|
173
|
-
let native =
|
|
174
|
-
|
|
183
|
+
let native = false;
|
|
184
|
+
let registered: GortexConfig | null = null;
|
|
185
|
+
function registerNative(connector: boolean): void {
|
|
186
|
+
if (native) unregisterNativeServer(pi);
|
|
187
|
+
native = false;
|
|
188
|
+
registered = config;
|
|
189
|
+
nativeRefusal = "";
|
|
190
|
+
if (!connector || !config.nativeMcp || !supportsNativeMcp(pi) || !binaryResolves(config.bin)) return;
|
|
175
191
|
nativeRefusal = registerNativeServer(pi, config);
|
|
176
|
-
|
|
177
|
-
}
|
|
192
|
+
native = nativeRefusal === "";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Re-registering an unchanged server would make Pi reconnect it.
|
|
196
|
+
const channelChanged = (): boolean =>
|
|
197
|
+
registered === null ||
|
|
198
|
+
registered.nativeMcp !== config.nativeMcp ||
|
|
199
|
+
registered.toolsPreset !== config.toolsPreset;
|
|
178
200
|
|
|
179
201
|
// Latched for the whole session once the first turn has run the
|
|
180
202
|
// session_start hook, whether or not it produced anything to inject.
|
|
@@ -259,7 +281,10 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
259
281
|
// startSession holds the body of session_start so the readiness promise
|
|
260
282
|
// above settles on every exit path, early returns included.
|
|
261
283
|
async function startSession(ctx?: ExtensionContext): Promise<void> {
|
|
262
|
-
|
|
284
|
+
config = configFor(ctx?.cwd);
|
|
285
|
+
const connector = mcpConnectorLoaded(pi);
|
|
286
|
+
if (channelChanged()) registerNative(connector);
|
|
287
|
+
if (native && !connector) {
|
|
263
288
|
unregisterNativeServer(pi);
|
|
264
289
|
native = false;
|
|
265
290
|
}
|
|
@@ -269,7 +294,14 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
269
294
|
gortexToolNames.clear();
|
|
270
295
|
withdrawnToolNames.clear();
|
|
271
296
|
stopBridge();
|
|
272
|
-
const c = new MCPStdioClient({
|
|
297
|
+
const c = new MCPStdioClient({
|
|
298
|
+
bin: config.bin,
|
|
299
|
+
toolsPreset: config.toolsPreset,
|
|
300
|
+
cwd: ctx?.cwd,
|
|
301
|
+
deps,
|
|
302
|
+
callTimeoutMs: options.callTimeoutMs,
|
|
303
|
+
maxFrameBytes: options.maxFrameBytes,
|
|
304
|
+
});
|
|
273
305
|
// Claim the slot before the first await: an overlapping
|
|
274
306
|
// session_start (rapid /new or /reload) then sees and stops THIS
|
|
275
307
|
// bridge instead of leaking its child mid-handshake.
|
|
@@ -316,7 +348,7 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
316
348
|
const ready = native ? await waitForNativeTools(pi, nativeReadyWaitMs) : sessionDone;
|
|
317
349
|
const decision = callHook(config.hookArgv, deps, {
|
|
318
350
|
event: "session_start",
|
|
319
|
-
cwd: ctx?.cwd ??
|
|
351
|
+
cwd: ctx?.cwd ?? process.cwd(),
|
|
320
352
|
});
|
|
321
353
|
// Pi's MCP keeps the handshake to itself, so the version comes from the briefing.
|
|
322
354
|
if (native && decision.orientation) {
|
|
@@ -376,7 +408,7 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
376
408
|
event: "tool_call",
|
|
377
409
|
tool_name: norm.tool_name,
|
|
378
410
|
tool_input: norm.tool_input,
|
|
379
|
-
cwd: ctx?.cwd ??
|
|
411
|
+
cwd: ctx?.cwd ?? process.cwd(),
|
|
380
412
|
session_id: (ctx as { sessionManager?: { sessionId?: string } })?.sessionManager?.sessionId ?? "",
|
|
381
413
|
is_gortex_tool: isGortexTool,
|
|
382
414
|
});
|
|
@@ -397,12 +429,13 @@ export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensi
|
|
|
397
429
|
}
|
|
398
430
|
return;
|
|
399
431
|
};
|
|
400
|
-
|
|
432
|
+
// Registered unconditionally, since a session's own sidecar can turn enforcement on.
|
|
433
|
+
pi.on("tool_call", (event, ctx) => (config.enforce ? enforceReadDiscipline(event, ctx) : undefined));
|
|
401
434
|
|
|
402
435
|
// After enforcement, so a blocked call is never snapshotted. Shell calls are
|
|
403
436
|
// tracked on both channels; edits are snapshotted on Pi's built-in MCP only.
|
|
404
437
|
watchNativeEdits(pi, () => native);
|
|
405
|
-
if (
|
|
438
|
+
if (supportsNativeMcp(pi)) {
|
|
406
439
|
// Pi < 1.0.1 has no tool renderers; the diffs are still stored.
|
|
407
440
|
try {
|
|
408
441
|
(pi as Partial<Pick<ExtensionAPI, "registerToolRenderer">>).registerToolRenderer?.(nativeRenderers);
|
package/src/mcp-client.ts
CHANGED
|
@@ -46,36 +46,30 @@ export function isBelowVersion(actual: string, floor: string): boolean {
|
|
|
46
46
|
return false;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
// Presets the proxy has to be told about. The daemon's own default surface
|
|
50
|
-
// already IS core/defer, so those pass nothing. Anything unrecognised (a typo,
|
|
51
|
-
// a stale sidecar value) is NOT forwarded: server-side it would parse as a
|
|
52
|
-
// one-tool allow-list and silently filter every promoted tool out of the
|
|
53
|
-
// session. Fail open to the daemon's default surface instead.
|
|
54
|
-
const FORWARDED_PRESETS = new Set(["edit", "nav", "readonly"]);
|
|
55
|
-
|
|
56
49
|
/**
|
|
57
|
-
* The variables `gortex mcp` needs on top of `env` to serve `toolsPreset
|
|
58
|
-
*
|
|
59
|
-
*
|
|
50
|
+
* The variables `gortex mcp` needs on top of `env` to serve `toolsPreset`, the
|
|
51
|
+
* spec resolveConfig validated (it already folds in the user's GORTEX_TOOLS).
|
|
52
|
+
* Both are always set, so an inherited value resolveConfig rejected never
|
|
53
|
+
* reaches the child; empty leaves the daemon's default surface.
|
|
60
54
|
*/
|
|
61
55
|
export function presetEnv(
|
|
62
56
|
toolsPreset: string,
|
|
63
57
|
env: Record<string, string | undefined>,
|
|
64
58
|
): Record<string, string> {
|
|
65
|
-
|
|
66
|
-
const preset = toolsPreset.trim().toLowerCase();
|
|
67
|
-
const tools = env.GORTEX_TOOLS || (FORWARDED_PRESETS.has(preset) ? preset : "");
|
|
68
|
-
if (tools && !env.GORTEX_TOOLS) out.GORTEX_TOOLS = tools;
|
|
59
|
+
if (toolsPreset === "") return { GORTEX_TOOLS: "", GORTEX_TOOLS_MODE: "" };
|
|
69
60
|
// Never let a preset hide-block tools promoted later by tools_search.
|
|
70
|
-
|
|
71
|
-
return out;
|
|
61
|
+
return { GORTEX_TOOLS: toolsPreset, GORTEX_TOOLS_MODE: env.GORTEX_TOOLS_MODE?.trim() || "defer" };
|
|
72
62
|
}
|
|
73
63
|
|
|
74
64
|
export interface MCPStdioClientOptions {
|
|
75
65
|
bin: string;
|
|
76
66
|
toolsPreset: string;
|
|
67
|
+
/** The session's directory, which `gortex mcp` scopes the graph to. Default: the process cwd. */
|
|
68
|
+
cwd?: string;
|
|
77
69
|
deps: ProcessDeps;
|
|
78
70
|
env?: Record<string, string | undefined>;
|
|
71
|
+
callTimeoutMs?: number;
|
|
72
|
+
maxFrameBytes?: number;
|
|
79
73
|
}
|
|
80
74
|
|
|
81
75
|
interface PendingRequest {
|
|
@@ -84,6 +78,25 @@ interface PendingRequest {
|
|
|
84
78
|
timer?: ReturnType<typeof setTimeout>;
|
|
85
79
|
}
|
|
86
80
|
|
|
81
|
+
/** A JSON-RPC error reply, with the server's code and data kept for the model. */
|
|
82
|
+
export class JsonRpcError extends Error {
|
|
83
|
+
readonly code: number | undefined;
|
|
84
|
+
readonly data: unknown;
|
|
85
|
+
|
|
86
|
+
constructor(error: { code?: unknown; message?: unknown; data?: unknown }) {
|
|
87
|
+
const code = typeof error.code === "number" ? error.code : undefined;
|
|
88
|
+
let message = typeof error.message === "string" && error.message ? error.message : "JSON-RPC error";
|
|
89
|
+
if (code !== undefined) message += ` (${code})`;
|
|
90
|
+
if (error.data !== undefined) {
|
|
91
|
+
message += `: ${typeof error.data === "string" ? error.data : JSON.stringify(error.data)}`;
|
|
92
|
+
}
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = "JsonRpcError";
|
|
95
|
+
this.code = code;
|
|
96
|
+
this.data = error.data;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
export interface ToolDescriptor {
|
|
88
101
|
name: string;
|
|
89
102
|
description?: string;
|
|
@@ -102,6 +115,7 @@ export function ensureDaemon(bin: string, deps: ProcessDeps): void {
|
|
|
102
115
|
const child = deps.spawn(bin, ["daemon", "start", "--detach"], {
|
|
103
116
|
stdio: "ignore",
|
|
104
117
|
detached: true,
|
|
118
|
+
windowsHide: true,
|
|
105
119
|
});
|
|
106
120
|
child.on("error", () => {}); // binary missing / spawn failure: swallow
|
|
107
121
|
// No teardown counterpart, by design: the daemon is shared, long-lived
|
|
@@ -127,8 +141,10 @@ export function textFromResult(result: unknown): string {
|
|
|
127
141
|
|
|
128
142
|
export class MCPStdioClient {
|
|
129
143
|
private child: BridgeChild | null = null;
|
|
130
|
-
|
|
131
|
-
|
|
144
|
+
// Raw bytes of the frame still being read. Frames are cut at the 0x0A byte
|
|
145
|
+
// and decoded whole, so a character split across chunks survives.
|
|
146
|
+
private chunks: Buffer[] = [];
|
|
147
|
+
private bufferedBytes = 0;
|
|
132
148
|
private nextId = 1;
|
|
133
149
|
private pending = new Map<number, PendingRequest>();
|
|
134
150
|
private exited = true;
|
|
@@ -136,8 +152,11 @@ export class MCPStdioClient {
|
|
|
136
152
|
|
|
137
153
|
private readonly bin: string;
|
|
138
154
|
private readonly toolsPreset: string;
|
|
155
|
+
private readonly cwd: string | undefined;
|
|
139
156
|
private readonly deps: ProcessDeps;
|
|
140
157
|
private readonly env: Record<string, string | undefined>;
|
|
158
|
+
private readonly callTimeoutMs: number;
|
|
159
|
+
private readonly maxFrameBytes: number;
|
|
141
160
|
|
|
142
161
|
// Fired on notifications/tools/list_changed (server-side promotion).
|
|
143
162
|
onToolsListChanged: (() => void) | null = null;
|
|
@@ -150,8 +169,11 @@ export class MCPStdioClient {
|
|
|
150
169
|
constructor(options: MCPStdioClientOptions) {
|
|
151
170
|
this.bin = options.bin;
|
|
152
171
|
this.toolsPreset = options.toolsPreset;
|
|
172
|
+
this.cwd = options.cwd;
|
|
153
173
|
this.deps = options.deps;
|
|
154
174
|
this.env = options.env ?? process.env;
|
|
175
|
+
this.callTimeoutMs = options.callTimeoutMs ?? CALL_TIMEOUT_MS;
|
|
176
|
+
this.maxFrameBytes = options.maxFrameBytes ?? MAX_BUFFER_BYTES;
|
|
155
177
|
}
|
|
156
178
|
|
|
157
179
|
private childEnv(): Record<string, string | undefined> {
|
|
@@ -161,11 +183,13 @@ export class MCPStdioClient {
|
|
|
161
183
|
private spawnChild(): void {
|
|
162
184
|
const child = this.deps.spawn(this.bin, ["mcp"], {
|
|
163
185
|
stdio: ["pipe", "pipe", "pipe"],
|
|
186
|
+
cwd: this.cwd,
|
|
164
187
|
env: this.childEnv(),
|
|
188
|
+
windowsHide: true,
|
|
165
189
|
});
|
|
166
190
|
this.child = child;
|
|
167
|
-
this.
|
|
168
|
-
this.
|
|
191
|
+
this.chunks = [];
|
|
192
|
+
this.bufferedBytes = 0;
|
|
169
193
|
this.exited = false;
|
|
170
194
|
child.stdout?.on("data", (chunk: unknown) => this.onData(chunk as Buffer));
|
|
171
195
|
// Consume stderr so the child never blocks on a full pipe; routing it
|
|
@@ -185,68 +209,93 @@ export class MCPStdioClient {
|
|
|
185
209
|
if (this.exited && this.pending.size === 0) return;
|
|
186
210
|
this.exited = true;
|
|
187
211
|
this.child = null;
|
|
188
|
-
|
|
189
|
-
if (p.timer) clearTimeout(p.timer);
|
|
190
|
-
p.reject(err);
|
|
191
|
-
}
|
|
212
|
+
const pending = [...this.pending.values()];
|
|
192
213
|
this.pending.clear();
|
|
214
|
+
for (const p of pending) p.reject(err);
|
|
193
215
|
}
|
|
194
216
|
|
|
195
217
|
private onData(chunk: Buffer): void {
|
|
196
|
-
|
|
218
|
+
let start = 0;
|
|
197
219
|
let nl: number;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
this.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
let msg: unknown;
|
|
207
|
-
try {
|
|
208
|
-
msg = JSON.parse(line);
|
|
209
|
-
} catch {
|
|
210
|
-
continue; // non-JSON noise, ignore
|
|
211
|
-
}
|
|
212
|
-
this.dispatch(msg);
|
|
220
|
+
while ((nl = chunk.indexOf(0x0a, start)) >= 0) {
|
|
221
|
+
if (this.bufferedBytes + nl - start > this.maxFrameBytes) return this.overflow();
|
|
222
|
+
const tail = chunk.subarray(start, nl);
|
|
223
|
+
const frame = this.chunks.length > 0 ? Buffer.concat([...this.chunks, tail]) : tail;
|
|
224
|
+
this.chunks = [];
|
|
225
|
+
this.bufferedBytes = 0;
|
|
226
|
+
start = nl + 1;
|
|
227
|
+
this.onFrame(frame.toString("utf8").trim());
|
|
213
228
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
229
|
+
if (start >= chunk.length) return;
|
|
230
|
+
this.chunks.push(start === 0 ? chunk : chunk.subarray(start));
|
|
231
|
+
this.bufferedBytes += chunk.length - start;
|
|
232
|
+
if (this.bufferedBytes > this.maxFrameBytes) this.overflow();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// One frame past the cap is fatal for this child (no way to resync
|
|
236
|
+
// mid-frame): pending requests reject, the next call respawns a fresh child.
|
|
237
|
+
private overflow(): void {
|
|
238
|
+
this.chunks = [];
|
|
239
|
+
this.bufferedBytes = 0;
|
|
240
|
+
const child = this.child;
|
|
241
|
+
this.markExited(new Error(`gortex mcp frame exceeded ${this.maxFrameBytes} bytes`));
|
|
242
|
+
try {
|
|
243
|
+
child?.kill();
|
|
244
|
+
} catch {
|
|
245
|
+
// already gone
|
|
228
246
|
}
|
|
229
247
|
}
|
|
230
248
|
|
|
231
|
-
private
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
249
|
+
private onFrame(line: string): void {
|
|
250
|
+
if (!line) return;
|
|
251
|
+
let msg: unknown;
|
|
252
|
+
try {
|
|
253
|
+
msg = JSON.parse(line);
|
|
254
|
+
} catch {
|
|
255
|
+
return; // non-JSON noise, ignore
|
|
256
|
+
}
|
|
257
|
+
if (msg && typeof msg === "object") this.dispatch(msg as Record<string, unknown>);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private dispatch(frame: Record<string, unknown>): void {
|
|
261
|
+
const { id, method } = frame;
|
|
262
|
+
if (typeof method === "string") {
|
|
263
|
+
if (typeof id === "number" || typeof id === "string") this.answerServerRequest(id, method);
|
|
264
|
+
else if (method === "notifications/tools/list_changed") {
|
|
265
|
+
try {
|
|
266
|
+
this.onToolsListChanged?.();
|
|
267
|
+
} catch {
|
|
268
|
+
// best effort; never break the read loop.
|
|
269
|
+
}
|
|
241
270
|
}
|
|
242
271
|
return;
|
|
243
272
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
273
|
+
const error = frame.error as Record<string, unknown> | undefined;
|
|
274
|
+
if (id === null && error) {
|
|
275
|
+
// The server could not tell which request this answers, so none of
|
|
276
|
+
// them will get a reply.
|
|
277
|
+
const pending = [...this.pending.values()];
|
|
278
|
+
this.pending.clear();
|
|
279
|
+
for (const p of pending) p.reject(new JsonRpcError(error));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (typeof id !== "number") return;
|
|
283
|
+
const p = this.pending.get(id);
|
|
284
|
+
if (!p) return;
|
|
285
|
+
this.pending.delete(id);
|
|
286
|
+
if (error) p.reject(new JsonRpcError(error));
|
|
287
|
+
else p.resolve(frame.result);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private answerServerRequest(id: number | string, method: string): void {
|
|
291
|
+
const reply =
|
|
292
|
+
method === "ping"
|
|
293
|
+
? { jsonrpc: "2.0", id, result: {} }
|
|
294
|
+
: { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } };
|
|
295
|
+
try {
|
|
296
|
+
this.send(reply);
|
|
297
|
+
} catch {
|
|
298
|
+
// the child is gone; nothing is waiting on the answer.
|
|
250
299
|
}
|
|
251
300
|
}
|
|
252
301
|
|
|
@@ -270,24 +319,54 @@ export class MCPStdioClient {
|
|
|
270
319
|
return this.respawnPromise;
|
|
271
320
|
}
|
|
272
321
|
|
|
273
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Sends one request. On abort or timeout the call is dropped and the server
|
|
324
|
+
* is sent notifications/cancelled, so a late reply is ignored.
|
|
325
|
+
*/
|
|
326
|
+
async request(
|
|
327
|
+
method: string,
|
|
328
|
+
params: unknown,
|
|
329
|
+
timeoutMs: number = RPC_TIMEOUT_MS,
|
|
330
|
+
signal?: AbortSignal,
|
|
331
|
+
): Promise<unknown> {
|
|
332
|
+
const aborted = () => new Error(`${method} aborted`);
|
|
333
|
+
if (signal?.aborted) throw aborted();
|
|
274
334
|
if (this.exited) await this.respawn();
|
|
335
|
+
if (signal?.aborted) throw aborted();
|
|
275
336
|
const id = this.nextId++;
|
|
276
337
|
return new Promise((resolve, reject) => {
|
|
277
|
-
const
|
|
338
|
+
const onAbort = () => abandon(aborted());
|
|
339
|
+
const settled = () => {
|
|
340
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
341
|
+
signal?.removeEventListener("abort", onAbort);
|
|
342
|
+
};
|
|
343
|
+
const entry: PendingRequest = {
|
|
344
|
+
resolve: (value) => {
|
|
345
|
+
settled();
|
|
346
|
+
resolve(value);
|
|
347
|
+
},
|
|
348
|
+
reject: (err) => {
|
|
349
|
+
settled();
|
|
350
|
+
reject(err);
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
const abandon = (err: Error) => {
|
|
354
|
+
if (this.pending.get(id) !== entry) return;
|
|
355
|
+
this.pending.delete(id);
|
|
356
|
+
entry.reject(err);
|
|
357
|
+
// The MCP spec forbids cancelling initialize.
|
|
358
|
+
if (method !== "initialize") this.notify("notifications/cancelled", { requestId: id, reason: err.message });
|
|
359
|
+
};
|
|
278
360
|
if (Number.isFinite(timeoutMs)) {
|
|
279
|
-
entry.timer = setTimeout(() => {
|
|
280
|
-
this.pending.delete(id);
|
|
281
|
-
reject(new Error(`${method} timed out after ${timeoutMs}ms`));
|
|
282
|
-
}, timeoutMs);
|
|
361
|
+
entry.timer = setTimeout(() => abandon(new Error(`${method} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
283
362
|
}
|
|
363
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
284
364
|
this.pending.set(id, entry);
|
|
285
365
|
try {
|
|
286
366
|
this.send({ jsonrpc: "2.0", id, method, params });
|
|
287
367
|
} catch (err) {
|
|
288
368
|
this.pending.delete(id);
|
|
289
|
-
|
|
290
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
369
|
+
entry.reject(err instanceof Error ? err : new Error(String(err)));
|
|
291
370
|
}
|
|
292
371
|
});
|
|
293
372
|
}
|
|
@@ -336,8 +415,8 @@ export class MCPStdioClient {
|
|
|
336
415
|
return Array.isArray(res?.tools) ? (res.tools as ToolDescriptor[]) : [];
|
|
337
416
|
}
|
|
338
417
|
|
|
339
|
-
async callTool(name: string, args: Record<string, unknown
|
|
340
|
-
return this.request("tools/call", { name, arguments: args ?? {} },
|
|
418
|
+
async callTool(name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
|
|
419
|
+
return this.request("tools/call", { name, arguments: args ?? {} }, this.callTimeoutMs, signal);
|
|
341
420
|
}
|
|
342
421
|
|
|
343
422
|
stop(): void {
|
package/src/native.ts
CHANGED
|
@@ -18,7 +18,7 @@ const SERVER_DESCRIPTION =
|
|
|
18
18
|
"Gortex code graph of the indexed repositories: symbol search, source reads, usages, " +
|
|
19
19
|
"call chains, change impact and graph-aware edits.";
|
|
20
20
|
|
|
21
|
-
// Pi 0.
|
|
21
|
+
// Read structurally, since a Pi before 0.99 has none of these members.
|
|
22
22
|
type NativeMcpApi = Pick<ExtensionAPI, "registerMcpServer" | "unregisterMcpServer" | "getAllTools" | "getCommands">;
|
|
23
23
|
|
|
24
24
|
export function supportsNativeMcp(pi: ExtensionAPI): boolean {
|
package/src/runtime.ts
CHANGED
|
@@ -32,7 +32,9 @@ export interface BridgeChild {
|
|
|
32
32
|
export interface SpawnOpts {
|
|
33
33
|
stdio?: "ignore" | ("pipe" | "ignore")[];
|
|
34
34
|
detached?: boolean;
|
|
35
|
+
cwd?: string;
|
|
35
36
|
env?: Record<string, string | undefined>;
|
|
37
|
+
windowsHide?: boolean;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
export interface ExecFileSyncOpts {
|
|
@@ -40,6 +42,7 @@ export interface ExecFileSyncOpts {
|
|
|
40
42
|
encoding?: "utf8";
|
|
41
43
|
maxBuffer?: number;
|
|
42
44
|
timeout?: number;
|
|
45
|
+
windowsHide?: boolean;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
export interface ProcessDeps {
|
package/src/tools.ts
CHANGED
|
@@ -95,11 +95,11 @@ export function registerOneTool(pi: ExtensionAPI, desc: ToolDescriptor): void {
|
|
|
95
95
|
label: name,
|
|
96
96
|
description: piAliasedDescription(name, piAliasName(name), (desc.description || name).trim()),
|
|
97
97
|
parameters,
|
|
98
|
-
async execute(_id, params,
|
|
98
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
99
99
|
const client = getClient();
|
|
100
100
|
if (!client) throw new Error(`gortex ${name}: MCP bridge is not connected`);
|
|
101
101
|
const args = params ?? {};
|
|
102
|
-
const callTool = () => client.callTool(name, args);
|
|
102
|
+
const callTool = () => client.callTool(name, args, signal);
|
|
103
103
|
let result: unknown;
|
|
104
104
|
let diffs: FileDiff[] = [];
|
|
105
105
|
try {
|