@fnclaude/cli 1.1.0 → 2.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.
Files changed (100) hide show
  1. package/bin/fnc.js +34 -79
  2. package/package.json +6 -9
  3. package/share/fnclaude/templates/handoff.template.md +11 -0
  4. package/src/argv/classify.ts +48 -0
  5. package/src/argv/expand.ts +51 -0
  6. package/src/argv/intake.ts +52 -0
  7. package/src/argv/magic.ts +103 -0
  8. package/src/argv/parse.ts +213 -0
  9. package/src/argv/preserve-args.ts +333 -0
  10. package/src/argv/sentinel.ts +41 -0
  11. package/src/argv/short-flags.ts +152 -0
  12. package/src/config/load.ts +116 -0
  13. package/src/handoff/awaiter.ts +140 -0
  14. package/src/handoff/clean-env.ts +45 -0
  15. package/src/handoff/kill-and-exec.ts +110 -0
  16. package/src/handoff/spawn-launcher.ts +185 -0
  17. package/src/handoff/summary-file.ts +86 -0
  18. package/src/handoff/trigger.ts +90 -0
  19. package/src/help-version.ts +151 -0
  20. package/src/launch/compose-env.ts +34 -0
  21. package/src/launch/cross-cwd-parse.ts +69 -0
  22. package/src/launch/cross-cwd-relaunch.ts +95 -0
  23. package/src/launch/find-claude.ts +52 -0
  24. package/src/launch/live-permission-reader.ts +133 -0
  25. package/src/launch/ring-buffer.ts +92 -0
  26. package/src/main.ts +580 -437
  27. package/src/mcp/dispatch.ts +240 -0
  28. package/src/mcp/handlers/clipboard-backends.ts +176 -0
  29. package/src/mcp/handlers/clipboard.ts +62 -0
  30. package/src/mcp/handlers/restart.ts +156 -0
  31. package/src/mcp/handlers/spawn.ts +219 -0
  32. package/src/mcp/handlers/switch.ts +272 -0
  33. package/src/mcp/inject-config.ts +59 -0
  34. package/src/mcp/jsonrpc-server.ts +154 -0
  35. package/src/mcp/listener.ts +141 -0
  36. package/src/mcp/parent-dispatch.ts +154 -0
  37. package/src/mcp/socket-path.ts +48 -0
  38. package/src/mcp/wire.ts +181 -0
  39. package/src/name/auto-name.ts +162 -0
  40. package/src/name/llm-prompt.ts +14 -0
  41. package/src/name/sanitize.ts +57 -0
  42. package/src/name/sdk-llm.ts +42 -0
  43. package/src/noop/seed.ts +63 -0
  44. package/src/noop/template-source.ts +62 -0
  45. package/src/path/ensure-cwd.ts +95 -0
  46. package/src/path/resolve.ts +58 -0
  47. package/src/prompts/dir.ts +61 -0
  48. package/src/prompts/load.ts +100 -0
  49. package/src/prompts/select.ts +43 -0
  50. package/src/repo/clone-exec.ts +37 -0
  51. package/src/repo/clone.ts +45 -0
  52. package/src/repo/gh-runner.ts +68 -0
  53. package/src/repo/host-aliases.ts +58 -0
  54. package/src/repo/owner-lookup.ts +71 -0
  55. package/src/repo/ref.ts +146 -0
  56. package/src/repo/repo-settings.ts +99 -0
  57. package/src/repo/resolve-input.ts +179 -0
  58. package/src/repo/template.ts +92 -0
  59. package/src/warnings/buffer.ts +39 -0
  60. package/src/worktree/auto-tmux.ts +45 -0
  61. package/src/worktree/git-list.ts +73 -0
  62. package/src/worktree/intercept.ts +150 -0
  63. package/bin/preflight.js +0 -66
  64. package/prompts/agent-pitfall.md +0 -1
  65. package/prompts/noop-router.md +0 -186
  66. package/prompts/project-switch.md +0 -64
  67. package/prompts/restart.md +0 -50
  68. package/prompts/spawn.md +0 -62
  69. package/src/argParser.ts +0 -367
  70. package/src/args/preserve.ts +0 -338
  71. package/src/args.ts +0 -239
  72. package/src/argv.ts +0 -203
  73. package/src/autoname.ts +0 -273
  74. package/src/clipboard.ts +0 -149
  75. package/src/config.ts +0 -369
  76. package/src/errors.ts +0 -13
  77. package/src/handoff.ts +0 -108
  78. package/src/help.ts +0 -139
  79. package/src/hostAliases.ts +0 -139
  80. package/src/index.ts +0 -120
  81. package/src/mcp/client.ts +0 -645
  82. package/src/mcp/protocol.ts +0 -445
  83. package/src/mcp/socketListener.ts +0 -540
  84. package/src/noop.ts +0 -106
  85. package/src/passthrough.ts +0 -36
  86. package/src/paths.ts +0 -55
  87. package/src/prompts.ts +0 -279
  88. package/src/pty/unix.ts +0 -429
  89. package/src/pty/windows.ts +0 -125
  90. package/src/pty.ts +0 -380
  91. package/src/repoRef.ts +0 -158
  92. package/src/repoSettings.ts +0 -144
  93. package/src/resolver.ts +0 -519
  94. package/src/sanitize.ts +0 -120
  95. package/src/sessionState.ts +0 -220
  96. package/src/silentRelaunch.ts +0 -178
  97. package/src/spawn.ts +0 -163
  98. package/src/template.ts +0 -44
  99. package/src/warnings.ts +0 -34
  100. package/src/worktree.ts +0 -201
@@ -0,0 +1,154 @@
1
+ /**
2
+ * JSON-RPC 2.0 server scaffold for the MCP subprocess.
3
+ *
4
+ * The fnclaude MCP subprocess (spawned by claude via the injected
5
+ * `--mcp-config`) is a Model Context Protocol server that speaks
6
+ * newline-delimited JSON-RPC 2.0 over stdio. This module is the pure
7
+ * routing layer — transport (read stdin lines, write stdout lines) is the
8
+ * subprocess entry point's job (§7.5); per-call dialing of the parent
9
+ * socket is the tool handler's job (§7.6 / §8).
10
+ *
11
+ * Methods routed:
12
+ * - "initialize" → returns the injected initializeResponse
13
+ * - "tools/list" → returns { tools: [{name, description, inputSchema}, ...] }
14
+ * in registration order (Object.entries order on
15
+ * the tools record)
16
+ * - "tools/call" → dispatches to tools[name].handler(args), wraps
17
+ * the return value in MCP's content shape
18
+ * ({ content: [{ type: "text", text: <json> }] })
19
+ * - anything else → JSON-RPC error -32601 (method not found)
20
+ *
21
+ * Notifications (requests with no `id` field) are processed but produce no
22
+ * response — handle() returns null.
23
+ *
24
+ * Error codes follow the JSON-RPC 2.0 spec:
25
+ * -32700 Parse error (malformed JSON)
26
+ * -32600 Invalid Request (not an object, missing method)
27
+ * -32601 Method not found (unknown method or unknown tool name)
28
+ * -32603 Internal error (handler throw)
29
+ *
30
+ * Per design.mcp.md §3, the wire format is one JSON object per line; this
31
+ * function takes one line and returns one line (or null for
32
+ * notifications). Pipelining is not used.
33
+ *
34
+ * Tool registration is by injection — the four real tools (fnc_restart,
35
+ * fnc_switch_project, fnc_spawn_session, fnc_copy_to_clipboard) come in
36
+ * §8; tests use fakes.
37
+ */
38
+
39
+ export type JsonRpcId = number | string | null;
40
+
41
+ export interface McpTool {
42
+ description: string;
43
+ inputSchema: object;
44
+ handler: (args: unknown) => Promise<object>;
45
+ }
46
+
47
+ export interface CreateJsonRpcServerArgs {
48
+ tools: Record<string, McpTool>;
49
+ initializeResponse: object;
50
+ }
51
+
52
+ export interface JsonRpcServer {
53
+ handle(line: string): Promise<string | null>;
54
+ }
55
+
56
+ interface JsonRpcRequest {
57
+ jsonrpc?: unknown;
58
+ id?: JsonRpcId | undefined;
59
+ method?: unknown;
60
+ params?: unknown;
61
+ }
62
+
63
+ export function createJsonRpcServer(
64
+ args: CreateJsonRpcServerArgs,
65
+ ): JsonRpcServer {
66
+ return {
67
+ async handle(line: string): Promise<string | null> {
68
+ let parsed: unknown;
69
+ try {
70
+ parsed = JSON.parse(line);
71
+ } catch {
72
+ return serializeError(null, -32700, 'Parse error: malformed JSON');
73
+ }
74
+
75
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
76
+ return serializeError(null, -32600, 'Invalid Request: not a JSON-RPC object');
77
+ }
78
+
79
+ const req = parsed as JsonRpcRequest;
80
+ const id = normalizeId(req.id);
81
+ const isNotification = req.id === undefined;
82
+
83
+ if (typeof req.method !== 'string') {
84
+ if (isNotification) return null;
85
+ return serializeError(id, -32600, 'Invalid Request: missing method');
86
+ }
87
+
88
+ const method = req.method;
89
+
90
+ if (isNotification) {
91
+ // Notifications are processed but produce no response.
92
+ return null;
93
+ }
94
+
95
+ if (method === 'initialize') {
96
+ return serializeResult(id, args.initializeResponse);
97
+ }
98
+
99
+ if (method === 'tools/list') {
100
+ const tools = Object.entries(args.tools).map(([name, tool]) => ({
101
+ name,
102
+ description: tool.description,
103
+ inputSchema: tool.inputSchema,
104
+ }));
105
+ return serializeResult(id, { tools });
106
+ }
107
+
108
+ if (method === 'tools/call') {
109
+ const params = (req.params ?? {}) as { name?: unknown; arguments?: unknown };
110
+ const name = typeof params.name === 'string' ? params.name : '';
111
+ if (!name || !(name in args.tools)) {
112
+ return serializeError(id, -32601, `Unknown tool: ${name || '(missing name)'}`);
113
+ }
114
+ const tool = args.tools[name]!;
115
+ const toolArgs =
116
+ params.arguments !== undefined && params.arguments !== null
117
+ ? params.arguments
118
+ : {};
119
+ let result: object;
120
+ try {
121
+ result = await tool.handler(toolArgs);
122
+ } catch (err) {
123
+ const msg = err instanceof Error ? err.message : String(err);
124
+ return serializeError(id, -32603, `Internal error: ${msg}`);
125
+ }
126
+ return serializeResult(id, {
127
+ content: [
128
+ {
129
+ type: 'text',
130
+ text: JSON.stringify(result),
131
+ },
132
+ ],
133
+ });
134
+ }
135
+
136
+ return serializeError(id, -32601, `Method not found: ${method}`);
137
+ },
138
+ };
139
+ }
140
+
141
+ function normalizeId(id: unknown): JsonRpcId {
142
+ if (typeof id === 'number' || typeof id === 'string' || id === null) {
143
+ return id;
144
+ }
145
+ return null;
146
+ }
147
+
148
+ function serializeResult(id: JsonRpcId, result: object): string {
149
+ return JSON.stringify({ jsonrpc: '2.0', id, result });
150
+ }
151
+
152
+ function serializeError(id: JsonRpcId, code: number, message: string): string {
153
+ return JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } });
154
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * AF_UNIX MCP listener — parent half of the in-process transport.
3
+ *
4
+ * Per docs/design.mcp.md §2.1, the launcher binds + listens BEFORE
5
+ * spawning claude, so the MCP subprocess (which claude spawns from the
6
+ * injected --mcp-config) can dial back via $FNC_SOCKET on every tool
7
+ * call. The accept loop runs `onConnection` per incoming dial; the
8
+ * caller (parent-side dispatch in §7.7) wires per-connection
9
+ * read-one-request / write-one-response logic onto each socket.
10
+ *
11
+ * Today the listener is generic: it doesn't parse requests itself. That
12
+ * lets §7.2 ship in isolation — wire-format + JSON-RPC scaffolding land
13
+ * in §7.3 and §7.6.
14
+ *
15
+ * Cleanup contract (matches Go canonical and design.mcp.md §7):
16
+ * - Best-effort unlink of any stale socket file before bind. Covers
17
+ * unclean shutdowns from a prior PID-colliding fnclaude.
18
+ * - On stop(), close the listener and best-effort unlink the file.
19
+ * Bun.listen.stop() already removes the file on Linux, but the
20
+ * explicit unlink mirrors the design's defense-in-depth posture.
21
+ * - stop() is idempotent — calling it twice is a no-op the second
22
+ * time (the listener is null after first stop).
23
+ *
24
+ * Cross-platform: throws NotImplementedYet on win32. AF_UNIX over
25
+ * Bun.listen({ unix }) is Unix-only today; the Windows named-pipe path
26
+ * is a sibling §7 follow-up. Callers (main.ts) skip listener startup
27
+ * entirely on win32 so the launcher still works without self-MCP.
28
+ */
29
+
30
+ import { unlink } from 'node:fs/promises';
31
+ import type { Socket, SocketHandler } from 'bun';
32
+
33
+ /**
34
+ * Per-connection socket exposed to onConnection. The `handlers` field is
35
+ * a writable wrapper around the static Bun socket handlers so per-call
36
+ * code can install its own data() / close() / error() implementations
37
+ * without each implementation having to be re-registered as a separate
38
+ * Bun.listen call. The dispatch layer (§7.7) overrides these to drive
39
+ * the newline-delimited JSON protocol.
40
+ */
41
+ export interface AcceptedSocket {
42
+ socket: Socket<undefined>;
43
+ handlers: {
44
+ data?: (socket: Socket<undefined>, data: Buffer) => void;
45
+ close?: (socket: Socket<undefined>) => void;
46
+ error?: (socket: Socket<undefined>, error: Error) => void;
47
+ };
48
+ }
49
+
50
+ export interface StartMcpListenerArgs {
51
+ socketPath: string;
52
+ onConnection: (accepted: AcceptedSocket) => void;
53
+ }
54
+
55
+ export interface McpListener {
56
+ socketPath: string;
57
+ stop: () => Promise<void>;
58
+ }
59
+
60
+ export async function startMcpListener(args: StartMcpListenerArgs): Promise<McpListener> {
61
+ if (process.platform === 'win32') {
62
+ throw new Error(
63
+ 'startMcpListener: win32 not yet supported (AF_UNIX path only); see build-plan §7 follow-up',
64
+ );
65
+ }
66
+
67
+ // Best-effort unlink of a stale socket file from a prior crashed run.
68
+ // ENOENT is the normal "no leftover" case — swallow it; anything else
69
+ // is also swallowed because bind() will surface a clearer error in a
70
+ // moment (EADDRINUSE if the file's still there as a real bound socket,
71
+ // EACCES if perms are wrong, etc.).
72
+ try {
73
+ await unlink(args.socketPath);
74
+ } catch {
75
+ // intentionally ignored
76
+ }
77
+
78
+ // Per-connection routing table. Bun.listen takes a single static
79
+ // handler block; we route per-socket via a WeakMap keyed on the
80
+ // Socket object so each connection's onConnection callback can hang
81
+ // its own handlers without affecting siblings.
82
+ const perSocketHandlers = new WeakMap<Socket<undefined>, AcceptedSocket['handlers']>();
83
+
84
+ const handler: SocketHandler<undefined> = {
85
+ open(socket) {
86
+ const handlers: AcceptedSocket['handlers'] = {};
87
+ perSocketHandlers.set(socket, handlers);
88
+ args.onConnection({ socket, handlers });
89
+ },
90
+ data(socket, data) {
91
+ const h = perSocketHandlers.get(socket);
92
+ h?.data?.(socket, data);
93
+ },
94
+ close(socket) {
95
+ const h = perSocketHandlers.get(socket);
96
+ h?.close?.(socket);
97
+ perSocketHandlers.delete(socket);
98
+ },
99
+ error(socket, error) {
100
+ const h = perSocketHandlers.get(socket);
101
+ h?.error?.(socket, error);
102
+ },
103
+ };
104
+
105
+ // Bun.listen throws synchronously on bind failure (verified empirically
106
+ // against Bun 1.3.14 — ENOENT for bad parent dir, EADDRINUSE for
107
+ // already-bound). Wrap in try/catch + reject so the contract matches a
108
+ // promise-returning startup; main.ts treats rejection as fatal.
109
+ let bunListener;
110
+ try {
111
+ bunListener = Bun.listen<undefined>({
112
+ unix: args.socketPath,
113
+ socket: handler,
114
+ });
115
+ } catch (err) {
116
+ throw new Error(
117
+ `startMcpListener: failed to bind ${args.socketPath}: ${(err as Error).message}`,
118
+ { cause: err },
119
+ );
120
+ }
121
+
122
+ let stopped = false;
123
+ const stop = async (): Promise<void> => {
124
+ if (stopped) return;
125
+ stopped = true;
126
+ bunListener.stop();
127
+ // Defense-in-depth unlink. Bun.listen.stop() already removes the
128
+ // file on Linux, but design.mcp.md §7 calls for an explicit unlink
129
+ // so platforms that don't auto-clean still get covered.
130
+ try {
131
+ await unlink(args.socketPath);
132
+ } catch {
133
+ // already gone — expected on Linux/macOS
134
+ }
135
+ };
136
+
137
+ return {
138
+ socketPath: args.socketPath,
139
+ stop,
140
+ };
141
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * §7.7 — Per-tool dispatch on the parent side of the MCP socket.
3
+ *
4
+ * `createParentDispatcher` returns the `onConnection` callback that
5
+ * `startMcpListener` (§7.2) invokes for every accepted AF_UNIX dial. For
6
+ * each connection it:
7
+ *
8
+ * 1. Buffers inbound bytes until the first '\n'.
9
+ * 2. Parses the line as a WireRequest (§7.6 — newline-delimited JSON,
10
+ * one request per connection).
11
+ * 3. Routes by the `op` field to one of the four per-tool handlers.
12
+ * 4. Awaits the handler's WireResponse, writes it back as one
13
+ * newline-delimited JSON line, then closes the socket.
14
+ *
15
+ * Concurrency: each accepted connection drives its own handler chain —
16
+ * the listener's data() pump is itself per-socket, so a slow handler on
17
+ * one connection can't block sibling dispatches. We deliberately do not
18
+ * await handler completion in the listener thread (the listener doesn't
19
+ * have a "thread" — Bun.listen calls our data() then returns
20
+ * immediately); the handler chain runs as a floating Promise that ends
21
+ * by writing + closing on its own socket.
22
+ *
23
+ * Stub handlers from §8.1–§8.5 fill in the real per-tool behavior. Until
24
+ * then, default stubs return a placeholder `{ action: 'done',
25
+ * message: '§8.X not yet implemented' }` so the wire still round-trips.
26
+ *
27
+ * Design: docs/design.mcp.md §2.3, §3.
28
+ */
29
+
30
+ import type { AcceptedSocket } from './listener.ts';
31
+ import type { WireOp, WireRequest, WireResponse } from './wire.ts';
32
+
33
+ export type ParentDispatchHandler = (req: WireRequest) => Promise<WireResponse>;
34
+
35
+ export type ParentDispatchHandlers = Record<WireOp, ParentDispatchHandler>;
36
+
37
+ export interface CreateParentDispatcherArgs {
38
+ handlers: ParentDispatchHandlers;
39
+ }
40
+
41
+ /**
42
+ * Default per-tool stub handlers — the wire round-trips cleanly so the
43
+ * full §7 chain can be exercised before §8 lands the real implementations.
44
+ * Each returns `action: 'done'` plus a marker `message` that names the
45
+ * §8.x slot that will replace it.
46
+ */
47
+ export const stubParentHandlers: ParentDispatchHandlers = {
48
+ restart: async (_req) => ({ action: 'done', message: '§8.1 fnc_restart not yet implemented' }),
49
+ switch: async (_req) => ({ action: 'done', message: '§8.2 fnc_switch_project not yet implemented' }),
50
+ spawn: async (_req) => ({ action: 'done', message: '§8.3 fnc_spawn_session not yet implemented' }),
51
+ copy_to_clipboard: async (_req) => ({
52
+ action: 'done',
53
+ message: '§8.4 fnc_copy_to_clipboard not yet implemented',
54
+ }),
55
+ };
56
+
57
+ const KNOWN_OPS = new Set<WireOp>(['restart', 'switch', 'spawn', 'copy_to_clipboard']);
58
+
59
+ function isWireOp(value: unknown): value is WireOp {
60
+ return typeof value === 'string' && KNOWN_OPS.has(value as WireOp);
61
+ }
62
+
63
+ /**
64
+ * Build the `onConnection` callback shape that `startMcpListener` expects.
65
+ * The returned function is invoked once per accepted dial; the listener
66
+ * passes both the underlying `Socket` and the per-connection `handlers`
67
+ * slot, which we populate with our data/close/error implementations.
68
+ */
69
+ export function createParentDispatcher(args: CreateParentDispatcherArgs): (accepted: AcceptedSocket) => void {
70
+ return (accepted) => {
71
+ let buffered = '';
72
+ let consumed = false;
73
+
74
+ const reply = async (response: WireResponse): Promise<void> => {
75
+ // Wire-protocol contract: write exactly one ndjson line, then close.
76
+ // Wrap write/end in try/catch — the peer may already have closed the
77
+ // half-duplex link by the time we finish the handler.
78
+ try {
79
+ accepted.socket.write(JSON.stringify(response) + '\n');
80
+ } catch {
81
+ // ignore — socket may be gone
82
+ }
83
+ try {
84
+ accepted.socket.end();
85
+ } catch {
86
+ // ignore
87
+ }
88
+ };
89
+
90
+ const errorReply = (err: string): Promise<void> => reply({ action: 'error', error: err });
91
+
92
+ accepted.handlers.data = (_socket, chunk) => {
93
+ if (consumed) {
94
+ // Per design.mcp.md §3 we read exactly one request per connection.
95
+ // Discard any pipelined bytes — subprocess never sends two on one dial.
96
+ return;
97
+ }
98
+ buffered += chunk.toString('utf8');
99
+ const nl = buffered.indexOf('\n');
100
+ if (nl === -1) return;
101
+ consumed = true;
102
+ const line = buffered.slice(0, nl);
103
+
104
+ // Float the handler chain — listener doesn't await us, and we don't
105
+ // need to block sibling sockets on this connection's handler latency.
106
+ void dispatchOne(line, args.handlers, reply, errorReply);
107
+ };
108
+
109
+ accepted.handlers.error = (_socket, _err) => {
110
+ // Nothing useful to do here — the subprocess errored its half of
111
+ // the connection. We've either already responded (in which case our
112
+ // .end() raced) or we're past the point of writing back anyway.
113
+ // §8 may want to log; for now stay quiet.
114
+ };
115
+
116
+ accepted.handlers.close = (_socket) => {
117
+ // Peer closed before we finished. Nothing to clean up — the
118
+ // dispatcher's state is per-call and goes out of scope on close.
119
+ };
120
+ };
121
+ }
122
+
123
+ async function dispatchOne(
124
+ line: string,
125
+ handlers: ParentDispatchHandlers,
126
+ reply: (r: WireResponse) => Promise<void>,
127
+ errorReply: (msg: string) => Promise<void>,
128
+ ): Promise<void> {
129
+ let request: unknown;
130
+ try {
131
+ request = JSON.parse(line);
132
+ } catch (err) {
133
+ await errorReply(`malformed request: ${(err as Error).message}`);
134
+ return;
135
+ }
136
+ if (request === null || typeof request !== 'object' || Array.isArray(request)) {
137
+ await errorReply('malformed request: expected JSON object');
138
+ return;
139
+ }
140
+ const op = (request as { op?: unknown }).op;
141
+ if (!isWireOp(op)) {
142
+ await errorReply(`unknown op: ${typeof op === 'string' ? op : '<missing>'}`);
143
+ return;
144
+ }
145
+ const handler = handlers[op];
146
+ let response: WireResponse;
147
+ try {
148
+ response = await handler(request as WireRequest);
149
+ } catch (err) {
150
+ await errorReply(`handler error: ${(err as Error).message}`);
151
+ return;
152
+ }
153
+ await reply(response);
154
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * AF_UNIX socket path computation for the parent fnclaude MCP listener.
3
+ *
4
+ * Pure function — takes the env map and a pid, returns the resolved path.
5
+ * Used by §7.2 to bind/listen, and by §6.1's env composition to set
6
+ * FNC_SOCKET on the child claude.
7
+ *
8
+ * Path formula (per design.md §14, design.mcp.md §1–2):
9
+ *
10
+ * <base>/fnclaude-mcp-<pid>.sock
11
+ *
12
+ * Base directory preference (highest precedence first):
13
+ * 1. $XDG_RUNTIME_DIR — Linux/systemd tmpfs, mode 700, cleared on logout
14
+ * 2. $TMPDIR — honored on Unix when XDG isn't set
15
+ * 3. /tmp — final Unix fallback
16
+ *
17
+ * Empty-string env vars are treated as unset (matches Go canonical's
18
+ * `os.Getenv("X") != ""` check at src/handoff.go:55–82).
19
+ *
20
+ * Windows: throws. The Windows path (named pipe) is a §7 follow-up; for
21
+ * now the design.mcp.md §1 invariant — AF_UNIX with a PID-suffixed file
22
+ * under XDG_RUNTIME_DIR/TMPDIR — is Unix-only, so failing loudly beats
23
+ * returning a path the listener can't bind to.
24
+ */
25
+
26
+ export interface ComputeSocketPathArgs {
27
+ env: Record<string, string | undefined>;
28
+ pid: number;
29
+ platform: NodeJS.Platform;
30
+ }
31
+
32
+ export function computeSocketPath(args: ComputeSocketPathArgs): string {
33
+ if (args.platform === 'win32') {
34
+ throw new Error(
35
+ 'computeSocketPath: win32 not yet supported (AF_UNIX path only); see build-plan §7 follow-up',
36
+ );
37
+ }
38
+ const base = resolveBaseDir(args.env);
39
+ return `${base}/fnclaude-mcp-${args.pid}.sock`;
40
+ }
41
+
42
+ function resolveBaseDir(env: Record<string, string | undefined>): string {
43
+ const xdg = env.XDG_RUNTIME_DIR;
44
+ if (xdg !== undefined && xdg !== '') return xdg;
45
+ const tmp = env.TMPDIR;
46
+ if (tmp !== undefined && tmp !== '') return tmp;
47
+ return '/tmp';
48
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * §7.6 — Newline-delimited JSON wire format for the AF_UNIX MCP socket.
3
+ *
4
+ * One request → one response per connection. Subprocess dials, writes
5
+ * one JSON line, reads one JSON line, closes. Connections are not
6
+ * reused (per design.mcp.md §3). Each tool call gets a fresh dial.
7
+ *
8
+ * Timeouts (per design.mcp.md §3.3):
9
+ * - 10s dial timeout: connect() must complete in 10s
10
+ * - 10s per-call deadline: covers the open-connection write+read window
11
+ *
12
+ * On either timeout, the dial/call is rejected. Callers (the four tool
13
+ * handlers in §8) surface the rejection to claude as a tool-level error
14
+ * — i.e. a successful MCP response containing an error-shaped payload,
15
+ * NOT a JSON-RPC protocol error.
16
+ *
17
+ * Types are intentionally permissive: the wire shape is defined by the
18
+ * parent server in §7.7, so the subprocess only needs to carry fields
19
+ * through. WireRequest's `op` field is the one we constrain.
20
+ */
21
+
22
+ export type WireOp = 'restart' | 'switch' | 'spawn' | 'copy_to_clipboard';
23
+
24
+ export interface WireRequest {
25
+ op: WireOp;
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ export interface WireResponse {
30
+ ok?: boolean;
31
+ action?: 'done' | 'paste_flow' | 'error' | string;
32
+ message?: string;
33
+ command?: string;
34
+ clipboard_ok?: boolean;
35
+ error?: string;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ export interface DialAndCallArgs {
40
+ socketPath: string;
41
+ request: WireRequest;
42
+ /** Connect timeout in milliseconds. Default 10s per design.mcp.md §3.3. */
43
+ dialTimeoutMs?: number;
44
+ /** Write+read deadline once the socket is open. Default 10s. */
45
+ callTimeoutMs?: number;
46
+ }
47
+
48
+ const DEFAULT_DIAL_TIMEOUT_MS = 10_000;
49
+ const DEFAULT_CALL_TIMEOUT_MS = 10_000;
50
+
51
+ /**
52
+ * Dial the AF_UNIX socket at `socketPath`, write `request` as one JSON
53
+ * line, read back one JSON line, close. Rejects on dial timeout, call
54
+ * timeout, malformed JSON in the response, or any underlying socket
55
+ * error (ECONNREFUSED, ENOENT, EPIPE, etc).
56
+ *
57
+ * The connection lifetime is bounded by the call timeout — even an
58
+ * actively-writing server can't keep us pinned past callTimeoutMs.
59
+ */
60
+ export async function dialAndCall(args: DialAndCallArgs): Promise<WireResponse> {
61
+ const dialTimeoutMs = args.dialTimeoutMs ?? DEFAULT_DIAL_TIMEOUT_MS;
62
+ const callTimeoutMs = args.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
63
+ const payload = JSON.stringify(args.request) + '\n';
64
+
65
+ return new Promise<WireResponse>((resolve, reject) => {
66
+ let settled = false;
67
+ let dialTimer: ReturnType<typeof setTimeout> | undefined;
68
+ let callTimer: ReturnType<typeof setTimeout> | undefined;
69
+ let buffered = '';
70
+ // The Bun.connect Promise resolves with a Socket once the OS-level
71
+ // connect completes; we keep a handle here so the timeout branches
72
+ // can close it.
73
+ let socketHandle: { end: () => unknown } | undefined;
74
+
75
+ const cleanup = () => {
76
+ if (dialTimer !== undefined) clearTimeout(dialTimer);
77
+ if (callTimer !== undefined) clearTimeout(callTimer);
78
+ try {
79
+ socketHandle?.end();
80
+ } catch {
81
+ // ignore — socket may already be closed
82
+ }
83
+ };
84
+
85
+ const settleResolve = (value: WireResponse) => {
86
+ if (settled) return;
87
+ settled = true;
88
+ cleanup();
89
+ resolve(value);
90
+ };
91
+
92
+ const settleReject = (err: Error) => {
93
+ if (settled) return;
94
+ settled = true;
95
+ cleanup();
96
+ reject(err);
97
+ };
98
+
99
+ dialTimer = setTimeout(() => {
100
+ settleReject(
101
+ new Error(
102
+ `dialAndCall: dial timeout after ${dialTimeoutMs}ms connecting to ${args.socketPath}`,
103
+ ),
104
+ );
105
+ }, dialTimeoutMs);
106
+
107
+ Bun.connect({
108
+ unix: args.socketPath,
109
+ socket: {
110
+ open(socket) {
111
+ if (settled) {
112
+ socket.end();
113
+ return;
114
+ }
115
+ if (dialTimer !== undefined) clearTimeout(dialTimer);
116
+ dialTimer = undefined;
117
+ socketHandle = socket;
118
+ callTimer = setTimeout(() => {
119
+ settleReject(
120
+ new Error(
121
+ `dialAndCall: call timeout after ${callTimeoutMs}ms (socket ${args.socketPath})`,
122
+ ),
123
+ );
124
+ }, callTimeoutMs);
125
+ socket.write(payload);
126
+ },
127
+ data(socket, chunk) {
128
+ buffered += chunk.toString('utf8');
129
+ const nl = buffered.indexOf('\n');
130
+ if (nl === -1) return;
131
+ const line = buffered.slice(0, nl);
132
+ let parsed: WireResponse;
133
+ try {
134
+ parsed = JSON.parse(line) as WireResponse;
135
+ } catch (err) {
136
+ settleReject(
137
+ new Error(
138
+ `dialAndCall: malformed JSON response: ${(err as Error).message}`,
139
+ ),
140
+ );
141
+ return;
142
+ }
143
+ // Resolve BEFORE closing — Bun fires `close` synchronously
144
+ // from `end()`, which would otherwise race the settle flag.
145
+ settleResolve(parsed);
146
+ try {
147
+ socket.end();
148
+ } catch {
149
+ // ignore
150
+ }
151
+ },
152
+ error(_socket, err) {
153
+ settleReject(
154
+ new Error(
155
+ `dialAndCall: socket error (${args.socketPath}): ${(err as Error).message ?? err}`,
156
+ ),
157
+ );
158
+ },
159
+ close(_socket) {
160
+ if (!settled) {
161
+ settleReject(
162
+ new Error(
163
+ `dialAndCall: connection closed before response (${args.socketPath})`,
164
+ ),
165
+ );
166
+ }
167
+ },
168
+ },
169
+ }).catch((err: unknown) => {
170
+ // Bun.connect's returned Promise rejects on synchronous connect
171
+ // failures (ENOENT for a missing socket file, ECONNREFUSED if no
172
+ // listener, etc.). The `error` handler above covers post-open
173
+ // breaks; this catches the rest.
174
+ settleReject(
175
+ new Error(
176
+ `dialAndCall: connect failed (${args.socketPath}): ${(err as Error).message ?? err}`,
177
+ ),
178
+ );
179
+ });
180
+ });
181
+ }