pi-gortex 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +102 -0
- package/docs/architecture.md +126 -0
- package/docs/tests.md +108 -0
- package/index.ts +3 -0
- package/package.json +63 -0
- package/src/config.ts +158 -0
- package/src/hook.ts +97 -0
- package/src/index.ts +313 -0
- package/src/mcp-client.ts +338 -0
- package/src/runtime.ts +58 -0
- package/src/state.ts +56 -0
- package/src/tools.ts +180 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Configuration resolution: environment, then a project sidecar, then a global
|
|
2
|
+
// one, then defaults; first hit wins. The README documents the keys.
|
|
3
|
+
//
|
|
4
|
+
// A missing or malformed file is never fatal and falls through to the next
|
|
5
|
+
// layer, because losing the graph tools over a stray comma would be a worse
|
|
6
|
+
// failure than running with defaults.
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
/** The sidecar file's on-disk shape. `gortex init` is its writer. */
|
|
13
|
+
export interface SidecarConfig {
|
|
14
|
+
/** Absolute path to the gortex binary. Default: resolved from PATH. */
|
|
15
|
+
bin?: string;
|
|
16
|
+
/** Hook posture: deny | enrich | consult-unlock | nudge. */
|
|
17
|
+
hook_mode?: string;
|
|
18
|
+
/** false mirrors `gortex init --no-hooks`: orientation only, no enforcement. */
|
|
19
|
+
enforce?: boolean;
|
|
20
|
+
/** Eager MCP tool preset: core | full | edit | nav | readonly. */
|
|
21
|
+
tools_preset?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Everything the extension needs to run, fully resolved. */
|
|
25
|
+
export interface GortexConfig {
|
|
26
|
+
/** Binary used for `daemon start`, `mcp` and `hook`. */
|
|
27
|
+
bin: string;
|
|
28
|
+
/** Full argv for the hook bridge, e.g. [bin, "hook", "--agent=pi", "--mode=enrich"]. */
|
|
29
|
+
hookArgv: string[];
|
|
30
|
+
/** Whether to wire read-discipline enforcement onto tool_call. */
|
|
31
|
+
enforce: boolean;
|
|
32
|
+
/** Eager tool preset; the daemon's default applies when this is core/full. */
|
|
33
|
+
toolsPreset: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Seams for tests; every one defaults to the real environment. */
|
|
37
|
+
export interface ResolveConfigOptions {
|
|
38
|
+
env?: Record<string, string | undefined>;
|
|
39
|
+
cwd?: string;
|
|
40
|
+
/** Pi's agent directory. Default: $PI_CODING_AGENT_DIR, else ~/.pi/agent. */
|
|
41
|
+
agentDir?: string;
|
|
42
|
+
readFile?: (path: string) => string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const DEFAULT_BIN = "gortex";
|
|
46
|
+
export const DEFAULT_TOOLS_PRESET = "core";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pi's agent directory, resolved the way Pi itself resolves it
|
|
50
|
+
* (config.ts::getAgentDir in @earendil-works/pi-coding-agent).
|
|
51
|
+
*
|
|
52
|
+
* Re-derived here because the extension keeps zero runtime dependencies, so it
|
|
53
|
+
* also loads as a bare directory extension with no node_modules beside it.
|
|
54
|
+
* Pi's types are imported as types only, and erased.
|
|
55
|
+
*/
|
|
56
|
+
export function defaultAgentDir(env: Record<string, string | undefined> = process.env): string {
|
|
57
|
+
const override = env.PI_CODING_AGENT_DIR;
|
|
58
|
+
if (override && override.trim() !== "") {
|
|
59
|
+
const path = override.trim();
|
|
60
|
+
if (path === "~") return homedir();
|
|
61
|
+
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
|
62
|
+
return path;
|
|
63
|
+
}
|
|
64
|
+
return join(homedir(), ".pi", "agent");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Sidecar paths, nearest first. A project file wins over the global one. */
|
|
68
|
+
export function sidecarPaths(cwd: string, agentDir: string): string[] {
|
|
69
|
+
return [join(cwd, ".pi", "gortex.json"), join(agentDir, "extensions", "gortex.json")];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readSidecar(path: string, readFile: (p: string) => string): SidecarConfig {
|
|
73
|
+
let raw: string;
|
|
74
|
+
try {
|
|
75
|
+
raw = readFile(path);
|
|
76
|
+
} catch {
|
|
77
|
+
return {}; // absent is the common case
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const parsed: unknown = JSON.parse(raw);
|
|
81
|
+
return parsed !== null && typeof parsed === "object" ? (parsed as SidecarConfig) : {};
|
|
82
|
+
} catch {
|
|
83
|
+
// Malformed JSON falls through to the next layer. Nothing here can log
|
|
84
|
+
// without corrupting Pi's TUI.
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Hook postures the Go side accepts. Anything unrecognised (including empty)
|
|
91
|
+
* collapses to "deny", which is also the hook's own default.
|
|
92
|
+
*/
|
|
93
|
+
export function normalizeMode(mode: string | undefined): string {
|
|
94
|
+
switch ((mode ?? "").trim().toLowerCase()) {
|
|
95
|
+
case "enrich":
|
|
96
|
+
return "enrich";
|
|
97
|
+
case "consult-unlock":
|
|
98
|
+
return "consult-unlock";
|
|
99
|
+
case "nudge":
|
|
100
|
+
case "adaptive-nudge":
|
|
101
|
+
return "nudge";
|
|
102
|
+
default:
|
|
103
|
+
return "deny";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Accepts the spellings a shell user reaches for; anything else is undefined. */
|
|
108
|
+
export function parseBoolean(value: string | undefined): boolean | undefined {
|
|
109
|
+
switch ((value ?? "").trim().toLowerCase()) {
|
|
110
|
+
case "1":
|
|
111
|
+
case "true":
|
|
112
|
+
case "yes":
|
|
113
|
+
case "on":
|
|
114
|
+
return true;
|
|
115
|
+
case "0":
|
|
116
|
+
case "false":
|
|
117
|
+
case "no":
|
|
118
|
+
case "off":
|
|
119
|
+
return false;
|
|
120
|
+
default:
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function firstString(...values: (string | undefined)[]): string | undefined {
|
|
126
|
+
for (const value of values) {
|
|
127
|
+
if (typeof value === "string" && value.trim() !== "") return value.trim();
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function resolveConfig(opts: ResolveConfigOptions = {}): GortexConfig {
|
|
133
|
+
const env = opts.env ?? process.env;
|
|
134
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
135
|
+
const agentDir = opts.agentDir ?? defaultAgentDir(env);
|
|
136
|
+
const readFile = opts.readFile ?? ((path: string) => readFileSync(path, "utf8"));
|
|
137
|
+
|
|
138
|
+
const [project, global] = sidecarPaths(cwd, agentDir).map((path) => readSidecar(path, readFile));
|
|
139
|
+
|
|
140
|
+
// `gortex` unqualified is resolved by spawn against PATH.
|
|
141
|
+
const bin = firstString(env.GORTEX_BIN, project?.bin, global?.bin) ?? DEFAULT_BIN;
|
|
142
|
+
|
|
143
|
+
const mode = normalizeMode(firstString(env.GORTEX_HOOK_MODE, project?.hook_mode, global?.hook_mode));
|
|
144
|
+
const hookArgv = [bin, "hook", "--agent=pi"];
|
|
145
|
+
// "deny" is the hook's own default, so it rides as an absent flag.
|
|
146
|
+
if (mode !== "deny") hookArgv.push(`--mode=${mode}`);
|
|
147
|
+
|
|
148
|
+
const enforce =
|
|
149
|
+
parseBoolean(env.GORTEX_ENFORCE) ??
|
|
150
|
+
(typeof project?.enforce === "boolean" ? project.enforce : undefined) ??
|
|
151
|
+
(typeof global?.enforce === "boolean" ? global.enforce : undefined) ??
|
|
152
|
+
true;
|
|
153
|
+
|
|
154
|
+
const toolsPreset =
|
|
155
|
+
firstString(env.GORTEX_TOOLS, project?.tools_preset, global?.tools_preset) ?? DEFAULT_TOOLS_PRESET;
|
|
156
|
+
|
|
157
|
+
return { bin, hookArgv, enforce, toolsPreset };
|
|
158
|
+
}
|
package/src/hook.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The hook bridge: `gortex hook --agent=pi`. Pi has no hook configuration, so
|
|
2
|
+
// each event is shelled out with an envelope on stdin and the decision read
|
|
3
|
+
// back is applied.
|
|
4
|
+
|
|
5
|
+
import type { ExecFileSyncOpts, ProcessDeps } from "./runtime.ts";
|
|
6
|
+
|
|
7
|
+
/** The decision shape Go writes back (hooks.BridgeDecision). */
|
|
8
|
+
export interface PiDecision {
|
|
9
|
+
block?: boolean;
|
|
10
|
+
reason?: string;
|
|
11
|
+
additional_context?: string;
|
|
12
|
+
orientation?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The envelope the extension writes to the hook's stdin (hooks.BridgeEvent). */
|
|
16
|
+
export interface PiEnvelope {
|
|
17
|
+
event: string;
|
|
18
|
+
tool_name?: string;
|
|
19
|
+
tool_input?: Record<string, unknown>;
|
|
20
|
+
cwd?: string;
|
|
21
|
+
session_id?: string;
|
|
22
|
+
is_gortex_tool?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const HOOK_EXEC_OPTS: ExecFileSyncOpts = {
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
28
|
+
timeout: 5_000,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* callHook sends a normalized event envelope to `gortex hook --agent=pi` and
|
|
33
|
+
* parses the PiDecision it writes back. Fail-open: any error returns an empty
|
|
34
|
+
* decision so the extension never blocks Pi's flow on a hook hiccup (daemon
|
|
35
|
+
* down, parse error, timeout).
|
|
36
|
+
*/
|
|
37
|
+
export function callHook(argv: string[], deps: ProcessDeps, envelope: PiEnvelope): PiDecision {
|
|
38
|
+
const [bin, ...args] = argv;
|
|
39
|
+
if (!bin) return {};
|
|
40
|
+
try {
|
|
41
|
+
const out = deps
|
|
42
|
+
.execFileSync(bin, args, { ...HOOK_EXEC_OPTS, input: JSON.stringify(envelope) })
|
|
43
|
+
.trim();
|
|
44
|
+
if (!out) return {};
|
|
45
|
+
return JSON.parse(out) as PiDecision;
|
|
46
|
+
} catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Tool-call normalization (Pi vocabulary -> canonical Claude-Code vocabulary)
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
//
|
|
55
|
+
// The Go classifier switches on Claude-Code tool names and canonical input
|
|
56
|
+
// keys ("file_path"/"pattern"/"command"), so the translation lives here.
|
|
57
|
+
//
|
|
58
|
+
// NOTE: Pi is pre-1.0. The built-in tool names mapped here are pinned to Pi's
|
|
59
|
+
// documented API (packages/coding-agent/docs/extensions.md); if an upgrade
|
|
60
|
+
// renames one, adjust this small map; the Go wire contract stays unchanged.
|
|
61
|
+
export const toolNameMap: Record<string, string> = {
|
|
62
|
+
read: "Read",
|
|
63
|
+
grep: "Grep",
|
|
64
|
+
find: "Glob",
|
|
65
|
+
ls: "Glob",
|
|
66
|
+
bash: "Bash",
|
|
67
|
+
edit: "Edit",
|
|
68
|
+
write: "Write",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function firstString(obj: Record<string, unknown>, keys: string[]): string | undefined {
|
|
72
|
+
for (const k of keys) {
|
|
73
|
+
const v = obj[k];
|
|
74
|
+
if (typeof v === "string" && v !== "") return v;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function normalizeToolCall(
|
|
80
|
+
piName: string,
|
|
81
|
+
piInput: Record<string, unknown>,
|
|
82
|
+
): { tool_name: string; tool_input: Record<string, unknown> } {
|
|
83
|
+
const canonical = toolNameMap[piName.toLowerCase()] ?? piName;
|
|
84
|
+
const out: Record<string, unknown> = { ...piInput };
|
|
85
|
+
|
|
86
|
+
const path = firstString(piInput, ["file_path", "path", "file", "filepath", "absolute_path"]);
|
|
87
|
+
if (path !== undefined) out.file_path = path;
|
|
88
|
+
|
|
89
|
+
let pattern = firstString(piInput, ["pattern", "glob", "query", "regex", "name"]);
|
|
90
|
+
if (pattern === undefined && canonical === "Glob") pattern = path;
|
|
91
|
+
if (pattern !== undefined) out.pattern = pattern;
|
|
92
|
+
|
|
93
|
+
const command = firstString(piInput, ["command", "cmd", "script"]);
|
|
94
|
+
if (command !== undefined) out.command = command;
|
|
95
|
+
|
|
96
|
+
return { tool_name: canonical, tool_input: out };
|
|
97
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// The extension factory: the readiness barrier and the Pi lifecycle wiring for
|
|
2
|
+
// both channels, graph tools over the MCP bridge and read discipline over the
|
|
3
|
+
// hook bridge. See docs/architecture.md for the design.
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
ContextEvent,
|
|
7
|
+
ExtensionAPI,
|
|
8
|
+
ExtensionContext,
|
|
9
|
+
ToolCallEvent,
|
|
10
|
+
ToolCallEventResult,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
import { resolveConfig } from "./config.ts";
|
|
14
|
+
import type { GortexConfig } from "./config.ts";
|
|
15
|
+
import { callHook, normalizeToolCall } from "./hook.ts";
|
|
16
|
+
import {
|
|
17
|
+
MCPStdioClient,
|
|
18
|
+
MIN_GORTEX_VERSION,
|
|
19
|
+
PROTOCOL_VERSION,
|
|
20
|
+
ensureDaemon,
|
|
21
|
+
isBelowVersion,
|
|
22
|
+
} from "./mcp-client.ts";
|
|
23
|
+
import { nodeProcessDeps } from "./runtime.ts";
|
|
24
|
+
import type { ProcessDeps } from "./runtime.ts";
|
|
25
|
+
import {
|
|
26
|
+
clearSyncTimer,
|
|
27
|
+
registerGortexTools,
|
|
28
|
+
scheduleSyncTools,
|
|
29
|
+
} from "./tools.ts";
|
|
30
|
+
import {
|
|
31
|
+
getBridgeError,
|
|
32
|
+
getClient,
|
|
33
|
+
getVersionWarning,
|
|
34
|
+
gortexToolNames,
|
|
35
|
+
setBridgeError,
|
|
36
|
+
setClient,
|
|
37
|
+
setVersionWarning,
|
|
38
|
+
} from "./state.ts";
|
|
39
|
+
|
|
40
|
+
export type { GortexConfig, SidecarConfig } from "./config.ts";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How long before_agent_start waits for session_start to finish. Pi arms the
|
|
44
|
+
* editor's submit handler before session_start runs, at startup and across
|
|
45
|
+
* /reload and /new, so a fast prompt can reach the agent loop while
|
|
46
|
+
* registration is still pending. Capped so a wedged daemon costs the first turn
|
|
47
|
+
* seconds; start() alone can take ~2 min.
|
|
48
|
+
*/
|
|
49
|
+
export const READY_WAIT_MS = 15_000;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compares the daemon against what this extension is built for and, on a skew,
|
|
53
|
+
* tells both audiences: the user through Pi's warning channel (which never
|
|
54
|
+
* enters model context), the model through the orientation, so it knows a tool
|
|
55
|
+
* may misbehave. Advisory only: the session keeps every tool it registered,
|
|
56
|
+
* matching the fail-open posture in docs/architecture.md.
|
|
57
|
+
*/
|
|
58
|
+
function reportVersionSkew(
|
|
59
|
+
ctx: ExtensionContext | undefined,
|
|
60
|
+
serverVersion: string,
|
|
61
|
+
negotiatedProtocol: string,
|
|
62
|
+
): void {
|
|
63
|
+
const stale = serverVersion !== "" && isBelowVersion(serverVersion, MIN_GORTEX_VERSION);
|
|
64
|
+
const protocolSkew = negotiatedProtocol !== "" && negotiatedProtocol !== PROTOCOL_VERSION;
|
|
65
|
+
if (!stale && !protocolSkew) return;
|
|
66
|
+
|
|
67
|
+
const installed = serverVersion === "" ? "an unreported version" : `gortex ${serverVersion}`;
|
|
68
|
+
const user = stale
|
|
69
|
+
? `${installed} is older than ${MIN_GORTEX_VERSION}, which this extension needs. ` +
|
|
70
|
+
`Graph tools and read discipline may be missing or wrong. Run \`gortex upgrade\`.`
|
|
71
|
+
: `${installed} answered the handshake with MCP protocol ${negotiatedProtocol}, ` +
|
|
72
|
+
`not the ${PROTOCOL_VERSION} this extension speaks. Tool calls may fail. ` +
|
|
73
|
+
`Run \`gortex upgrade\`.`;
|
|
74
|
+
|
|
75
|
+
setVersionWarning(
|
|
76
|
+
stale
|
|
77
|
+
? `[Gortex] the daemon is ${installed}, older than the ${MIN_GORTEX_VERSION} this ` +
|
|
78
|
+
`extension targets. A graph tool may be missing or behave unexpectedly. If one ` +
|
|
79
|
+
`does, tell the user to run \`gortex upgrade\`.`
|
|
80
|
+
: `[Gortex] the daemon negotiated MCP protocol ${negotiatedProtocol} instead of ` +
|
|
81
|
+
`${PROTOCOL_VERSION}. A graph tool call may fail. If one does, tell the user to ` +
|
|
82
|
+
`run \`gortex upgrade\`.`,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// A Pi build without a UI (print/JSON mode) or without notify must not take
|
|
86
|
+
// the session down over an advisory message.
|
|
87
|
+
try {
|
|
88
|
+
if (ctx?.hasUI) ctx.ui.notify(user, "warning");
|
|
89
|
+
} catch {
|
|
90
|
+
// the model still gets the orientation line above
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Test seams. Pi itself calls the factory with the `pi` object alone. */
|
|
95
|
+
export interface GortexExtensionOptions {
|
|
96
|
+
/** Pre-resolved configuration; resolveConfig() runs when this is absent. */
|
|
97
|
+
config?: GortexConfig;
|
|
98
|
+
/** Process seam for spawn/execFileSync. */
|
|
99
|
+
deps?: ProcessDeps;
|
|
100
|
+
/** Readiness cap override. */
|
|
101
|
+
readyWaitMs?: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export default function gortexExtension(pi: ExtensionAPI, options: GortexExtensionOptions = {}): void {
|
|
105
|
+
const config = options.config ?? resolveConfig();
|
|
106
|
+
// Pi 0.85's ExtensionAPI declares no `cwd`, so the hook envelope reads it
|
|
107
|
+
// defensively and falls back to the process cwd. A Pi version that grows the
|
|
108
|
+
// field is picked up when it exists.
|
|
109
|
+
const piCwd = (): string => (pi as { cwd?: string })?.cwd ?? process.cwd();
|
|
110
|
+
const deps = options.deps ?? nodeProcessDeps;
|
|
111
|
+
const readyWaitMs = options.readyWaitMs ?? READY_WAIT_MS;
|
|
112
|
+
|
|
113
|
+
// Latched for the whole session once the first turn has run the
|
|
114
|
+
// session_start hook, whether or not it produced anything to inject.
|
|
115
|
+
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 = "";
|
|
120
|
+
|
|
121
|
+
// Settles when the current session_start handler is done: bridge up and
|
|
122
|
+
// tools registered, or the handshake failed. Never rejects.
|
|
123
|
+
//
|
|
124
|
+
// Armed at factory time, because every session builds a fresh instance before
|
|
125
|
+
// its session_start fires, so an instance can be asked for a turn before its
|
|
126
|
+
// own handler has run. Every path that constructs an instance goes on to emit
|
|
127
|
+
// session_start, so the wait ends.
|
|
128
|
+
let sessionReady!: Promise<void>;
|
|
129
|
+
let settleSessionReady: () => void = () => {};
|
|
130
|
+
let sessionReadyPending = false;
|
|
131
|
+
|
|
132
|
+
function armSessionReady(): void {
|
|
133
|
+
if (sessionReadyPending) return; // keep the promise parked waiters hold
|
|
134
|
+
sessionReadyPending = true;
|
|
135
|
+
sessionReady = new Promise<void>((resolve) => {
|
|
136
|
+
settleSessionReady = () => {
|
|
137
|
+
sessionReadyPending = false;
|
|
138
|
+
resolve();
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
armSessionReady();
|
|
143
|
+
|
|
144
|
+
// Resolves true when session_start finished, false when the cap expired
|
|
145
|
+
// first. The caller has to tell those apart, since a cap expiry leaves
|
|
146
|
+
// bridgeError empty.
|
|
147
|
+
function waitForSession(ms: number): Promise<boolean> {
|
|
148
|
+
const ready = sessionReady;
|
|
149
|
+
return new Promise<boolean>((resolve) => {
|
|
150
|
+
let settled = false;
|
|
151
|
+
const done = (ok: boolean) => {
|
|
152
|
+
if (settled) return;
|
|
153
|
+
settled = true;
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
resolve(ok);
|
|
156
|
+
};
|
|
157
|
+
const timer = setTimeout(() => done(false), ms);
|
|
158
|
+
(timer as { unref?: () => void })?.unref?.();
|
|
159
|
+
ready.then(() => done(true), () => done(true));
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Pi resets the session's tool registry on every session_start, so
|
|
164
|
+
// (re)register here. Clear the name guard first, since it persists across
|
|
165
|
+
// sessions and would otherwise suppress re-registration. The previous
|
|
166
|
+
// session's bridge child (if any) is stopped before a fresh handshake.
|
|
167
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
168
|
+
startupHookRan = false;
|
|
169
|
+
pendingOrientation = "";
|
|
170
|
+
setBridgeError("");
|
|
171
|
+
setVersionWarning("");
|
|
172
|
+
armSessionReady(); // no-op when a turn is already parked on this session
|
|
173
|
+
const settle = settleSessionReady; // this invocation's resolver
|
|
174
|
+
try {
|
|
175
|
+
await startSession(ctx);
|
|
176
|
+
} finally {
|
|
177
|
+
settle();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
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();
|
|
186
|
+
clearSyncTimer();
|
|
187
|
+
const previous = getClient();
|
|
188
|
+
if (previous) {
|
|
189
|
+
previous.stop();
|
|
190
|
+
setClient(null);
|
|
191
|
+
}
|
|
192
|
+
const c = new MCPStdioClient({ bin: config.bin, toolsPreset: config.toolsPreset, deps });
|
|
193
|
+
// Claim the slot before the first await: an overlapping
|
|
194
|
+
// session_start (rapid /new or /reload) then sees and stops THIS
|
|
195
|
+
// bridge instead of leaking its child mid-handshake.
|
|
196
|
+
setClient(c);
|
|
197
|
+
try {
|
|
198
|
+
await c.start();
|
|
199
|
+
if (getClient() !== c) return; // superseded while handshaking
|
|
200
|
+
reportVersionSkew(ctx, c.serverVersion, c.negotiatedProtocol);
|
|
201
|
+
c.onToolsListChanged = () => {
|
|
202
|
+
scheduleSyncTools(pi);
|
|
203
|
+
};
|
|
204
|
+
await registerGortexTools(pi);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
// daemon unreachable / binary missing / handshake or tools/list
|
|
207
|
+
// failure: no graph tools this session; /reload (or the next
|
|
208
|
+
// session_start) retries.
|
|
209
|
+
c.stop();
|
|
210
|
+
if (getClient() === c) {
|
|
211
|
+
setClient(null);
|
|
212
|
+
setBridgeError(err instanceof Error ? err.message : String(err));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
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;
|
|
221
|
+
startupHookRan = true; // before the first await, so a second turn can't race in
|
|
222
|
+
// Pi awaits each listener, so this holds the turn until the tools are
|
|
223
|
+
// registered and bridgeError reflects the handshake (or the cap expires).
|
|
224
|
+
const ready = await waitForSession(readyWaitMs);
|
|
225
|
+
const decision = callHook(config.hookArgv, deps, {
|
|
226
|
+
event: "session_start",
|
|
227
|
+
cwd: piCwd(),
|
|
228
|
+
});
|
|
229
|
+
const parts: string[] = [];
|
|
230
|
+
const bridgeError = getBridgeError();
|
|
231
|
+
if (bridgeError) {
|
|
232
|
+
parts.push(
|
|
233
|
+
`[Gortex] graph tools are unavailable this session (${bridgeError}). ` +
|
|
234
|
+
`Tell the user to run /reload to retry the connection.`,
|
|
235
|
+
);
|
|
236
|
+
setBridgeError("");
|
|
237
|
+
} else if (!ready) {
|
|
238
|
+
// Cap expired mid-handshake: bridgeError is still empty, and this is the
|
|
239
|
+
// only turn that reports it: startupHookRan latches above, so a
|
|
240
|
+
// handshake that fails after the cap never reaches the branch above.
|
|
241
|
+
parts.push(
|
|
242
|
+
`[Gortex] graph tools were still registering when this turn began, so a tool ` +
|
|
243
|
+
`named below may not be callable yet. If one is missing, say so and tell the ` +
|
|
244
|
+
`user to retry, or to run /reload if it stays missing. Don't fall back to ` +
|
|
245
|
+
`native tools.`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const versionWarning = getVersionWarning();
|
|
249
|
+
if (versionWarning) {
|
|
250
|
+
parts.push(versionWarning);
|
|
251
|
+
setVersionWarning("");
|
|
252
|
+
}
|
|
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.
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (!config.enforce) return;
|
|
280
|
+
|
|
281
|
+
// 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> => {
|
|
283
|
+
const piName: string = (event as { toolName?: string })?.toolName ?? "";
|
|
284
|
+
const piInput: Record<string, unknown> = (event?.input as Record<string, unknown>) ?? {};
|
|
285
|
+
const isGortexTool = gortexToolNames.has(piName);
|
|
286
|
+
|
|
287
|
+
const norm = normalizeToolCall(piName, piInput);
|
|
288
|
+
const decision = callHook(config.hookArgv, deps, {
|
|
289
|
+
event: "tool_call",
|
|
290
|
+
tool_name: norm.tool_name,
|
|
291
|
+
tool_input: norm.tool_input,
|
|
292
|
+
cwd: ctx?.cwd ?? piCwd(),
|
|
293
|
+
session_id: (ctx as { sessionManager?: { sessionId?: string } })?.sessionManager?.sessionId ?? "",
|
|
294
|
+
is_gortex_tool: isGortexTool,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (decision.block) {
|
|
298
|
+
return { block: true, reason: decision.reason ?? "[Gortex] blocked: prefer graph tools." };
|
|
299
|
+
}
|
|
300
|
+
if (decision.additional_context) {
|
|
301
|
+
// Soft guidance: surface it without blocking the call.
|
|
302
|
+
try {
|
|
303
|
+
pi.sendMessage(
|
|
304
|
+
{ customType: "gortex", content: decision.additional_context, display: true },
|
|
305
|
+
{ deliverAs: "steer" },
|
|
306
|
+
);
|
|
307
|
+
} catch {
|
|
308
|
+
// sendMessage shape can vary across Pi versions; never fatal.
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
});
|
|
313
|
+
}
|