@fnclaude/cli 1.1.1 → 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.
- package/bin/fnc.js +34 -79
- package/package.json +6 -9
- package/share/fnclaude/templates/handoff.template.md +11 -0
- package/src/argv/classify.ts +48 -0
- package/src/argv/expand.ts +51 -0
- package/src/argv/intake.ts +52 -0
- package/src/argv/magic.ts +103 -0
- package/src/argv/parse.ts +213 -0
- package/src/argv/preserve-args.ts +333 -0
- package/src/argv/sentinel.ts +41 -0
- package/src/argv/short-flags.ts +152 -0
- package/src/config/load.ts +116 -0
- package/src/handoff/awaiter.ts +140 -0
- package/src/handoff/clean-env.ts +45 -0
- package/src/handoff/kill-and-exec.ts +110 -0
- package/src/handoff/spawn-launcher.ts +185 -0
- package/src/handoff/summary-file.ts +86 -0
- package/src/handoff/trigger.ts +90 -0
- package/src/help-version.ts +151 -0
- package/src/launch/compose-env.ts +34 -0
- package/src/launch/cross-cwd-parse.ts +69 -0
- package/src/launch/cross-cwd-relaunch.ts +95 -0
- package/src/launch/find-claude.ts +52 -0
- package/src/launch/live-permission-reader.ts +133 -0
- package/src/launch/ring-buffer.ts +92 -0
- package/src/main.ts +580 -437
- package/src/mcp/dispatch.ts +240 -0
- package/src/mcp/handlers/clipboard-backends.ts +176 -0
- package/src/mcp/handlers/clipboard.ts +62 -0
- package/src/mcp/handlers/restart.ts +156 -0
- package/src/mcp/handlers/spawn.ts +219 -0
- package/src/mcp/handlers/switch.ts +272 -0
- package/src/mcp/inject-config.ts +59 -0
- package/src/mcp/jsonrpc-server.ts +154 -0
- package/src/mcp/listener.ts +141 -0
- package/src/mcp/parent-dispatch.ts +154 -0
- package/src/mcp/socket-path.ts +48 -0
- package/src/mcp/wire.ts +181 -0
- package/src/name/auto-name.ts +162 -0
- package/src/name/llm-prompt.ts +14 -0
- package/src/name/sanitize.ts +57 -0
- package/src/name/sdk-llm.ts +42 -0
- package/src/noop/seed.ts +63 -0
- package/src/noop/template-source.ts +62 -0
- package/src/path/ensure-cwd.ts +95 -0
- package/src/path/resolve.ts +58 -0
- package/src/prompts/dir.ts +61 -0
- package/src/prompts/load.ts +100 -0
- package/src/prompts/select.ts +43 -0
- package/src/repo/clone-exec.ts +37 -0
- package/src/repo/clone.ts +45 -0
- package/src/repo/gh-runner.ts +68 -0
- package/src/repo/host-aliases.ts +58 -0
- package/src/repo/owner-lookup.ts +71 -0
- package/src/repo/ref.ts +146 -0
- package/src/repo/repo-settings.ts +99 -0
- package/src/repo/resolve-input.ts +179 -0
- package/src/repo/template.ts +92 -0
- package/src/warnings/buffer.ts +39 -0
- package/src/worktree/auto-tmux.ts +45 -0
- package/src/worktree/git-list.ts +73 -0
- package/src/worktree/intercept.ts +150 -0
- package/bin/preflight.js +0 -66
- package/prompts/agent-pitfall.md +0 -1
- package/prompts/noop-router.md +0 -186
- package/prompts/project-switch.md +0 -64
- package/prompts/restart.md +0 -50
- package/prompts/spawn.md +0 -62
- package/src/argParser.ts +0 -367
- package/src/args/preserve.ts +0 -338
- package/src/args.ts +0 -239
- package/src/argv.ts +0 -219
- package/src/autoname.ts +0 -273
- package/src/clipboard.ts +0 -149
- package/src/config.ts +0 -369
- package/src/errors.ts +0 -13
- package/src/handoff.ts +0 -108
- package/src/help.ts +0 -139
- package/src/hostAliases.ts +0 -139
- package/src/index.ts +0 -120
- package/src/mcp/client.ts +0 -645
- package/src/mcp/protocol.ts +0 -445
- package/src/mcp/socketListener.ts +0 -540
- package/src/noop.ts +0 -106
- package/src/passthrough.ts +0 -36
- package/src/paths.ts +0 -55
- package/src/prompts.ts +0 -279
- package/src/pty/unix.ts +0 -429
- package/src/pty/windows.ts +0 -125
- package/src/pty.ts +0 -380
- package/src/repoRef.ts +0 -158
- package/src/repoSettings.ts +0 -144
- package/src/resolver.ts +0 -519
- package/src/sanitize.ts +0 -120
- package/src/sessionState.ts +0 -220
- package/src/silentRelaunch.ts +0 -178
- package/src/spawn.ts +0 -163
- package/src/template.ts +0 -44
- package/src/warnings.ts +0 -34
- 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
|
+
}
|
package/src/mcp/wire.ts
ADDED
|
@@ -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
|
+
}
|