@skastr0/prism-sdk 0.3.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.
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Aggregating MCP shim.
3
+ *
4
+ * A single stdio MCP server, spawned once by the harness, that fronts N
5
+ * Prism-generated plugin daemons over Unix domain sockets. It replaces the
6
+ * "one spawned process per plugin" shape with "one spawned process,
7
+ * multiplexed over UDS to N already-running daemons":
8
+ *
9
+ * harness <--stdio(JSON-RPC)--> shim <--HTTP-over-UDS--> daemon[plugin A]
10
+ * \-HTTP-over-UDS--> daemon[plugin B]
11
+ *
12
+ * Harness side (this module's `runShim`/`createShimServer`): a bare
13
+ * JSON-RPC MCP server over stdin/stdout using the SDK's low-level `Server`
14
+ * + `StdioServerTransport`. Stdio is a single duplex pipe with exactly one
15
+ * peer, so there is no SSE and no HTTP session concept to manage here —
16
+ * that machinery only exists on the daemon side.
17
+ *
18
+ * Daemon side (`DaemonConnection`): a minimal hand-rolled HTTP-over-UDS
19
+ * JSON-RPC client, deliberately not the SDK's `Client` +
20
+ * `StreamableHTTPClientTransport`. The SDK client runs every response
21
+ * through `safeParse(resultSchema, ...)`, which is a plain (non-passthrough)
22
+ * Zod object and silently drops any field the schema does not declare. This
23
+ * shim's job is to forward a daemon's tool-call result value-preserving —
24
+ * stripping no field (name mapping aside) — so the daemon's raw JSON-RPC
25
+ * `result`/`error` is read and handed back untouched instead of being
26
+ * round-tripped through schema validation on the way in.
27
+ *
28
+ * A plugin absent from the UDS registry, whose recorded daemon is dead, or
29
+ * whose recorded bundle hash no longer matches the compiled bundle on disk,
30
+ * is resolved via `daemon-resolver.ts`'s `resolveOrSpawnDaemon`: it spawns a
31
+ * fresh daemon and waits (bounded by a spawn timeout) for it to register
32
+ * and answer live. If that also fails, or a live daemon is unreachable
33
+ * within `daemonTimeoutMs`, the failure is never fatal to the shim: the
34
+ * plugin's tools are omitted from the merged `tools/list`, and a
35
+ * `tools/call` naming it returns a typed `McpError` (SDK
36
+ * `ErrorCode.ConnectionClosed`). Every other configured plugin keeps
37
+ * serving normally — each plugin's request runs against its own timeout and
38
+ * failure is caught per-plugin.
39
+ */
40
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
41
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
42
+ import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, LATEST_PROTOCOL_VERSION, McpError, } from "@modelcontextprotocol/sdk/types.js";
43
+ import { getDaemon as getDaemonDefault } from "./uds-registry.js";
44
+ import { resolveOrSpawnDaemon, DaemonResolveError, DEFAULT_SPAWN_TIMEOUT_MS } from "./daemon-resolver.js";
45
+ // ---------------------------------------------------------------------------
46
+ // Naming: the per-plugin namespace segment used to flatten N plugins' tools
47
+ // into the one flat name-space a single MCP `tools/list` response can hold.
48
+ // ---------------------------------------------------------------------------
49
+ /**
50
+ * FNV-1a, 8 hex chars. Deliberately bit-for-bit identical to
51
+ * `stableHash8` in the root package's `src/compile/stable-hash.ts`, which
52
+ * backs `generatedMcpWireServerName` (`p_<hash8>`) — the segment every
53
+ * harness lowerer (Claude Code, Codex CLI, Antigravity, Factory Droid,
54
+ * Kimi Code, Cursor, Grok) already bakes into compiled permission strings
55
+ * and tool FQNs (e.g. Claude Code's `mcp__p_<hash8>__<tool>`).
56
+ *
57
+ * `packages/prism-sdk` cannot import that root-package module (its
58
+ * `tsconfig.json` `rootDir` is `./src`; a real `import` of anything under
59
+ * the workspace root `src/` fails to compile), so the tiny pure function is
60
+ * duplicated here rather than re-derived with a different algorithm. Using
61
+ * the *same* namespace segment the harness side already computes means a
62
+ * later wave that points a harness's MCP config at this shim only has to
63
+ * add an outer wrapper layer around `p_<hash8>__<tool>` (the shim's own
64
+ * server key in that harness's config), not rename every already-compiled
65
+ * permission string from scratch.
66
+ */
67
+ const stableHash8 = (input) => {
68
+ let hash = 0x811c9dc5;
69
+ for (let index = 0; index < input.length; index++) {
70
+ hash ^= input.codePointAt(index);
71
+ hash = Math.trunc(Math.imul(hash, 0x01000193));
72
+ }
73
+ return (hash >>> 0).toString(16).padStart(8, "0");
74
+ };
75
+ /** `p_<hash8>` — see `stableHash8` above for why this exact shape. */
76
+ export const pluginWireNamespace = (pluginName) => `p_${stableHash8(pluginName)}`;
77
+ const NAMESPACE_SEPARATOR = "__";
78
+ /** Fixed width of `p_<hash8>`: literal `p_` (2) + 8 hex chars. */
79
+ const NAMESPACE_LENGTH = 2 + 8;
80
+ /** Harness-visible tool name: `<pluginWireNamespace>__<originalToolName>`. */
81
+ export const namespacedToolName = (pluginName, toolName) => `${pluginWireNamespace(pluginName)}${NAMESPACE_SEPARATOR}${toolName}`;
82
+ /**
83
+ * Inverse of `namespacedToolName`. The namespace segment has a fixed
84
+ * width, so this splits on position rather than searching for the `__`
85
+ * separator — a tool name is free-form and may itself legally contain
86
+ * `__`, but the namespace segment never does.
87
+ */
88
+ export const splitNamespacedToolName = (fqName) => {
89
+ if (fqName.length <= NAMESPACE_LENGTH + NAMESPACE_SEPARATOR.length)
90
+ return undefined;
91
+ const namespace = fqName.slice(0, NAMESPACE_LENGTH);
92
+ const separator = fqName.slice(NAMESPACE_LENGTH, NAMESPACE_LENGTH + NAMESPACE_SEPARATOR.length);
93
+ if (separator !== NAMESPACE_SEPARATOR)
94
+ return undefined;
95
+ const toolName = fqName.slice(NAMESPACE_LENGTH + NAMESPACE_SEPARATOR.length);
96
+ if (toolName.length === 0)
97
+ return undefined;
98
+ return { namespace, toolName };
99
+ };
100
+ // ---------------------------------------------------------------------------
101
+ // Daemon-facing HTTP-over-UDS client
102
+ // ---------------------------------------------------------------------------
103
+ export class ShimDaemonError extends Error {
104
+ pluginName;
105
+ cause;
106
+ kind = "shim-daemon-error";
107
+ constructor(pluginName, message, cause) {
108
+ super(`[${pluginName}] ${message}`);
109
+ this.pluginName = pluginName;
110
+ this.cause = cause;
111
+ this.name = "ShimDaemonError";
112
+ }
113
+ }
114
+ /**
115
+ * One JSON-RPC-over-UDS connection to a single plugin daemon, matching the
116
+ * contract `src/compile/mcp-bundle.ts`'s `MCP_SDK_HTTP_RUNTIME` template
117
+ * implements: POST to `/mcp` with `mcp-protocol-version`, `initialize`
118
+ * first to obtain `mcp-session-id`, then that header on every subsequent
119
+ * call. Initializes once and reuses the session; a failed request drops
120
+ * the cached session so the next call starts a fresh handshake (the
121
+ * daemon may have restarted).
122
+ */
123
+ class DaemonConnection {
124
+ pluginName;
125
+ socketPath;
126
+ timeoutMs;
127
+ exposureProfile;
128
+ sessionId;
129
+ requestId = 0;
130
+ /**
131
+ * In-flight handshake, memoized so concurrent first-callers share one
132
+ * `initialize` round-trip instead of each racing their own. Cleared
133
+ * (success or failure) once the handshake settles, so a later call that
134
+ * finds `sessionId` unset — because the handshake failed, or because a
135
+ * broken response later dropped the session — starts a fresh one rather
136
+ * than piling onto a promise that has already resolved or rejected.
137
+ */
138
+ initializing;
139
+ constructor(pluginName, socketPath, timeoutMs, exposureProfile) {
140
+ this.pluginName = pluginName;
141
+ this.socketPath = socketPath;
142
+ this.timeoutMs = timeoutMs;
143
+ this.exposureProfile = exposureProfile;
144
+ }
145
+ async listTools() {
146
+ await this.ensureInitialized();
147
+ const envelope = await this.rpc("tools/list");
148
+ if (envelope.error) {
149
+ throw new ShimDaemonError(this.pluginName, `daemon tools/list failed: ${envelope.error.message}`);
150
+ }
151
+ const tools = envelope.result?.tools;
152
+ return tools ?? [];
153
+ }
154
+ /** Returns the daemon's raw JSON-RPC `result` for `tools/call`, untouched. */
155
+ async callTool(toolName, args) {
156
+ await this.ensureInitialized();
157
+ const envelope = await this.rpc("tools/call", { name: toolName, arguments: args });
158
+ if (envelope.error) {
159
+ throw new ShimDaemonError(this.pluginName, `daemon tools/call '${toolName}' failed: ${envelope.error.message}`);
160
+ }
161
+ return envelope.result;
162
+ }
163
+ /**
164
+ * Single-flight: if a handshake is already in progress, every concurrent
165
+ * caller awaits that same promise instead of firing its own `initialize`
166
+ * request against the daemon.
167
+ */
168
+ async ensureInitialized() {
169
+ if (this.sessionId)
170
+ return;
171
+ if (!this.initializing) {
172
+ this.initializing = this.handshake().finally(() => {
173
+ this.initializing = undefined;
174
+ });
175
+ }
176
+ return this.initializing;
177
+ }
178
+ async handshake() {
179
+ const response = await this.request("initialize", {
180
+ protocolVersion: LATEST_PROTOCOL_VERSION,
181
+ capabilities: {},
182
+ clientInfo: { name: "prism-mcp-shim", version: "0.1.0" },
183
+ });
184
+ const sessionId = response.headers.get("mcp-session-id");
185
+ if (!sessionId) {
186
+ throw new ShimDaemonError(this.pluginName, "daemon initialize did not return mcp-session-id");
187
+ }
188
+ const body = await this.parseBody(response);
189
+ if (body.error) {
190
+ throw new ShimDaemonError(this.pluginName, `daemon initialize failed: ${body.error.message}`);
191
+ }
192
+ this.sessionId = sessionId;
193
+ }
194
+ async rpc(method, params) {
195
+ const response = await this.request(method, params);
196
+ return this.parseBody(response);
197
+ }
198
+ /**
199
+ * Parses a daemon response body as the JSON-RPC envelope. A daemon that
200
+ * answers HTTP 200 with a truncated or otherwise non-JSON body is just as
201
+ * wedged as one that never answered: the malformed body is wrapped as a
202
+ * `ShimDaemonError` (never a raw `SyntaxError` escaping uncaught and
203
+ * mis-classified as `InternalError` by the caller) and the cached session
204
+ * is dropped, so the next call re-handshakes instead of replaying against
205
+ * a connection the daemon has already shown is broken.
206
+ */
207
+ async parseBody(response) {
208
+ const text = await response.text();
209
+ if (text.length === 0)
210
+ return {};
211
+ try {
212
+ return JSON.parse(text);
213
+ }
214
+ catch (error) {
215
+ this.sessionId = undefined;
216
+ throw new ShimDaemonError(this.pluginName, `daemon returned a malformed response body: ${errorMessage(error)}`, error);
217
+ }
218
+ }
219
+ /**
220
+ * Fires one JSON-RPC request at the daemon's UDS socket. Any failure —
221
+ * connection refused, socket file gone, timeout, malformed response — is
222
+ * wrapped as `ShimDaemonError` and drops the cached session, so a dead
223
+ * daemon never leaves this connection wedged on a session id that will
224
+ * never work again.
225
+ */
226
+ async request(method, params) {
227
+ const id = (this.requestId += 1);
228
+ const headers = {
229
+ accept: "application/json, text/event-stream",
230
+ "content-type": "application/json",
231
+ "mcp-protocol-version": LATEST_PROTOCOL_VERSION,
232
+ };
233
+ if (this.sessionId)
234
+ headers["mcp-session-id"] = this.sessionId;
235
+ if (this.exposureProfile)
236
+ headers["x-prism-mcp-exposure"] = this.exposureProfile;
237
+ const controller = new AbortController();
238
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
239
+ try {
240
+ const init = {
241
+ method: "POST",
242
+ headers,
243
+ body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
244
+ signal: controller.signal,
245
+ unix: this.socketPath,
246
+ };
247
+ const response = await fetch("http://localhost/mcp", init);
248
+ if (!response.ok) {
249
+ this.sessionId = undefined;
250
+ throw new ShimDaemonError(this.pluginName, `daemon '${method}' returned HTTP ${response.status}`);
251
+ }
252
+ return response;
253
+ }
254
+ catch (error) {
255
+ this.sessionId = undefined;
256
+ if (error instanceof ShimDaemonError)
257
+ throw error;
258
+ throw new ShimDaemonError(this.pluginName, `daemon '${method}' unreachable: ${errorMessage(error)}`, error);
259
+ }
260
+ finally {
261
+ clearTimeout(timeout);
262
+ }
263
+ }
264
+ }
265
+ const errorMessage = (error) => (error instanceof Error ? error.message : String(error));
266
+ export const DEFAULT_SHIM_DAEMON_TIMEOUT_MS = 30_000;
267
+ export class ShimAggregator {
268
+ plugins;
269
+ daemonTimeoutMs;
270
+ getDaemon;
271
+ spawnTimeoutMs;
272
+ resolveOrSpawn;
273
+ enabledTools;
274
+ exposureProfile;
275
+ connections = new Map();
276
+ constructor(options) {
277
+ this.plugins = options.plugins;
278
+ this.daemonTimeoutMs = options.daemonTimeoutMs ?? DEFAULT_SHIM_DAEMON_TIMEOUT_MS;
279
+ this.getDaemon = options.getDaemon ?? getDaemonDefault;
280
+ this.spawnTimeoutMs = options.spawnTimeoutMs ?? DEFAULT_SPAWN_TIMEOUT_MS;
281
+ this.enabledTools = options.enabledTools;
282
+ this.exposureProfile = options.exposureProfile;
283
+ this.resolveOrSpawn =
284
+ options.resolveOrSpawn ??
285
+ ((plugin) => resolveOrSpawnDaemon({
286
+ plugin,
287
+ prismHome: options.prismHome,
288
+ getDaemon: this.getDaemon,
289
+ spawnTimeoutMs: this.spawnTimeoutMs,
290
+ }));
291
+ }
292
+ /**
293
+ * Resolves (or reuses) the connection for `plugin`: registry record
294
+ * present and live and fresh -> reuse; absent, dead, or stale (bundle
295
+ * hash no longer matches the on-disk bundle) -> `resolveOrSpawn` spawns a
296
+ * fresh daemon and waits for it to come up (see `daemon-resolver.ts`).
297
+ *
298
+ * Returns `undefined` if resolution ultimately fails (no bundle to spawn,
299
+ * or the daemon never became live within the timeout) — that is
300
+ * "unavailable", not fatal: the caller omits its tools / reports a typed
301
+ * error, but the aggregator itself never throws for one bad plugin.
302
+ */
303
+ async resolveConnection(plugin) {
304
+ let entry;
305
+ try {
306
+ entry = await this.resolveOrSpawn(plugin);
307
+ }
308
+ catch (error) {
309
+ this.connections.delete(plugin);
310
+ const detail = error instanceof DaemonResolveError || error instanceof Error ? error.message : String(error);
311
+ console.error(`[prism-mcp-shim] ${plugin}: ${detail}`);
312
+ return undefined;
313
+ }
314
+ const cached = this.connections.get(plugin);
315
+ if (cached && cached.sock === entry.sock) {
316
+ return cached.connection;
317
+ }
318
+ const connection = new DaemonConnection(plugin, entry.sock, this.daemonTimeoutMs, this.exposureProfile);
319
+ this.connections.set(plugin, { sock: entry.sock, connection });
320
+ return connection;
321
+ }
322
+ /**
323
+ * Merged, namespaced `tools/list` across every configured plugin.
324
+ * Per-plugin fetches run in parallel (one slow/dead plugin never blocks
325
+ * another), but results are reassembled in the fixed configured-plugin
326
+ * order afterward, so the merged list is deterministic regardless of
327
+ * which daemon happened to answer first.
328
+ *
329
+ * Tools are filtered by `enabledTools` if set: only tools in that set are
330
+ * returned. This is the shim-side filtering applied before any daemon
331
+ * response is merged.
332
+ */
333
+ async listTools() {
334
+ const perPlugin = await Promise.all(this.plugins.map(async (plugin) => {
335
+ try {
336
+ const connection = await this.resolveConnection(plugin);
337
+ if (!connection)
338
+ return [];
339
+ const tools = await connection.listTools();
340
+ return tools.map((tool) => ({ plugin, tool }));
341
+ }
342
+ catch {
343
+ // Unavailable for this pass; omitted, never fatal to the merge.
344
+ return [];
345
+ }
346
+ }));
347
+ const merged = [];
348
+ for (const named of perPlugin) {
349
+ for (const { plugin, tool } of named) {
350
+ const fqName = namespacedToolName(plugin, tool.name);
351
+ // Filter by enabledTools if set
352
+ if (this.enabledTools !== undefined && !this.enabledTools.has(fqName)) {
353
+ continue;
354
+ }
355
+ merged.push({ ...tool, name: fqName });
356
+ }
357
+ }
358
+ return merged;
359
+ }
360
+ /**
361
+ * Dispatches a namespaced tool name to its owning plugin's daemon and
362
+ * returns the daemon's raw `CallToolResult` untouched. Throws
363
+ * `ShimDaemonError` (typed) when the name cannot be resolved to a
364
+ * configured plugin, when that plugin's daemon is absent/unreachable
365
+ * within the timeout, or when the tool is not in the enabled set —
366
+ * either way, this never crashes the shim and never touches any other
367
+ * plugin's connection.
368
+ */
369
+ async callTool(fqName, args) {
370
+ // Filter by enabledTools if set
371
+ if (this.enabledTools !== undefined && !this.enabledTools.has(fqName)) {
372
+ throw new ShimDaemonError("shim", `tool name '${fqName}' is not enabled`);
373
+ }
374
+ const split = splitNamespacedToolName(fqName);
375
+ if (!split) {
376
+ throw new ShimDaemonError("shim", `tool name '${fqName}' is not a namespaced shim tool`);
377
+ }
378
+ const plugin = this.plugins.find((candidate) => pluginWireNamespace(candidate) === split.namespace);
379
+ if (!plugin) {
380
+ throw new ShimDaemonError("shim", `tool name '${fqName}' does not match any configured plugin`);
381
+ }
382
+ const connection = await this.resolveConnection(plugin);
383
+ if (!connection) {
384
+ throw new ShimDaemonError(plugin, `plugin has no live daemon registered`);
385
+ }
386
+ return connection.callTool(split.toolName, args);
387
+ }
388
+ }
389
+ // ---------------------------------------------------------------------------
390
+ // Harness-facing stdio MCP server
391
+ // ---------------------------------------------------------------------------
392
+ const SERVER_NAME = "prism-mcp-shim";
393
+ const SERVER_VERSION = "0.1.0";
394
+ export const createShimServer = (aggregator) => {
395
+ const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
396
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
397
+ tools: await aggregator.listTools(),
398
+ }));
399
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
400
+ const { name, arguments: args } = request.params;
401
+ try {
402
+ const result = await aggregator.callTool(name, args ?? {});
403
+ return result;
404
+ }
405
+ catch (error) {
406
+ if (error instanceof ShimDaemonError) {
407
+ throw new McpError(ErrorCode.ConnectionClosed, error.message);
408
+ }
409
+ throw new McpError(ErrorCode.InternalError, errorMessage(error));
410
+ }
411
+ });
412
+ return server;
413
+ };
414
+ /**
415
+ * Wires the aggregator to a stdio transport and runs until stdin closes or
416
+ * the process receives SIGINT/SIGTERM. Never throws for daemon-level
417
+ * failures — those surface per-request as typed MCP errors instead.
418
+ */
419
+ export const runShim = async (options) => {
420
+ const aggregator = new ShimAggregator(options);
421
+ const server = createShimServer(aggregator);
422
+ const transport = new StdioServerTransport();
423
+ const shutdown = async () => {
424
+ try {
425
+ await server.close();
426
+ }
427
+ finally {
428
+ process.exit(0);
429
+ }
430
+ };
431
+ process.on("SIGINT", () => void shutdown());
432
+ process.on("SIGTERM", () => void shutdown());
433
+ await server.connect(transport);
434
+ };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Error type for UDS path violations.
3
+ * Typed to distinguish from other path errors.
4
+ */
5
+ export declare class UDSPathLengthError extends Error {
6
+ readonly attemptedPath: string;
7
+ readonly kind: "uds-path-length-error";
8
+ constructor(message: string, attemptedPath: string);
9
+ }
10
+ /**
11
+ * Construct a content-addressed UDS socket path.
12
+ *
13
+ * Returns: `<prismHome>/runtime/mcp/<plugin>/<truncated-hash>.sock`
14
+ *
15
+ * @param plugin - Plugin identifier (must match /^[a-zA-Z0-9_-]+$/)
16
+ * @param bundleHash - Full content hash; will be truncated to first 16 hex
17
+ * @param prismHome - Prism home directory. Threaded explicitly by the
18
+ * caller (the CLI edge resolves `PRISM_HOME` once via `resolvePrismHome()`
19
+ * and passes the result down); this package cannot import that resolver
20
+ * itself (`packages/prism-sdk` has no dependency on the root `src/`
21
+ * tree), so an omitted value falls back to the same default
22
+ * (`~/.prism`) `resolvePrismHome()` uses when unset.
23
+ * @returns Absolute path to the socket file
24
+ * @throws UDSPathLengthError if resulting path exceeds 100 bytes
25
+ */
26
+ export declare function udsPathFor(plugin: string, bundleHash: string, prismHome?: string): string;
@@ -0,0 +1,70 @@
1
+ import { homedir } from "os";
2
+ /**
3
+ * Error type for UDS path violations.
4
+ * Typed to distinguish from other path errors.
5
+ */
6
+ export class UDSPathLengthError extends Error {
7
+ attemptedPath;
8
+ kind = "uds-path-length-error";
9
+ constructor(message, attemptedPath) {
10
+ super(message);
11
+ this.attemptedPath = attemptedPath;
12
+ this.name = "UDSPathLengthError";
13
+ }
14
+ }
15
+ /**
16
+ * macOS sun_path limit (sockaddr_un.sun_path)
17
+ */
18
+ const MACOS_SUN_PATH_LIMIT = 104;
19
+ /**
20
+ * Tolerance below the hard limit to account for null terminator and safety margin.
21
+ * Standard practice: 104 - 4 bytes = 100 bytes.
22
+ */
23
+ const PATH_LENGTH_LIMIT = 100;
24
+ /**
25
+ * Regex to validate plugin names: alphanumeric, dash, underscore only.
26
+ * Prevents path traversal and special chars that could break socket path integrity.
27
+ */
28
+ const VALID_PLUGIN_NAME = /^[a-zA-Z0-9_-]+$/;
29
+ /**
30
+ * Truncate hash to first 16 hex characters for path length safety.
31
+ * 16 hex chars = 64 bits, sufficient for disambiguation.
32
+ */
33
+ function truncateHash(bundleHash) {
34
+ return bundleHash.slice(0, 16);
35
+ }
36
+ /**
37
+ * Construct a content-addressed UDS socket path.
38
+ *
39
+ * Returns: `<prismHome>/runtime/mcp/<plugin>/<truncated-hash>.sock`
40
+ *
41
+ * @param plugin - Plugin identifier (must match /^[a-zA-Z0-9_-]+$/)
42
+ * @param bundleHash - Full content hash; will be truncated to first 16 hex
43
+ * @param prismHome - Prism home directory. Threaded explicitly by the
44
+ * caller (the CLI edge resolves `PRISM_HOME` once via `resolvePrismHome()`
45
+ * and passes the result down); this package cannot import that resolver
46
+ * itself (`packages/prism-sdk` has no dependency on the root `src/`
47
+ * tree), so an omitted value falls back to the same default
48
+ * (`~/.prism`) `resolvePrismHome()` uses when unset.
49
+ * @returns Absolute path to the socket file
50
+ * @throws UDSPathLengthError if resulting path exceeds 100 bytes
51
+ */
52
+ export function udsPathFor(plugin, bundleHash, prismHome) {
53
+ // Validate plugin name
54
+ if (!VALID_PLUGIN_NAME.test(plugin)) {
55
+ throw new Error(`Invalid plugin name: '${plugin}'. Must match /^[a-zA-Z0-9_-]+$/`);
56
+ }
57
+ // Truncate hash to first 16 hex characters
58
+ const truncated = truncateHash(bundleHash);
59
+ // Build the path
60
+ const home = prismHome ?? `${homedir()}/.prism`;
61
+ const relativePath = `runtime/mcp/${plugin}/${truncated}.sock`;
62
+ const fullPath = `${home}/${relativePath}`;
63
+ // Assert length constraint
64
+ const pathLength = Buffer.byteLength(fullPath, "utf8");
65
+ if (pathLength > PATH_LENGTH_LIMIT) {
66
+ throw new UDSPathLengthError(`UDS socket path exceeds ${PATH_LENGTH_LIMIT}-byte limit (got ${pathLength} bytes). ` +
67
+ `Plugin name '${plugin}' may be too long.`, fullPath);
68
+ }
69
+ return fullPath;
70
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Error type for registry operation violations.
3
+ * Used to distinguish registry errors from other file system errors.
4
+ */
5
+ export declare class UDSRegistryError extends Error {
6
+ readonly context?: unknown | undefined;
7
+ readonly kind: "uds-registry-error";
8
+ constructor(message: string, context?: unknown | undefined);
9
+ }
10
+ /**
11
+ * Per-plugin daemon instance record.
12
+ */
13
+ export interface RegistryEntry {
14
+ readonly pid: number;
15
+ readonly sock: string;
16
+ readonly bundleHash: string;
17
+ readonly startedAt: number;
18
+ readonly lastUsed: number;
19
+ }
20
+ /**
21
+ * Registry of active daemon instances, indexed by plugin name.
22
+ */
23
+ export type RegistryData = Record<string, RegistryEntry>;
24
+ /**
25
+ * Result type for registry operations that may fail gracefully.
26
+ * Success carries the result; absent means the entry does not exist or is corrupted.
27
+ */
28
+ export type RegistryResult<T> = {
29
+ kind: "ok";
30
+ value: T;
31
+ } | {
32
+ kind: "absent";
33
+ };
34
+ /**
35
+ * Register a new daemon instance or update an existing one.
36
+ *
37
+ * Atomically writes the entry to this plugin's own registry file. Never
38
+ * reads or rewrites any other plugin's file.
39
+ *
40
+ * Throws UDSRegistryError on I/O failure (unrecoverable).
41
+ */
42
+ export declare function registerDaemon(plugin: string, entry: RegistryEntry): Promise<void>;
43
+ /**
44
+ * Unregister a daemon instance by plugin name.
45
+ *
46
+ * Removes this plugin's registry file. Does nothing (no error) if the
47
+ * entry does not exist.
48
+ *
49
+ * Throws UDSRegistryError on I/O failure (unrecoverable), file-not-found excepted.
50
+ */
51
+ export declare function unregisterDaemon(plugin: string): Promise<void>;
52
+ /**
53
+ * Get a single daemon entry by plugin name.
54
+ *
55
+ * Returns `{ kind: "ok", value: entry }` on success.
56
+ * Returns `{ kind: "absent" }` if the plugin is not registered or
57
+ * the registry is corrupted.
58
+ *
59
+ * Never throws.
60
+ */
61
+ export declare function getDaemon(plugin: string): Promise<RegistryResult<RegistryEntry>>;
62
+ /**
63
+ * Get all registered daemon entries across every plugin.
64
+ *
65
+ * Returns `{ kind: "ok", value: data }` on success (may be empty).
66
+ * Returns `{ kind: "absent" }` if the registry root directory does not
67
+ * exist yet (nothing has ever registered).
68
+ *
69
+ * Never throws; unreadable or corrupted per-plugin files are skipped
70
+ * individually rather than failing the whole scan.
71
+ */
72
+ export declare function getAllDaemons(): Promise<RegistryResult<RegistryData>>;
73
+ /**
74
+ * Update the lastUsed timestamp for a daemon entry.
75
+ *
76
+ * Returns `{ kind: "ok" }` on success.
77
+ * Returns `{ kind: "absent" }` if the plugin is not registered.
78
+ *
79
+ * Throws UDSRegistryError on I/O failure (unrecoverable).
80
+ */
81
+ export declare function touchDaemon(plugin: string): Promise<RegistryResult<void>>;
82
+ /**
83
+ * Outcome of `cleanupDaemonIfOwner`.
84
+ *
85
+ * - "cleaned": the record belonged to the given pid; it was unregistered
86
+ * and the socket file (if provided) was unlinked.
87
+ * - "not-owner": a record exists but names a *different* pid — a successor
88
+ * that has already re-registered under the same plugin key. Nothing was
89
+ * touched.
90
+ * - "absent": no record exists for this plugin. Treated as "not ours":
91
+ * nothing was touched.
92
+ */
93
+ export type OwnershipCleanupResult = "cleaned" | "not-owner" | "absent";
94
+ /**
95
+ * Ownership-gated teardown for a daemon's registration and socket file.
96
+ *
97
+ * Re-reads the current registry record for `plugin` and only acts — unlink
98
+ * `socketPath` and remove the registry entry — when that record's pid
99
+ * matches `pid`, i.e. when the caller is still the process the registry
100
+ * currently considers live for that plugin.
101
+ *
102
+ * This exists to protect a fast-respawning successor: if a predecessor is
103
+ * slow to drain and only calls this after a successor has already bound
104
+ * the same socket path and re-registered under the same plugin key, the
105
+ * registry record now names the successor's pid — a missing record or one
106
+ * naming a different pid means "not mine to clean up", so the successor's
107
+ * live socket file and registration are left completely untouched.
108
+ */
109
+ export declare function cleanupDaemonIfOwner(plugin: string, pid: number, socketPath: string | undefined): Promise<OwnershipCleanupResult>;