@geohar/pi-mcp-combiner 0.0.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 ADDED
@@ -0,0 +1,484 @@
1
+ // Pi extension: run the `mcp-combiner` MCP aggregator via the `sharedserver` CLI so it
2
+ // is available to Pi through `pi-mcp-adapter`.
3
+ //
4
+ // Pi has no MCP of its own; `pi-mcp-adapter` (a separate Pi package) is what actually
5
+ // talks MCP, reading its own `mcp.json`. This extension is the OTHER half — the exact
6
+ // counterpart of the process side of the Claude Code and OpenCode plugins:
7
+ //
8
+ // 1. Process — drive `sharedserver use … -- <combiner> --mcp --config … --port …` so
9
+ // the combiner is running and refcounted, SHARED with any other client (Claude
10
+ // Code, OpenCode, Neovim) that uses the same sharedserver name. Launched in
11
+ // `session_start` (Pi forbids background startup from the factory) and released in
12
+ // `session_shutdown` — but only on `reason === "quit"`, since reload/new/resume/
13
+ // fork keep the same Pi process alive and a fresh `session_start` re-attaches.
14
+ // 2. Instructions — append the combiner's tool-discovery directive to the system
15
+ // prompt via `before_agent_start` (analogue of the CC plugin's SessionStart
16
+ // additionalContext and the OpenCode plugin's system.transform hook). The combiner
17
+ // also serves the same text as its MCP `instructions`, which pi-mcp-adapter
18
+ // surfaces on connect — this is the guaranteed, client-native belt to that braces.
19
+ //
20
+ // Registration itself (pointing pi-mcp-adapter at the combiner) is a single `mcp.json`
21
+ // entry — see mcp.json.example and the README. That is static, CC-style, by design;
22
+ // this extension deliberately does not write another extension's config.
23
+ //
24
+ // The sharedserver resolution/fetch and the combiner command ladder are ported
25
+ // faithfully from plugins/opencode/src/index.ts — same floor, same warnings, same
26
+ // degrade-rather-than-die behaviour, so a user running several clients gets one answer
27
+ // to "which combiner / which sharedserver am I on, and why".
28
+
29
+ import { spawnSync } from "node:child_process"
30
+ import { existsSync, mkdirSync, readFileSync } from "node:fs"
31
+ import { homedir } from "node:os"
32
+ import { dirname, join } from "node:path"
33
+ import { fileURLToPath } from "node:url"
34
+ import type {
35
+ AutocompleteItem,
36
+ ExtensionAPI,
37
+ ExtensionCommandContext,
38
+ ExtensionContext,
39
+ SessionShutdownEvent,
40
+ } from "./pi.js"
41
+ import { resolveSharedserver } from "./sharedserver-resolve.js"
42
+
43
+ const DEFAULT_PORT = 9741
44
+ const DEFAULT_NAME = "mcp-combiner"
45
+ const DEFAULT_GRACE = "30m"
46
+ // The `--mcp` serve flag arrived in combiner 0.8.0; older versions serve with a bare
47
+ // `--config`. That boundary is also the floor for trusting a PATH install — below it we
48
+ // fetch a known-good release via uvx rather than limp along. NOT the lockstep release
49
+ // version; it moves only when this extension depends on newer combiner behaviour.
50
+ const MIN_COMBINER_VERSION: [number, number, number] = [0, 8, 0]
51
+ // Floor-only against sharedserver's latest release: this repo consumes sharedserver
52
+ // rather than shipping it. Kept equal to the OpenCode plugin's value (they resolve the
53
+ // same binary the same way).
54
+ const SHAREDSERVER_MIN_VERSION = "0.6.7"
55
+
56
+ type LogFn = (level: "info" | "warn" | "error", message: string) => void
57
+
58
+ // ── the tool-discovery directive ───────────────────────────────────
59
+ // Appended to the system prompt so the agent knows combined tools arrive under a
60
+ // `<server>_` prefix (and, through pi-mcp-adapter, are reached via its `mcp()` proxy or
61
+ // promoted `directTools`), and looks before deciding a capability is absent. Canonical
62
+ // source: CLAUDE.md.example at the repo root; a release-time `prepack` copies it to this
63
+ // package's root as instructions.txt (see package.json). A dev/unbuilt run without the
64
+ // copy falls back to empty and simply injects nothing.
65
+ const COMBINER_DIRECTIVE: string = (() => {
66
+ try {
67
+ const here = dirname(fileURLToPath(import.meta.url))
68
+ // dist/index.js and the packed instructions.txt both sit one level up from here
69
+ // when built (dist/), and the source layout mirrors it (src/ → package root).
70
+ return readFileSync(join(here, "..", "instructions.txt"), "utf8")
71
+ } catch {
72
+ return ""
73
+ }
74
+ })()
75
+ // First line of the directive — used to detect an already-appended prompt so repeated
76
+ // `before_agent_start` turns do not stack duplicate copies.
77
+ const DIRECTIVE_MARKER = COMBINER_DIRECTIVE.split("\n", 1)[0] ?? ""
78
+
79
+ // ── env configuration ──────────────────────────────────────────────
80
+ // The PI_MCP_COMBINER_* namespace mirrors the Claude plugin's CLAUDE_MCP_COMBINER_* and
81
+ // the OpenCode plugin's OPENCODE_MCP_COMBINER_*, so a user running several clients keeps
82
+ // one namespace per client rather than options in one place and env vars in another.
83
+
84
+ function env(name: string): string | undefined {
85
+ const v = process.env[name]
86
+ return v !== undefined && v !== "" ? v : undefined
87
+ }
88
+
89
+ function splitArgs(value: string | undefined): string[] {
90
+ if (!value) return []
91
+ return value.split(/\s+/).filter((s) => s.length > 0)
92
+ }
93
+
94
+ // ── combiner command resolution (ported from the OpenCode plugin) ──
95
+
96
+ type Command = {
97
+ cmd: string
98
+ /** Structural args identifying WHAT to run. Probed with; never omitted. */
99
+ args: string[]
100
+ /** The user's own extra args. Appended at spawn time only — never probed with. */
101
+ extra?: string[]
102
+ version?: [number, number, number]
103
+ }
104
+
105
+ function parseVersion(text: string): [number, number, number] | undefined {
106
+ const m = text.match(/(\d+)\.(\d+)\.(\d+)/)
107
+ if (!m) return undefined
108
+ return [Number(m[1]), Number(m[2]), Number(m[3])]
109
+ }
110
+
111
+ function gte(a: [number, number, number], b: [number, number, number]): boolean {
112
+ for (let i = 0; i < 3; i++) {
113
+ if (a[i] !== b[i]) return a[i] > b[i]
114
+ }
115
+ return true
116
+ }
117
+
118
+ /** Probe a command once for BOTH facts: is it runnable, and which version. Presence is
119
+ * "spawn did not fail", NOT "exited 0" — a pre-0.8.0 combiner exits non-zero on
120
+ * `--version` (the flag did not exist), which is exactly the stale install the floor
121
+ * exists to catch. Probes with ONLY the structural args; the user's extras are never
122
+ * folded in, so one arg the CLI rejects cannot make a healthy install look absent. */
123
+ function probe(cmd: Command): { present: boolean; version?: [number, number, number] } {
124
+ const r = spawnSync(cmd.cmd, [...cmd.args, "--version"], { env: process.env })
125
+ if (r.error) return { present: false }
126
+ if (r.status !== 0) return { present: true }
127
+ return { present: true, version: parseVersion(`${r.stdout?.toString() ?? ""}${r.stderr?.toString() ?? ""}`) }
128
+ }
129
+
130
+ function probeUsable(cmd: Command): Command | undefined {
131
+ const { version } = probe(cmd)
132
+ if (!version || !gte(version, MIN_COMBINER_VERSION)) return undefined
133
+ return { ...cmd, version }
134
+ }
135
+
136
+ /** This extension's own version, kept equal to the published PyPI mcp-combiner by
137
+ * lockstep releases (scripts/bump-version.sh). Used as the uvx pin — read from the
138
+ * manifest, never duplicated as a constant. */
139
+ const PLUGIN_VERSION: string | undefined = (() => {
140
+ try {
141
+ const here = dirname(fileURLToPath(import.meta.url))
142
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")) as { version?: string }
143
+ return pkg.version
144
+ } catch {
145
+ return undefined
146
+ }
147
+ })()
148
+
149
+ /** Resolve how to invoke the combiner, mirroring the sibling plugins' priority:
150
+ * explicit env command → `mcp-combiner` on PATH (only when new enough) →
151
+ * `uv run --project <checkout> python -m mcp_combiner` → a pinned release via uvx. The
152
+ * uvx tail is what makes a bare install work with nothing installed by hand. */
153
+ function resolveCombiner(log: LogFn): Command | undefined {
154
+ const extra = splitArgs(env("PI_MCP_COMBINER_ARGS"))
155
+
156
+ const command = env("PI_MCP_COMBINER_COMMAND")
157
+ if (command) {
158
+ return { cmd: command, args: [], extra }
159
+ }
160
+
161
+ const onPath: Command = { cmd: "mcp-combiner", args: [] }
162
+ const pathProbe = probe(onPath)
163
+ if (pathProbe.present) {
164
+ if (pathProbe.version && gte(pathProbe.version, MIN_COMBINER_VERSION)) {
165
+ return { ...onPath, extra, version: pathProbe.version }
166
+ }
167
+ log(
168
+ "warn",
169
+ `mcp-combiner on PATH reports ${pathProbe.version?.join(".") ?? "a pre-0.8.0 version"}, older than ` +
170
+ `${MIN_COMBINER_VERSION.join(".")} — ignoring it and fetching a pinned release instead. ` +
171
+ "Upgrade with: uv tool install --upgrade mcp-combiner",
172
+ )
173
+ }
174
+
175
+ const checkout = env("PI_MCP_COMBINER_CHECKOUT")
176
+ if (checkout && existsSync(checkout) && probe({ cmd: "uv", args: [] }).present) {
177
+ return { cmd: "uv", args: ["run", "--project", checkout, "python", "-m", "mcp_combiner"], extra }
178
+ }
179
+
180
+ // Out-of-the-box path: no install required, fetch from PyPI. Pinned to this
181
+ // extension's version so the pair moves in lockstep; if that exact release is
182
+ // missing, fall back to latest rather than failing. The probe both validates the pin
183
+ // and warms uv's cache, so the real spawn is a cache hit.
184
+ if (probe({ cmd: "uvx", args: [] }).present) {
185
+ if (PLUGIN_VERSION) {
186
+ const pinned = probeUsable({ cmd: "uvx", args: [`mcp-combiner@${PLUGIN_VERSION}`] })
187
+ if (pinned) return { ...pinned, extra }
188
+ log("warn", `pinned mcp-combiner@${PLUGIN_VERSION} unavailable or too old; trying latest from PyPI`)
189
+ }
190
+ const latest = probeUsable({ cmd: "uvx", args: ["mcp-combiner"] })
191
+ if (latest) return { ...latest, extra }
192
+ }
193
+
194
+ return undefined
195
+ }
196
+
197
+ /** True if the resolved combiner supports (needs) the `--mcp` serve flag. Unknown
198
+ * version → assume NO: a version we cannot read is a pre-0.8.0 build serving on a bare
199
+ * `--config`, and a bare `--config` still serves on newer releases (with a deprecation
200
+ * warning), so guessing low degrades gracefully whereas guessing high does not. */
201
+ function combinerNeedsMcpFlag(cmd: Command): boolean {
202
+ const ver = cmd.version ?? probe({ cmd: cmd.cmd, args: cmd.args }).version
203
+ return ver ? gte(ver, MIN_COMBINER_VERSION) : false
204
+ }
205
+
206
+ // ── servers.json resolution (mirrors the sibling plugins) ──
207
+
208
+ function resolveConfig(log: LogFn): string | undefined {
209
+ const explicit = env("PI_MCP_COMBINER_CONFIG")
210
+ if (explicit) return explicit
211
+ const user = process.env.USER ?? process.env.LOGNAME ?? ""
212
+ const candidates = [
213
+ join(homedir(), ".cache", "secrets", `${user}.mcpservers.json`),
214
+ join(homedir(), ".config", "mcp-combiner", "servers.json"),
215
+ join(homedir(), ".config", "mcp", "servers.json"),
216
+ ]
217
+ const found = candidates.find(existsSync)
218
+ if (!found) {
219
+ log(
220
+ "error",
221
+ "no combiner servers.json found; set $PI_MCP_COMBINER_CONFIG (probed " +
222
+ "~/.cache/secrets/<user>.mcpservers.json, ~/.config/mcp-combiner/servers.json, " +
223
+ "~/.config/mcp/servers.json)",
224
+ )
225
+ }
226
+ return found
227
+ }
228
+
229
+ // ── sharedserver lifecycle ─────────────────────────────────────────
230
+
231
+ /** Attach state for exactly one live refcount. Guards `detach()` so a
232
+ * `session_shutdown("quit")` and a process-exit handler cannot double-`unuse` (which
233
+ * would over-decrement sharedserver's refcount). */
234
+ type Attachment = { binary: string; name: string }
235
+ let attachment: Attachment | null = null
236
+ let cleanupInstalled = false
237
+
238
+ function installProcessCleanup() {
239
+ if (cleanupInstalled) return
240
+ cleanupInstalled = true
241
+ // Belt to the session_shutdown("quit") braces: if Pi is killed hard enough that
242
+ // session_shutdown never fires, still release the refcount. Idempotent via `detach`.
243
+ process.on("exit", () => detach())
244
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as NodeJS.Signals[]) {
245
+ process.on(sig, () => {
246
+ detach()
247
+ process.kill(process.pid, sig)
248
+ })
249
+ }
250
+ }
251
+
252
+ function detach() {
253
+ if (!attachment) return
254
+ const { binary, name } = attachment
255
+ attachment = null
256
+ spawnSync(binary, ["unuse", name, "--pid", String(process.pid)], { stdio: "ignore", env: process.env })
257
+ }
258
+
259
+ // ── the extension ──────────────────────────────────────────────────
260
+
261
+ export default function mcpCombiner(pi: ExtensionAPI): void {
262
+ const notify = env("PI_MCP_COMBINER_NOTIFY") !== "false"
263
+ const wantInstructions = env("PI_MCP_COMBINER_INSTRUCTIONS") !== "false"
264
+ const manage = env("PI_MCP_COMBINER_MANAGE") !== "false"
265
+
266
+ // Host-owned mode: a set $MCP_COMPANION_COMBINER_URL means an editor/host (e.g.
267
+ // Neovim) already owns and refcounts the combiner. Then we NEVER launch — only the
268
+ // instructions half runs. The host never sets a port var, mirroring the sibling
269
+ // plugins' distinction.
270
+ const hostOwned = env("MCP_COMPANION_COMBINER_URL") !== undefined && env("PI_MCP_COMBINER_PORT") === undefined
271
+
272
+ // ── instructions: appended every turn (analogue of the sibling plugins) ──
273
+ pi.on("before_agent_start", (event) => {
274
+ if (!wantInstructions || !COMBINER_DIRECTIVE) return
275
+ // Do not stack duplicates across turns: the chained prompt carries forward.
276
+ if (DIRECTIVE_MARKER && event.systemPrompt.includes(DIRECTIVE_MARKER)) return
277
+ return { systemPrompt: `${event.systemPrompt}\n\n${COMBINER_DIRECTIVE}` }
278
+ })
279
+
280
+ // ── /mcp-combiner command: inspect the extension (verb: system-prompt) ──
281
+ pi.registerCommand("mcp-combiner", {
282
+ description: "mcp-combiner extension — verb: system-prompt (show the injected directive)",
283
+ getArgumentCompletions: (prefix) => completeVerbs(prefix),
284
+ handler: (args, ctx) => {
285
+ const verb = args.trim()
286
+ if (verb === "" || verb === "system-prompt") {
287
+ showDirective(ctx, "mcp-combiner", COMBINER_DIRECTIVE, wantInstructions)
288
+ return
289
+ }
290
+ ctx.ui?.notify?.(`mcp-combiner: unknown verb "${verb}". Try: system-prompt`, "warn")
291
+ },
292
+ })
293
+
294
+ if (hostOwned || !manage) {
295
+ // Registration is static (mcp.json) and the combiner is someone else's to run:
296
+ // nothing to launch. The instructions handler above still applies.
297
+ return
298
+ }
299
+
300
+ // ── process: launch on session_start, release on session_shutdown("quit") ──
301
+ // Pi forbids starting background resources from the factory, so all of this is
302
+ // deferred to session_start. session_start fires again on reload/new/resume/fork
303
+ // within the same process; the `attachment` guard makes re-entry a no-op attach.
304
+ pi.on("session_start", (_event, ctx) => {
305
+ if (attachment) return // already attached in this process
306
+
307
+ const log = makeLog(ctx, notify)
308
+ const binary = resolveSharedserver(
309
+ {
310
+ label: "mcp-combiner",
311
+ minVersion: SHAREDSERVER_MIN_VERSION,
312
+ installerUrl:
313
+ "https://github.com/georgeharker/sharedserver/releases/latest/download/sharedserver-installer.sh",
314
+ },
315
+ env("SHAREDSERVER_BIN"),
316
+ process.env,
317
+ log,
318
+ )
319
+ if (!binary) {
320
+ log("error", "sharedserver binary not found; set $SHAREDSERVER_BIN, or PI_MCP_COMBINER_MANAGE=false")
321
+ return
322
+ }
323
+
324
+ const combiner = resolveCombiner(log)
325
+ if (!combiner) {
326
+ log(
327
+ "error",
328
+ "mcp-combiner could not be found or fetched; install uv and it is fetched from PyPI on demand, " +
329
+ "or install mcp-combiner, or set $PI_MCP_COMBINER_COMMAND / $PI_MCP_COMBINER_CHECKOUT",
330
+ )
331
+ return
332
+ }
333
+
334
+ const cfgPath = resolveConfig(log)
335
+ if (!cfgPath) return // resolveConfig already logged the specifics
336
+
337
+ const name = env("PI_MCP_COMBINER_NAME") ?? DEFAULT_NAME
338
+ const port = resolvePort(log)
339
+ const grace = env("PI_MCP_COMBINER_GRACE") ?? DEFAULT_GRACE
340
+
341
+ // Assemble: <combiner> [--mcp] --config <cfg> --port <port> [--host <host>]
342
+ const serve: string[] = []
343
+ const modern = combinerNeedsMcpFlag(combiner)
344
+ if (modern) serve.push("--mcp")
345
+ serve.push("--config", cfgPath, "--port", String(port))
346
+ const host = env("PI_MCP_COMBINER_HOST")
347
+ if (host) serve.push("--host", host)
348
+
349
+ // Logging parity with the sibling plugins' two-file scheme: sharedserver captures
350
+ // raw stdout/stderr (--log-file on `use`), and the combiner writes its own
351
+ // --log-file. Both default under $XDG_STATE_HOME/mcp-combiner; "none" disables.
352
+ // Combiner-side flags ride the same version gate as --mcp.
353
+ const logDir = join(process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "mcp-combiner")
354
+ const pyLogFile = env("PI_MCP_COMBINER_PYLOG") ?? join(logDir, "mcp-combiner-py.log")
355
+ if (modern) {
356
+ if (pyLogFile !== "none") serve.push("--log-file", pyLogFile)
357
+ serve.push("--log-level", env("PI_MCP_COMBINER_LOG_LEVEL") ?? "info")
358
+ }
359
+
360
+ const wrappedArgs = [...combiner.args, ...(combiner.extra ?? []), ...serve]
361
+ const useArgs = [
362
+ "use",
363
+ name,
364
+ "--pid",
365
+ String(process.pid),
366
+ "--grace-period",
367
+ grace,
368
+ "--metadata",
369
+ `pi-${process.pid}`,
370
+ ]
371
+ const logFile = env("PI_MCP_COMBINER_LOG") ?? join(logDir, "mcp-combiner.log")
372
+ if (logFile !== "none") {
373
+ try {
374
+ mkdirSync(dirname(logFile), { recursive: true })
375
+ } catch {
376
+ // best-effort — a failed mkdir just means sharedserver may drop the capture
377
+ }
378
+ useArgs.push("--log-file", logFile)
379
+ }
380
+ useArgs.push("--", combiner.cmd, ...wrappedArgs)
381
+
382
+ installProcessCleanup()
383
+ const result = spawnSync(binary, useArgs, { stdio: "pipe", env: process.env })
384
+ if (result.error) {
385
+ log("error", `${name}: failed to spawn sharedserver (${result.error.message})`)
386
+ return
387
+ }
388
+ if (result.status !== 0) {
389
+ const stderr = result.stderr?.toString().trim()
390
+ log("error", `${name}: sharedserver use exited ${result.status}${stderr ? ` (${stderr})` : ""}`)
391
+ return
392
+ }
393
+
394
+ attachment = { binary, name }
395
+ log("info", `combiner "${name}" attached on port ${port} (${combiner.cmd} ${wrappedArgs.join(" ")})`)
396
+ })
397
+
398
+ pi.on("session_shutdown", (event: SessionShutdownEvent) => {
399
+ // Only "quit" means the Pi process is actually leaving. reload/new/resume/fork
400
+ // keep the process alive and a fresh session_start re-attaches — releasing the
401
+ // refcount on those would needlessly drop (and re-take) the shared combiner.
402
+ if (event.reason === "quit") detach()
403
+ })
404
+ }
405
+
406
+ // ── helpers ────────────────────────────────────────────────────────
407
+
408
+ // The verbs the extension's slash command understands. `system-prompt` shows the
409
+ // directive this extension injects — the show-command pattern from pi-custom-system-prompt,
410
+ // since `before_agent_start` injections are per-turn and never appear in Pi's own
411
+ // `/system-prompt` (which reports the base prompt only).
412
+ const COMMAND_VERBS = ["system-prompt"]
413
+ function completeVerbs(prefix: string): AutocompleteItem[] | null {
414
+ const p = prefix.trim()
415
+ const matches = COMMAND_VERBS.filter((v) => v.startsWith(p))
416
+ return matches.length ? matches.map((v) => ({ value: v, label: v })) : null
417
+ }
418
+
419
+ const SHOW_LIMIT = 1600
420
+ function showDirective(ctx: ExtensionCommandContext, label: string, directive: string, enabled: boolean): void {
421
+ if (!directive) {
422
+ ctx.ui?.notify?.(`${label}: no directive bundled (instructions.txt missing)`, "warn")
423
+ return
424
+ }
425
+ const head = enabled
426
+ ? `${label} directive — injected into the system prompt on every turn (before_agent_start):`
427
+ : `${label} directive — injection is DISABLED this session; it would be:`
428
+ const body =
429
+ directive.length > SHOW_LIMIT
430
+ ? `${directive.slice(0, SHOW_LIMIT)}\n\n… (${directive.length} chars total)`
431
+ : directive
432
+ ctx.ui?.notify?.(`${head}\n\n${body}`, "info")
433
+ }
434
+
435
+ function makeLog(ctx: ExtensionContext, notify: boolean): LogFn {
436
+ return (level, message) => {
437
+ const line = `mcp-combiner: ${message}`
438
+ // Pi has no structured plugin log sink like OpenCode's client.app.log; surface
439
+ // through the UI when there is one (and the user has not opted out), else stderr.
440
+ if (notify && ctx.hasUI && ctx.ui?.notify) {
441
+ ctx.ui.notify(line, level === "error" ? "error" : level === "warn" ? "warn" : "info")
442
+ } else if (level === "error" || level === "warn") {
443
+ process.stderr.write(`${line}\n`)
444
+ }
445
+ }
446
+ }
447
+
448
+ /** The port to serve on, reconciling PI_MCP_COMBINER_PORT with any explicit
449
+ * $MCP_COMPANION_COMBINER_URL (which is what pi-mcp-adapter registers, so serving
450
+ * anywhere else would be unreachable — the URL wins, same rule as the sibling plugins). */
451
+ function resolvePort(log: LogFn): number {
452
+ const raw = env("PI_MCP_COMBINER_PORT")
453
+ let port = DEFAULT_PORT
454
+ if (raw !== undefined) {
455
+ const n = Number(raw)
456
+ if (Number.isInteger(n) && n > 0) port = n
457
+ else log("warn", `PI_MCP_COMBINER_PORT=${raw} is not a positive integer; using ${DEFAULT_PORT}`)
458
+ }
459
+ const url = env("MCP_COMPANION_COMBINER_URL")
460
+ if (url && raw !== undefined) {
461
+ let urlPort = Number.NaN
462
+ try {
463
+ urlPort = Number(new URL(url).port)
464
+ } catch {
465
+ // malformed URL — fall through to the no-usable-port warning below
466
+ }
467
+ if (!Number.isInteger(urlPort) || urlPort <= 0) {
468
+ log(
469
+ "warn",
470
+ `MCP_COMPANION_COMBINER_URL=${url} has no explicit port; serving on ${port}. ` +
471
+ `Use an explicit port in the URL (e.g. http://127.0.0.1:${port}/mcp).`,
472
+ )
473
+ } else if (urlPort !== port) {
474
+ log(
475
+ "warn",
476
+ `PI_MCP_COMBINER_PORT=${port} disagrees with MCP_COMPANION_COMBINER_URL=${url}; the URL is what ` +
477
+ `pi-mcp-adapter registers, so serving on ${port} would be unreachable — using ${urlPort}. ` +
478
+ "Set them to the same port.",
479
+ )
480
+ port = urlPort
481
+ }
482
+ }
483
+ return port
484
+ }
package/src/pi.ts ADDED
@@ -0,0 +1,70 @@
1
+ // Narrow, local typing for the slice of Pi's extension API this plugin uses.
2
+ //
3
+ // Pi (badlogic/pi-mono, earendil-works/pi) ships its ExtensionAPI types with the
4
+ // harness rather than as a standalone npm package we can depend on, so we declare
5
+ // exactly the surface we touch — three lifecycle events, `registerCommand`, `exec`,
6
+ // and `sendMessage`. Kept deliberately minimal: a wider mirror would rot against a
7
+ // moving upstream. Signatures follow the published extension docs
8
+ // (https://pi.dev/docs/latest/extensions).
9
+
10
+ export type SessionStartReason = "startup" | "reload" | "new" | "resume" | "fork"
11
+ export type SessionShutdownReason = "quit" | "reload" | "new" | "resume" | "fork"
12
+
13
+ export type SessionStartEvent = { reason: SessionStartReason; previousSessionFile?: string }
14
+ export type SessionShutdownEvent = { reason: SessionShutdownReason; targetSessionFile?: string }
15
+ export type BeforeAgentStartEvent = { systemPrompt: string }
16
+ export type BeforeAgentStartResult = { systemPrompt?: string } | void
17
+
18
+ export type ExtensionContext = {
19
+ cwd: string
20
+ mode: "tui" | "rpc" | "json" | "print"
21
+ hasUI: boolean
22
+ signal?: AbortSignal
23
+ ui?: { notify?: (message: string, level?: "info" | "warn" | "error") => void }
24
+ }
25
+
26
+ /** Context handed to a command handler. Superset of ExtensionContext in practice; we
27
+ * only read `signal`, `ui`, and `hasUI`. */
28
+ export type ExtensionCommandContext = ExtensionContext
29
+
30
+ export type AutocompleteItem = { value: string; label?: string }
31
+
32
+ export type ExecResult = { stdout: string; stderr: string; code: number; killed: boolean }
33
+ export type ExecOptions = { signal?: AbortSignal; timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv }
34
+
35
+ /** An LLM-visible message injected from a command. `display:true` shows it in the TUI;
36
+ * `{triggerTurn:true, deliverAs:"steer"}` makes the model act on it this turn. */
37
+ export type SendMessage = {
38
+ customType: string
39
+ content: string
40
+ display?: boolean
41
+ details?: Record<string, unknown>
42
+ }
43
+ export type SendMessageOptions = { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }
44
+
45
+ export type CommandSpec = {
46
+ description: string
47
+ handler: (args: string, ctx: ExtensionCommandContext) => void | Promise<void>
48
+ getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null
49
+ }
50
+
51
+ export interface ExtensionAPI {
52
+ on(
53
+ event: "session_start",
54
+ handler: (event: SessionStartEvent, ctx: ExtensionContext) => void | Promise<void>,
55
+ ): void
56
+ on(
57
+ event: "session_shutdown",
58
+ handler: (event: SessionShutdownEvent, ctx: ExtensionContext) => void | Promise<void>,
59
+ ): void
60
+ on(
61
+ event: "before_agent_start",
62
+ handler: (
63
+ event: BeforeAgentStartEvent,
64
+ ctx: ExtensionContext,
65
+ ) => BeforeAgentStartResult | Promise<BeforeAgentStartResult>,
66
+ ): void
67
+ registerCommand(name: string, spec: CommandSpec): void
68
+ exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>
69
+ sendMessage(message: SendMessage, options?: SendMessageOptions): void
70
+ }