@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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +419 -0
- package/dist/index.js.map +1 -0
- package/dist/pi.d.ts +70 -0
- package/dist/pi.d.ts.map +1 -0
- package/dist/pi.js +10 -0
- package/dist/pi.js.map +1 -0
- package/dist/sharedserver-resolve.d.ts +19 -0
- package/dist/sharedserver-resolve.d.ts.map +1 -0
- package/dist/sharedserver-resolve.js +188 -0
- package/dist/sharedserver-resolve.js.map +1 -0
- package/instructions.txt +27 -0
- package/mcp.json.example +7 -0
- package/package.json +58 -0
- package/src/index.ts +484 -0
- package/src/pi.ts +70 -0
- package/src/sharedserver-resolve.ts +221 -0
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
|
+
}
|