@geohar/pi-cribsheet 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,319 @@
1
+ // Pi extension: run the `crib` (cribsheet) memory + code-index MCP server via the
2
+ // `sharedserver` CLI, inject the reach-for-crib directive into the system prompt, and
3
+ // ship the `/crib` recall command.
4
+ //
5
+ // It is the Pi counterpart of cribsheet's Claude Code and OpenCode plugins, and mirrors
6
+ // their behaviour:
7
+ //
8
+ // 1. Stand-down switch — if a combiner already serves crib (global MCP_COMBINER, or
9
+ // the per-backend MCP_COMBINER_SERVES_CRIBSHEET override, which wins), do NOT
10
+ // launch a standalone backend. The combiner owns crib's lifecycle. Only the launch
11
+ // is gated — the directive and /crib command apply either way, since crib's tools
12
+ // are present via the combiner too.
13
+ // 2. Process — on `session_start`, drive `sharedserver use … -- crib --mcp --http …`
14
+ // so one warm crib is running and refcounted (shared across clients). Released on
15
+ // `session_shutdown` when `reason === "quit"` (reload/resume/fork keep the process
16
+ // and re-attach).
17
+ // 3. Directive — append the reach-for-crib text to the system prompt via
18
+ // `before_agent_start` (analogue of Claude Code's SessionStart additionalContext
19
+ // and OpenCode's system.transform).
20
+ // 4. /crib command — a Pi command mirroring commands/crib.md: shells out to the crib
21
+ // CLI (`note apropos` + `code lookup`) and steers the model to summarise.
22
+ //
23
+ // MCP registration itself (pointing pi-mcp-adapter at crib) is a single mcp.json entry
24
+ // — see mcp.json.example and the README; static, and unnecessary when combiner-served.
25
+ // The sharedserver resolution is ported faithfully from plugins/opencode/src/index.ts.
26
+
27
+ import { spawnSync } from "node:child_process"
28
+ import { existsSync, readFileSync } from "node:fs"
29
+ import { dirname, join } from "node:path"
30
+ import { fileURLToPath } from "node:url"
31
+ import type {
32
+ AutocompleteItem,
33
+ ExtensionAPI,
34
+ ExtensionCommandContext,
35
+ ExtensionContext,
36
+ SessionShutdownEvent,
37
+ } from "./pi.js"
38
+ import { resolveSharedserver } from "./sharedserver-resolve.js"
39
+
40
+ const DEFAULT_PORT = 7732
41
+ const DEFAULT_HOST = "127.0.0.1"
42
+ const DEFAULT_NAME = "cribsheet"
43
+ const DEFAULT_GRACE = "1h"
44
+ // Floor-only against sharedserver's latest release: cribsheet consumes sharedserver
45
+ // rather than shipping it. Kept equal to the sibling plugins' value.
46
+ const SHAREDSERVER_MIN_VERSION = "0.6.7"
47
+
48
+ type LogFn = (level: "info" | "warn" | "error", message: string) => void
49
+
50
+ // ── the reach-for-crib directive ───────────────────────────────────
51
+ // Appended to the system prompt so the agent reaches for crib's tools. Canonical
52
+ // source: CLAUDE.md.example at the repo root; a release-time `prepack` copies it to this
53
+ // package's root as instructions.txt (see package.json). A dev/unbuilt run without the
54
+ // copy falls back to empty and simply injects nothing.
55
+ const CRIB_DIRECTIVE: string = (() => {
56
+ try {
57
+ const here = dirname(fileURLToPath(import.meta.url))
58
+ return readFileSync(join(here, "..", "instructions.txt"), "utf8")
59
+ } catch {
60
+ return ""
61
+ }
62
+ })()
63
+ const DIRECTIVE_MARKER = CRIB_DIRECTIVE.split("\n", 1)[0] ?? ""
64
+
65
+ // ── env configuration ──────────────────────────────────────────────
66
+ // The PI_CRIBSHEET_* namespace mirrors the OpenCode plugin's OPENCODE_CRIBSHEET_* and
67
+ // the CC hook's CRIBSHEET_* set — one namespace per client.
68
+
69
+ function env(name: string): string | undefined {
70
+ const v = process.env[name]
71
+ return v !== undefined && v !== "" ? v : undefined
72
+ }
73
+
74
+ function splitArgs(value: string | undefined): string[] {
75
+ if (!value) return []
76
+ return value.split(/\s+/).filter((s) => s.length > 0)
77
+ }
78
+
79
+ // ── stand-down switch (mirrors the CC hook's combiner_serves) ──────
80
+
81
+ function truthy(v: string | undefined): boolean {
82
+ if (v == null) return false
83
+ return !["", "0", "false", "no", "off"].includes(v.trim().toLowerCase())
84
+ }
85
+
86
+ /** Does a combiner serve `name`? The per-backend `MCP_COMBINER_SERVES_<NAME>` override
87
+ * wins over the global `MCP_COMBINER` switch (presence, even empty, counts). These are
88
+ * the cross-tool switches shared with the CC/OpenCode plugins — NOT PI_-namespaced. */
89
+ function combinerServes(name: string): boolean {
90
+ const key = "MCP_COMBINER_SERVES_" + name.toUpperCase().replace(/[-\s]/g, "_")
91
+ if (key in process.env) return truthy(process.env[key])
92
+ return truthy(process.env.MCP_COMBINER)
93
+ }
94
+
95
+ // ── crib command resolution (mirrors the OpenCode plugin) ──────────
96
+
97
+ type Command = { cmd: string; args: string[] }
98
+
99
+ function onPath(cmd: string): boolean {
100
+ return spawnSync(cmd, ["--version"], { stdio: "ignore", env: process.env }).status === 0
101
+ }
102
+
103
+ /** Resolve how to invoke crib: env command → `crib` on PATH → `uv run --project
104
+ * <checkout> crib`. Returns the base command; callers append their own args. */
105
+ function resolveCrib(): Command | undefined {
106
+ const command = env("PI_CRIBSHEET_COMMAND")
107
+ if (command) return { cmd: command, args: splitArgs(env("PI_CRIBSHEET_ARGS")) }
108
+ if (onPath("crib")) return { cmd: "crib", args: [] }
109
+ const checkout = env("PI_CRIBSHEET_CHECKOUT")
110
+ if (checkout && existsSync(checkout) && onPath("uv")) {
111
+ return { cmd: "uv", args: ["run", "--project", checkout, "crib"] }
112
+ }
113
+ return undefined
114
+ }
115
+
116
+ // ── sharedserver lifecycle ─────────────────────────────────────────
117
+
118
+ type Attachment = { binary: string; name: string }
119
+ let attachment: Attachment | null = null
120
+ let cleanupInstalled = false
121
+
122
+ function installProcessCleanup() {
123
+ if (cleanupInstalled) return
124
+ cleanupInstalled = true
125
+ process.on("exit", () => detach())
126
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as NodeJS.Signals[]) {
127
+ process.on(sig, () => {
128
+ detach()
129
+ process.kill(process.pid, sig)
130
+ })
131
+ }
132
+ }
133
+
134
+ function detach() {
135
+ if (!attachment) return
136
+ const { binary, name } = attachment
137
+ attachment = null
138
+ spawnSync(binary, ["unuse", name, "--pid", String(process.pid)], { stdio: "ignore", env: process.env })
139
+ }
140
+
141
+ // ── the extension ──────────────────────────────────────────────────
142
+
143
+ export default function cribsheet(pi: ExtensionAPI): void {
144
+ const notify = env("PI_CRIBSHEET_NOTIFY") !== "false"
145
+ const wantInstructions = env("PI_CRIBSHEET_INSTRUCTIONS") !== "false"
146
+ const wantCommand = env("PI_CRIBSHEET_COMMAND_ENABLE") !== "false"
147
+ const manage = env("PI_CRIBSHEET_MANAGE") !== "false"
148
+ const name = env("PI_CRIBSHEET_NAME") ?? DEFAULT_NAME
149
+ const served = combinerServes(name)
150
+
151
+ // ── directive: appended every turn (dup-guarded across turns) ──
152
+ pi.on("before_agent_start", (event) => {
153
+ if (!wantInstructions || !CRIB_DIRECTIVE) return
154
+ if (DIRECTIVE_MARKER && event.systemPrompt.includes(DIRECTIVE_MARKER)) return
155
+ return { systemPrompt: `${event.systemPrompt}\n\n${CRIB_DIRECTIVE}` }
156
+ })
157
+
158
+ // ── /crib command: recall memory + code index, then steer the model ──
159
+ if (wantCommand) {
160
+ pi.registerCommand("crib", {
161
+ description: "Consult crib (recall memory + code index); verb: system-prompt shows the directive",
162
+ getArgumentCompletions: (prefix) => completeVerbs(prefix),
163
+ handler: async (args, ctx) => {
164
+ const arg = args.trim()
165
+ if (arg === "system-prompt") {
166
+ showDirective(ctx, "cribsheet", CRIB_DIRECTIVE, wantInstructions)
167
+ return
168
+ }
169
+ const topic = arg
170
+ if (!topic) {
171
+ ctx.ui?.notify?.("crib: give me a topic (e.g. /crib auth flow), or /crib system-prompt", "warn")
172
+ return
173
+ }
174
+ const crib = resolveCrib()
175
+ if (!crib) {
176
+ ctx.ui?.notify?.("crib: CLI not found; install cribsheet or set $PI_CRIBSHEET_COMMAND", "error")
177
+ return
178
+ }
179
+ const run = async (sub: string[]) => {
180
+ try {
181
+ const r = await pi.exec(crib.cmd, [...crib.args, ...sub, topic], {
182
+ signal: ctx.signal,
183
+ timeout: 30_000,
184
+ })
185
+ return r.stdout.trim() || r.stderr.trim()
186
+ } catch (e) {
187
+ return `(crib ${sub.join(" ")} failed: ${e instanceof Error ? e.message : String(e)})`
188
+ }
189
+ }
190
+ const [memory, code] = await Promise.all([run(["note", "apropos"]), run(["code", "lookup"])])
191
+ const content =
192
+ `Consult crib first for "${topic}" — recalled below. Summarise what crib already knows: ` +
193
+ "lead with the notes' answer, fold in any relevant code symbols, and cite note/symbol names " +
194
+ "so the user can `crib note read` / `crib code dossier` to go deeper. If nothing relevant came " +
195
+ "back, say so plainly rather than guessing.\n\n" +
196
+ `Memory — semantic recall (crib note apropos):\n${memory}\n\n` +
197
+ `Code index — symbols by concept or name (crib code lookup):\n${code}`
198
+ pi.sendMessage(
199
+ { customType: "crib-recall", content, display: true, details: { topic } },
200
+ { triggerTurn: true, deliverAs: "steer" },
201
+ )
202
+ },
203
+ })
204
+ }
205
+
206
+ // Combiner-served or manage=false: nothing to launch (crib runs elsewhere). The
207
+ // directive + command above still apply.
208
+ if (served || !manage) return
209
+
210
+ // ── process: launch on session_start, release on session_shutdown("quit") ──
211
+ pi.on("session_start", (_event, ctx) => {
212
+ if (attachment) return
213
+
214
+ const log = makeLog(ctx, notify)
215
+ const binary = resolveSharedserver(
216
+ {
217
+ label: "cribsheet",
218
+ minVersion: SHAREDSERVER_MIN_VERSION,
219
+ installerUrl:
220
+ "https://github.com/georgeharker/sharedserver/releases/latest/download/sharedserver-installer.sh",
221
+ },
222
+ env("SHAREDSERVER_BIN"),
223
+ process.env,
224
+ log,
225
+ )
226
+ if (!binary) {
227
+ log("error", "sharedserver binary not found; set $SHAREDSERVER_BIN, or PI_CRIBSHEET_MANAGE=false")
228
+ return
229
+ }
230
+
231
+ const crib = resolveCrib()
232
+ if (!crib) {
233
+ log(
234
+ "error",
235
+ "crib command not found; install cribsheet (so `crib` is on PATH), or set " +
236
+ "$PI_CRIBSHEET_COMMAND / $PI_CRIBSHEET_CHECKOUT",
237
+ )
238
+ return
239
+ }
240
+
241
+ const port = env("PI_CRIBSHEET_PORT") ?? String(DEFAULT_PORT)
242
+ const host = env("PI_CRIBSHEET_HOST") ?? DEFAULT_HOST
243
+ const grace = env("PI_CRIBSHEET_GRACE") ?? DEFAULT_GRACE
244
+
245
+ // Assemble: crib [extra] --mcp --http --host <host> --port <port>
246
+ const serve = ["--mcp", "--http", "--host", host, "--port", port]
247
+ const useArgs = [
248
+ "use",
249
+ name,
250
+ "--pid",
251
+ String(process.pid),
252
+ "--grace-period",
253
+ grace,
254
+ "--metadata",
255
+ `pi-${process.pid}`,
256
+ ]
257
+ const logFile = env("PI_CRIBSHEET_LOG")
258
+ if (logFile && logFile !== "none") useArgs.push("--log-file", logFile)
259
+ useArgs.push("--", crib.cmd, ...crib.args, ...serve)
260
+
261
+ installProcessCleanup()
262
+ const result = spawnSync(binary, useArgs, { stdio: "pipe", env: process.env })
263
+ if (result.error) {
264
+ log("error", `${name}: failed to spawn sharedserver (${result.error.message})`)
265
+ return
266
+ }
267
+ if (result.status !== 0) {
268
+ const stderr = result.stderr?.toString().trim()
269
+ log("error", `${name}: sharedserver use exited ${result.status}${stderr ? ` (${stderr})` : ""}`)
270
+ return
271
+ }
272
+
273
+ attachment = { binary, name }
274
+ log("info", `crib "${name}" attached on ${host}:${port} (${crib.cmd} ${crib.args.join(" ")})`)
275
+ })
276
+
277
+ pi.on("session_shutdown", (event: SessionShutdownEvent) => {
278
+ if (event.reason === "quit") detach()
279
+ })
280
+ }
281
+
282
+ // ── helpers ────────────────────────────────────────────────────────
283
+
284
+ // Slash-command verbs. `system-prompt` shows the directive this extension injects — the
285
+ // show-command pattern from pi-custom-system-prompt, since `before_agent_start`
286
+ // injections are per-turn and never appear in Pi's own `/system-prompt` (base only).
287
+ const COMMAND_VERBS = ["system-prompt"]
288
+ function completeVerbs(prefix: string): AutocompleteItem[] | null {
289
+ const p = prefix.trim()
290
+ const matches = COMMAND_VERBS.filter((v) => v.startsWith(p))
291
+ return matches.length ? matches.map((v) => ({ value: v, label: v })) : null
292
+ }
293
+
294
+ const SHOW_LIMIT = 1600
295
+ function showDirective(ctx: ExtensionCommandContext, label: string, directive: string, enabled: boolean): void {
296
+ if (!directive) {
297
+ ctx.ui?.notify?.(`${label}: no directive bundled (instructions.txt missing)`, "warn")
298
+ return
299
+ }
300
+ const head = enabled
301
+ ? `${label} directive — injected into the system prompt on every turn (before_agent_start):`
302
+ : `${label} directive — injection is DISABLED this session; it would be:`
303
+ const body =
304
+ directive.length > SHOW_LIMIT
305
+ ? `${directive.slice(0, SHOW_LIMIT)}\n\n… (${directive.length} chars total)`
306
+ : directive
307
+ ctx.ui?.notify?.(`${head}\n\n${body}`, "info")
308
+ }
309
+
310
+ function makeLog(ctx: ExtensionContext, notify: boolean): LogFn {
311
+ return (level, message) => {
312
+ const line = `cribsheet: ${message}`
313
+ if (notify && ctx.hasUI && ctx.ui?.notify) {
314
+ ctx.ui.notify(line, level === "error" ? "error" : level === "warn" ? "warn" : "info")
315
+ } else if (level === "error" || level === "warn") {
316
+ process.stderr.write(`${line}\n`)
317
+ }
318
+ }
319
+ }
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
+ }
@@ -0,0 +1,221 @@
1
+ // Resolve the `sharedserver` binary, fetching it when none is usable.
2
+ //
3
+ // VENDORED FILE. This is copied byte-identical into the consuming OpenCode plugins
4
+ // (cribsheet, svg-mcp) by their scripts/sync-vendored.sh, so the drift check is a plain
5
+ // diff. There is no way to share it as a dependency without coupling their versions to
6
+ // sharedserver's — see plugins/claude/bin/sharedserver for the same reasoning on the
7
+ // shell side. Edit it HERE; consumers re-sync.
8
+ //
9
+ // Everything repo-specific arrives through ResolveConfig rather than being hardcoded,
10
+ // mirroring bin/sharedserver.conf.
11
+
12
+ import { spawnSync } from "node:child_process"
13
+ import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs"
14
+ import { homedir } from "node:os"
15
+ import { join } from "node:path"
16
+
17
+ export type LogFn = (level: "info" | "warn" | "error", message: string) => void
18
+ export type ToastFn = (variant: "success" | "warning" | "error", message: string) => void
19
+
20
+ export type ResolveConfig = {
21
+ /** Prefix for user-facing messages. Default "sharedserver". */
22
+ label?: string
23
+ /** Minimum acceptable version. Defaults to pkgVersion (lockstep). */
24
+ minVersion?: string
25
+ /** Installer to fetch. Defaults to the release pinned to pkgVersion. */
26
+ installerUrl?: string
27
+ /** The host package's own version — only meaningful in lockstep mode. */
28
+ pkgVersion?: string
29
+ }
30
+
31
+ /** Oldest release these plugins are correct against: 0.5.0 added the PID-reuse guard,
32
+ * without which a recycled client PID can hold a server open indefinitely. */
33
+ const HARDCODED_FLOOR = "0.5.0"
34
+
35
+ const CANDIDATE_BINARIES = [
36
+ "sharedserver",
37
+ join(homedir(), ".cargo", "bin", "sharedserver"),
38
+ join(homedir(), ".local", "bin", "sharedserver"),
39
+ "/usr/local/bin/sharedserver",
40
+ "/opt/homebrew/bin/sharedserver",
41
+ ]
42
+
43
+ function parseVersion(text: string): [number, number, number] | undefined {
44
+ const m = text.match(/(\d+)\.(\d+)\.(\d+)/)
45
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined
46
+ }
47
+
48
+ function gte(a: [number, number, number], b: [number, number, number]): boolean {
49
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i]
50
+ return true
51
+ }
52
+
53
+ function versionOf(candidate: string, env: NodeJS.ProcessEnv): [number, number, number] | undefined {
54
+ const r = spawnSync(candidate, ["--version"], { env })
55
+ if (r.error) return undefined
56
+ return parseVersion(`${r.stdout?.toString() ?? ""}${r.stderr?.toString() ?? ""}`)
57
+ }
58
+
59
+ /** Synchronous sleep — this path runs before anything can be awaited, so the lock wait
60
+ * cannot use timers. Atomics.wait blocks without spinning. */
61
+ function sleepSync(ms: number): void {
62
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
63
+ }
64
+
65
+ /** First sharedserver found at ANY version, or undefined. */
66
+ function probeAny(override: string | undefined, env: NodeJS.ProcessEnv): string | undefined {
67
+ const candidates = [override, env.SHAREDSERVER_BIN, ...CANDIDATE_BINARIES].filter(
68
+ (v): v is string => typeof v === "string" && v.length > 0,
69
+ )
70
+ for (const candidate of candidates) {
71
+ if (candidate.includes("/")) {
72
+ if (existsSync(candidate)) return candidate
73
+ continue
74
+ }
75
+ // Presence is "spawn did not fail", NOT "exited 0" — a build that rejects some
76
+ // flag is still present.
77
+ if (!spawnSync(candidate, ["--version"], { stdio: "ignore", env }).error) return candidate
78
+ }
79
+ return undefined
80
+ }
81
+
82
+ /** Download and run the cargo-dist installer, then re-probe. */
83
+ function installPinned(
84
+ cfg: Required<Pick<ResolveConfig, "label">> & ResolveConfig,
85
+ url: string,
86
+ what: string,
87
+ env: NodeJS.ProcessEnv,
88
+ log?: LogFn,
89
+ toast?: ToastFn,
90
+ ): string | undefined {
91
+ // The SAME lock directory as the shell shim — cross-client coordination, so a
92
+ // Claude session and an OpenCode session starting together do not race.
93
+ const lockdir = join(env.TMPDIR || "/tmp", ".sharedserver-install.lock")
94
+ let haveLock = false
95
+ try {
96
+ mkdirSync(lockdir)
97
+ haveLock = true
98
+ } catch {
99
+ const deadline = Date.now() + 20_000
100
+ while (existsSync(lockdir) && Date.now() < deadline) sleepSync(250)
101
+ const after = probeAny(undefined, env)
102
+ if (after) return after
103
+ try {
104
+ mkdirSync(lockdir)
105
+ haveLock = true
106
+ } catch {
107
+ log?.("warn", `${cfg.label}: another process is installing sharedserver and did not finish; remove the stale lock if this persists: ${lockdir}`)
108
+ return undefined
109
+ }
110
+ }
111
+
112
+ try {
113
+ // Re-probe under the lock: the winner may have finished while we waited.
114
+ const already = probeAny(undefined, env)
115
+ const floor = parseVersion(cfg.minVersion ?? HARDCODED_FLOOR)
116
+ if (already) {
117
+ const v = versionOf(already, env)
118
+ if (v && floor && gte(v, floor)) return already
119
+ }
120
+
121
+ // Download to a file, then run it. `curl … | sh` would report success on a 404,
122
+ // since a pipeline's status is sh's; spawnSync uses no shell, so this is exact.
123
+ const script = join(lockdir, "installer.sh")
124
+ const dl = spawnSync("curl", ["--proto", "=https", "--tlsv1.2", "-LsSf", url, "-o", script], { env })
125
+ if (dl.error || dl.status !== 0) {
126
+ const msg = `${cfg.label}: could not download the sharedserver installer (${what})`
127
+ log?.("warn", msg)
128
+ toast?.("warning", msg)
129
+ return undefined
130
+ }
131
+ if (!existsSync(script) || statSync(script).size === 0) {
132
+ log?.("warn", `${cfg.label}: the downloaded sharedserver installer was empty`)
133
+ return undefined
134
+ }
135
+
136
+ // cargo-dist embeds the expected sha256 but looks up only `sha256sum` and SKIPS
137
+ // verification when missing — i.e. always, on macOS. Give it one (identical
138
+ // output) rather than execute an unverified binary.
139
+ let runEnv = env
140
+ if (spawnSync("sh", ["-c", "command -v sha256sum"], { stdio: "ignore" }).status !== 0) {
141
+ if (spawnSync("sh", ["-c", "command -v shasum"], { stdio: "ignore" }).status !== 0) {
142
+ log?.("warn", `${cfg.label}: refusing to install sharedserver — no sha256sum or shasum to verify the download`)
143
+ return undefined
144
+ }
145
+ writeFileSync(join(lockdir, "sha256sum"), '#!/bin/sh\nexec shasum -a 256 "$@"\n', { mode: 0o755 })
146
+ runEnv = { ...env, PATH: `${lockdir}:${env.PATH ?? ""}` }
147
+ }
148
+
149
+ const run = spawnSync("sh", [script], { env: runEnv, stdio: "ignore" })
150
+ if (run.error || run.status !== 0) {
151
+ log?.("warn", `${cfg.label}: the sharedserver installer ran but failed`)
152
+ return undefined
153
+ }
154
+ return probeAny(undefined, env)
155
+ } finally {
156
+ if (haveLock) {
157
+ try {
158
+ rmSync(lockdir, { recursive: true, force: true })
159
+ } catch {
160
+ /* best effort — a leaked lock costs the next run its bounded wait */
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ /** Resolve the sharedserver binary, fetching a release when nothing usable is present.
167
+ *
168
+ * Behaviourally identical to plugins/claude/bin/sharedserver — same ladder, floor,
169
+ * lock and degrade-rather-than-die fallback, so a user running both clients gets the
170
+ * same answer to "which sharedserver am I on, and why". */
171
+ export function resolveSharedserver(
172
+ cfg: ResolveConfig,
173
+ override: string | undefined,
174
+ env: NodeJS.ProcessEnv,
175
+ log?: LogFn,
176
+ toast?: ToastFn,
177
+ ): string | undefined {
178
+ const label = cfg.label ?? "sharedserver"
179
+ const minVersion = cfg.minVersion ?? cfg.pkgVersion ?? HARDCODED_FLOOR
180
+ const url =
181
+ cfg.installerUrl ??
182
+ (cfg.pkgVersion
183
+ ? `https://github.com/georgeharker/sharedserver/releases/download/v${cfg.pkgVersion}/sharedserver-installer.sh`
184
+ : "https://github.com/georgeharker/sharedserver/releases/latest/download/sharedserver-installer.sh")
185
+ const what = cfg.installerUrl || !cfg.pkgVersion ? "the latest release" : `v${cfg.pkgVersion}`
186
+ const resolved = { ...cfg, label, minVersion }
187
+ const floor = parseVersion(minVersion)
188
+
189
+ // An explicit binary / SHAREDSERVER_BIN is never second-guessed: the user named a
190
+ // specific one, so quietly downloading a different one is the wrong answer.
191
+ const explicit = override ?? env.SHAREDSERVER_BIN
192
+ if (explicit) {
193
+ const present = explicit.includes("/")
194
+ ? existsSync(explicit)
195
+ : !spawnSync(explicit, ["--version"], { stdio: "ignore", env }).error
196
+ if (present) {
197
+ const v = versionOf(explicit, env)
198
+ if (v && floor && !gte(v, floor)) {
199
+ const msg = `${label}: sharedserver at ${explicit} is ${v.join(".")}; this plugin expects >= ${minVersion}. Using it anyway because you set it explicitly.`
200
+ log?.("warn", msg)
201
+ toast?.("warning", msg)
202
+ }
203
+ return explicit
204
+ }
205
+ }
206
+
207
+ const found = probeAny(override, env)
208
+ if (found) {
209
+ const v = versionOf(found, env)
210
+ if (v && floor && gte(v, floor)) return found
211
+ // Too old: fetch, but remember this one — if the download fails we would rather
212
+ // run the old binary than nothing.
213
+ const msg = `${label}: sharedserver at ${found} is ${v?.join(".") ?? "an unknown version"}; this plugin expects >= ${minVersion}. Fetching ${what}. To keep your own build, set SHAREDSERVER_BIN=${found}`
214
+ log?.("warn", msg)
215
+ toast?.("warning", msg)
216
+ return installPinned(resolved, url, what, env, log, toast) ?? found
217
+ }
218
+
219
+ log?.("info", `${label}: no usable sharedserver found; fetching ${what} (one time). This needs no Rust toolchain.`)
220
+ return installPinned(resolved, url, what, env, log, toast)
221
+ }