@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
package/src/mcp/client.ts DELETED
@@ -1,645 +0,0 @@
1
- /**
2
- * MCP server subprocess — invoked by Claude Code as `fnclaude mcp [--noop]`
3
- * via `--mcp-config`. Reads JSON-RPC 2.0 from stdin and writes responses
4
- * to stdout. When a tool (fnc_restart / fnc_switch_project /
5
- * fnc_spawn_session / fnc_copy_to_clipboard) is invoked, dials the parent's
6
- * AF_UNIX socket (path in $FNC_SOCKET), sends a Request (mcp/protocol.ts),
7
- * reads Response, relays the outcome back to Claude as a text content item.
8
- *
9
- * Ported from src/mcp.go in the Go reference (fnclaude@fnrhombus).
10
- */
11
-
12
- import { Buffer } from 'node:buffer';
13
- import { connect, type Socket } from 'node:net';
14
- import type { Readable, Writable } from 'node:stream';
15
- import { errorMessage } from '../errors.js';
16
- import {
17
- encodeRequest,
18
- readResponse,
19
- type CopyRequest,
20
- type Request,
21
- type Response,
22
- type RestartRequest,
23
- type SpawnRequest,
24
- type SwitchRequest,
25
- } from './protocol.js';
26
- import pkg from '../../package.json' with { type: 'json' };
27
-
28
- // ── version (read from package.json at build time) ──────────────────────────
29
-
30
- /**
31
- * Binary version surfaced via the `initialize` response's serverInfo.
32
- * Read from package.json at build time, inlined by the TypeScript compiler
33
- * and bundler.
34
- */
35
- export const MCP_SERVER_VERSION = pkg.version;
36
-
37
- // ── Session ID validation ─────────────────────────────────────────────────
38
-
39
- const SESSION_ID_PATTERN =
40
- /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
41
-
42
- // ── JSON-RPC 2.0 types ────────────────────────────────────────────────────
43
-
44
- interface JSONRPCRequest {
45
- jsonrpc: string;
46
- id?: unknown; // null / number / string; absent for notifications
47
- method: string;
48
- params?: unknown;
49
- }
50
-
51
- interface JSONRPCErrorObject {
52
- code: number;
53
- message: string;
54
- }
55
-
56
- interface JSONRPCResponse {
57
- jsonrpc: '2.0';
58
- id: unknown;
59
- result?: unknown;
60
- error?: JSONRPCErrorObject;
61
- }
62
-
63
- const CODE_PARSE_ERROR = -32700;
64
- const CODE_METHOD_NOT_FOUND = -32601;
65
- const CODE_INVALID_PARAMS = -32602;
66
-
67
- // ── MCP protocol types ────────────────────────────────────────────────────
68
-
69
- interface MCPSchemaProperty {
70
- type: string;
71
- description: string;
72
- }
73
-
74
- interface MCPSchema {
75
- type: string;
76
- properties?: Record<string, MCPSchemaProperty>;
77
- required?: string[];
78
- }
79
-
80
- interface MCPTool {
81
- name: string;
82
- description: string;
83
- inputSchema: MCPSchema;
84
- }
85
-
86
- interface MCPContent {
87
- type: 'text';
88
- text: string;
89
- }
90
-
91
- interface MCPCallToolResult {
92
- content: MCPContent[];
93
- isError?: boolean;
94
- }
95
-
96
- interface MCPCallToolParams {
97
- name: string;
98
- arguments?: Record<string, unknown>;
99
- }
100
-
101
- // ── socket dial seam (injectable for tests) ──────────────────────────────
102
-
103
- /**
104
- * Dial the parent fnclaude's AF_UNIX socket at `socketPath`, send `req`,
105
- * and return the Response. Each invocation opens a fresh connection
106
- * (one-request-per-conn per the wire protocol). Throws on dial error /
107
- * write error / timeout / EOF without a response.
108
- */
109
- export type DialFn = (socketPath: string, req: Request) => Promise<Response>;
110
-
111
- const DEFAULT_DIAL_TIMEOUT_MS = 10_000;
112
-
113
- export const defaultDial: DialFn = async (socketPath, req) => {
114
- const sock: Socket = connect(socketPath);
115
-
116
- // Attach an error catcher up front so an early ECONNREFUSED doesn't
117
- // crash the process via 'error' before we await.
118
- let earlyErr: Error | undefined;
119
- sock.on('error', (e) => {
120
- if (!earlyErr) earlyErr = e;
121
- });
122
-
123
- try {
124
- await new Promise<void>((resolve, reject) => {
125
- const onConnect = (): void => {
126
- sock.off('error', onError);
127
- resolve();
128
- };
129
- const onError = (err: Error): void => {
130
- sock.off('connect', onConnect);
131
- reject(err);
132
- };
133
- const onTimeout = (): void => {
134
- sock.off('connect', onConnect);
135
- sock.off('error', onError);
136
- reject(new Error(`dial timeout after ${DEFAULT_DIAL_TIMEOUT_MS}ms`));
137
- };
138
- sock.once('connect', onConnect);
139
- sock.once('error', onError);
140
- // setTimeout idle timeout — bound the whole exchange.
141
- sock.setTimeout(DEFAULT_DIAL_TIMEOUT_MS, onTimeout);
142
- if (earlyErr) {
143
- sock.off('connect', onConnect);
144
- reject(earlyErr);
145
- }
146
- });
147
-
148
- sock.write(encodeRequest(req));
149
- const resp = await readResponse(sock);
150
- if (resp === undefined) {
151
- throw new Error('read response: EOF before any line');
152
- }
153
- return resp;
154
- } finally {
155
- sock.destroy();
156
- }
157
- };
158
-
159
- // ── Tool registration ─────────────────────────────────────────────────────
160
-
161
- const SOCKET_UNAVAILABLE_MSG =
162
- 'fnclaude socket unavailable; this MCP server was launched outside an fnclaude-managed session.';
163
-
164
- const toolRestart: MCPTool = {
165
- name: 'fnc_restart',
166
- description:
167
- "Restart the current fnclaude session in place, preserving conversation context. Use when the user asks to restart their session. fnclaude preserves the user's original startup flags (--ide, --brief, --allowedTools, etc.); the optional override args below let you change individual flags for the restarted session when the user requests it. Args: session_id (the current Claude session ID — read it from your shell env as $CLAUDE_CODE_SESSION_ID via Bash, since the env var isn't exposed to MCP tool input directly). Optional overrides: model, effort, permission_mode, allowed_tools, agent, brief, chrome, ide, verbose.",
168
- inputSchema: {
169
- type: 'object',
170
- properties: {
171
- session_id: {
172
- type: 'string',
173
- description:
174
- 'The current Claude session ID. Read the value of $CLAUDE_CODE_SESSION_ID from your shell env via Bash and pass it verbatim.',
175
- },
176
- model: { type: 'string', description: 'Optional. The model alias to use for the restarted session (e.g. opus, sonnet, haiku). --model is slash-command-mutable but has no env exposure; pass it only when the user explicitly requested a model change for this restart. Omit to preserve the startup --model (or its bare-magic equivalent).' },
177
- effort: { type: 'string', description: "Optional. The current in-session effort level. Read `$CLAUDE_EFFORT` via Bash before calling — claude updates this env var on `/effort` slash commands, and the assistant's Bash subprocess sees the live value. Pass it verbatim. Omit if unset; fnclaude will preserve the startup --effort if any." },
178
- permission_mode: { type: 'string', description: "Optional. Override the permission mode. fnclaude auto-captures the live mode from this session's JSONL log, so omit unless the user explicitly requested a change for this restart." },
179
- allowed_tools: { type: 'string', description: 'Optional. Override --allowedTools (immutable per session; preservation from startup is the only fallback).' },
180
- agent: { type: 'string', description: 'Optional. Override --agent (immutable per session).' },
181
- brief: { type: 'boolean', description: 'Optional. true → ensure --brief is on; false → off; omit → preserve startup.' },
182
- chrome: { type: 'boolean', description: 'Optional. true → ensure --chrome is on; false → off; omit → preserve startup.' },
183
- ide: { type: 'boolean', description: 'Optional. true → ensure --ide is on; false → off; omit → preserve startup.' },
184
- verbose: { type: 'boolean', description: 'Optional. true → ensure --verbose is on; false → off; omit → preserve startup.' },
185
- },
186
- required: ['session_id'],
187
- },
188
- };
189
-
190
- const toolSwitchProject: MCPTool = {
191
- name: 'fnc_switch_project',
192
- description:
193
- 'Switch this fnclaude session to a different project, carrying a continuity summary. Call as early in the turn as you recognize the user\'s intent belongs in another project — don\'t read/grep/test in this session as a pre-step, the destination session will do that with fresh context. Pre-investigating here wastes parent tokens and degrades the destination\'s research independence. ONE-SHOT: call once and the session is killed and re-launched at the destination. Because the call ends this session, print a brief cancellation-window line to the user (e.g. "Transferring in 3 seconds. Ctrl-C to cancel.") and run a Bash sleep BEFORE calling this tool; if the sleep completes uninterrupted, call once. fnclaude preserves the user\'s startup flags (minus a denylist of destination-bound ones like --add-dir, --mcp-config, --from-pr, --name, etc.); the optional override args below replace individual flags. Args: destination (verbatim user reference: a short repo name like \'arch-setup\', a name@owner like \'arch-setup@fnrhombus\', an owner/name like \'fnrhombus/arch-setup\', a URL, or an absolute path; a +workspace suffix is supported for worktrees), name (a 3-6 word kebab-case session topic, e.g. \'fix-auth-bug\'), summary (a /compact-style continuity summary that lets the receiving session pick up where this one left off — what the user asked for, decisions made, files touched, work in flight, open questions, user-specific observations), session_id (the current session UUID, read from $CLAUDE_CODE_SESSION_ID; used by fnclaude to auto-capture the live permission-mode from this session\'s JSONL log). Optional overrides: model, effort, permission_mode, allowed_tools, agent, brief, chrome, ide, verbose. Response.action will be done (transfer in flight), paste_flow (auto-handoff disabled — copy/paste the rendered command), or error.',
194
- inputSchema: {
195
- type: 'object',
196
- properties: {
197
- destination: { type: 'string', description: 'Verbatim user reference to the destination project.' },
198
- name: { type: 'string', description: 'A 3-6 word kebab-case session topic.' },
199
- summary: { type: 'string', description: 'A /compact-style continuity summary.' },
200
- session_id: { type: 'string', description: "Optional. The current Claude session ID (read $CLAUDE_CODE_SESSION_ID via Bash). Used by fnclaude to auto-capture live permission-mode from the session JSONL when no explicit override is set." },
201
- model: { type: 'string', description: 'Optional. Override --model. Slash-command-mutable but has no env exposure; pass only when the user explicitly requested a change. Omit to preserve startup --model.' },
202
- effort: { type: 'string', description: "Optional. The current in-session effort level. Read `$CLAUDE_EFFORT` via Bash before calling — claude updates this env var on `/effort` slash commands, and the assistant's Bash subprocess sees the live value. Pass it verbatim. Omit if unset; fnclaude will preserve the startup --effort if any." },
203
- permission_mode: { type: 'string', description: "Optional. Override the permission mode. fnclaude auto-captures the live mode from this session's JSONL log, so omit unless the user explicitly requested a change for this transfer." },
204
- allowed_tools: { type: 'string', description: 'Optional. Override --allowedTools.' },
205
- agent: { type: 'string', description: 'Optional. Override --agent.' },
206
- brief: { type: 'boolean', description: 'Optional. true → ensure --brief on; false → off; omit → preserve startup.' },
207
- chrome: { type: 'boolean', description: 'Optional. true → ensure --chrome on; false → off; omit → preserve startup.' },
208
- ide: { type: 'boolean', description: 'Optional. true → ensure --ide on; false → off; omit → preserve startup.' },
209
- verbose: { type: 'boolean', description: 'Optional. true → ensure --verbose on; false → off; omit → preserve startup.' },
210
- },
211
- required: ['destination', 'name', 'summary'],
212
- },
213
- };
214
-
215
- const toolSpawnSession: MCPTool = {
216
- name: 'fnc_spawn_session',
217
- description:
218
- "Spawn a sibling fnclaude session for a different project in a new terminal window, while leaving the CURRENT session running. Use when, in the middle of a task here, the user discovers an unrelated task in another project but doesn't want to abandon what's happening in this session. (Use fnc_switch_project instead when the current session should be replaced.) Call as early in the turn as you recognize the work belongs in another project — don't read/grep/test in this session as a pre-step, the sibling will do that with fresh context. Pre-investigating here wastes parent tokens and degrades the sibling's research independence. ONE-SHOT: call once; no countdown or cancellation window is needed — the current session keeps running regardless. Spawn is a fresh start — it does NOT preserve this session's startup flags; pass the optional override args when the user wants the sibling to start with explicit tooling choices. Args: destination (verbatim user reference: short repo name, name@owner, owner/name, URL, or absolute path; +workspace suffix supported), name (3-6 word kebab-case session topic for the new session, e.g. 'fix-css-bug'), summary (a /compact-style continuity summary for the new session — what the user wants done in that other project, with enough context to start cold). Optional overrides (applied to the sibling, not this session): model, effort, permission_mode, allowed_tools, agent, brief, chrome, ide, verbose. Response.action will be done (sibling launched), paste_flow (no launcher available — copy/paste the rendered command into a new terminal), or error.",
219
- inputSchema: {
220
- type: 'object',
221
- properties: {
222
- destination: { type: 'string', description: 'Verbatim user reference to the destination project for the sibling session.' },
223
- name: { type: 'string', description: 'A 3-6 word kebab-case session topic for the sibling session.' },
224
- summary: { type: 'string', description: "A /compact-style continuity summary scoped to the sibling session's task." },
225
- model: { type: 'string', description: 'Optional. --model for the sibling (e.g. opus, sonnet, haiku).' },
226
- effort: { type: 'string', description: 'Optional. --effort for the sibling (low, medium, high, xhigh, max). For the *current* session\'s live effort, read `$CLAUDE_EFFORT` via Bash.' },
227
- permission_mode: { type: 'string', description: 'Optional. --permission-mode for the sibling.' },
228
- allowed_tools: { type: 'string', description: 'Optional. --allowedTools for the sibling.' },
229
- agent: { type: 'string', description: 'Optional. --agent for the sibling.' },
230
- brief: { type: 'boolean', description: 'Optional. true → start sibling with --brief; false / omit → no --brief.' },
231
- chrome: { type: 'boolean', description: 'Optional. true → start sibling with --chrome.' },
232
- ide: { type: 'boolean', description: 'Optional. true → start sibling with --ide.' },
233
- verbose: { type: 'boolean', description: 'Optional. true → start sibling with --verbose.' },
234
- },
235
- required: ['destination', 'name', 'summary'],
236
- },
237
- };
238
-
239
- const toolCopyToClipboard: MCPTool = {
240
- name: 'fnc_copy_to_clipboard',
241
- description:
242
- 'Copy text to the user\'s clipboard. Args: text. Useful for paste-flow handoffs when auto-switching is disabled.',
243
- inputSchema: {
244
- type: 'object',
245
- properties: {
246
- text: { type: 'string', description: 'Text to copy to the clipboard.' },
247
- },
248
- required: ['text'],
249
- },
250
- };
251
-
252
- // ── MCPServer ─────────────────────────────────────────────────────────────
253
-
254
- export interface MCPServerOptions {
255
- /** When true, register fnc_switch_project + fnc_spawn_session + fnc_copy_to_clipboard. When false, register fnc_restart + fnc_switch_project + fnc_spawn_session. */
256
- noop: boolean;
257
- /** Stdin source for JSON-RPC requests. */
258
- stdin: Readable;
259
- /** Stdout sink for JSON-RPC responses. */
260
- stdout: Writable;
261
- /** Parent fnclaude's AF_UNIX socket path; empty string = no parent. */
262
- socketPath: string;
263
- /** Override the dial implementation — tests stub this. */
264
- dial?: DialFn;
265
- }
266
-
267
- /**
268
- * MCP server entry point. Reads newline-delimited JSON-RPC 2.0 messages
269
- * from stdin and writes responses to stdout. Returns 0 on clean stdin
270
- * EOF, non-zero on a protocol error that's worth aborting on.
271
- */
272
- export async function runMCPServer(opts: MCPServerOptions): Promise<number> {
273
- const dial = opts.dial ?? defaultDial;
274
- const reader = new LineReader(opts.stdin);
275
- // eslint-disable-next-line no-constant-condition
276
- while (true) {
277
- const line = await reader.readLine();
278
- if (line === undefined) return 0; // clean EOF
279
- try {
280
- await handleLine(opts, dial, line);
281
- } catch (err) {
282
- // Sending an error response is the right behavior; abort the loop
283
- // only if the write itself fails.
284
- try {
285
- sendError(opts.stdout, null, CODE_PARSE_ERROR, `parse error: ${errorMessage(err)}`);
286
- } catch {
287
- return 1;
288
- }
289
- }
290
- }
291
- }
292
-
293
- async function handleLine(
294
- opts: MCPServerOptions,
295
- dial: DialFn,
296
- line: string,
297
- ): Promise<void> {
298
- let req: JSONRPCRequest;
299
- try {
300
- req = JSON.parse(line) as JSONRPCRequest;
301
- } catch (err) {
302
- sendError(opts.stdout, null, CODE_PARSE_ERROR, `parse error: ${errorMessage(err)}`);
303
- return;
304
- }
305
-
306
- // Notifications (no id) are fire-and-forget.
307
- if (req.id === undefined) {
308
- // notifications/initialized is the standard handshake completion ack.
309
- // No state to flip in this lean port (Go tracks `initialized` but
310
- // never gates on it).
311
- return;
312
- }
313
-
314
- const method = req.method;
315
- switch (method) {
316
- case 'initialize':
317
- handleInitialize(opts.stdout, req);
318
- return;
319
- case 'tools/list':
320
- handleToolsList(opts, req);
321
- return;
322
- case 'tools/call':
323
- await handleToolsCall(opts, dial, req);
324
- return;
325
- default:
326
- sendError(opts.stdout, req.id, CODE_METHOD_NOT_FOUND, `method not found: ${JSON.stringify(method)}`);
327
- }
328
- }
329
-
330
- function handleInitialize(stdout: Writable, req: JSONRPCRequest): void {
331
- sendResult(stdout, req.id, {
332
- protocolVersion: '2024-11-05',
333
- capabilities: { tools: {} },
334
- serverInfo: { name: 'fnclaude', version: MCP_SERVER_VERSION },
335
- });
336
- }
337
-
338
- function handleToolsList(opts: MCPServerOptions, req: JSONRPCRequest): void {
339
- sendResult(opts.stdout, req.id, { tools: toolsFor(opts.noop) });
340
- }
341
-
342
- async function handleToolsCall(
343
- opts: MCPServerOptions,
344
- dial: DialFn,
345
- req: JSONRPCRequest,
346
- ): Promise<void> {
347
- let params: MCPCallToolParams;
348
- try {
349
- params = (req.params ?? {}) as MCPCallToolParams;
350
- } catch (err) {
351
- sendError(opts.stdout, req.id, CODE_INVALID_PARAMS, `invalid params: ${errorMessage(err)}`);
352
- return;
353
- }
354
-
355
- const args = (params.arguments ?? {}) as Record<string, unknown>;
356
- switch (params.name) {
357
- case 'fnc_restart':
358
- await callRestart(opts, dial, req.id, args);
359
- return;
360
- case 'fnc_switch_project':
361
- await callSwitch(opts, dial, req.id, args);
362
- return;
363
- case 'fnc_spawn_session':
364
- await callSpawn(opts, dial, req.id, args);
365
- return;
366
- case 'fnc_copy_to_clipboard':
367
- await callCopy(opts, dial, req.id, args);
368
- return;
369
- default:
370
- sendError(opts.stdout, req.id, CODE_METHOD_NOT_FOUND, `unknown tool: ${JSON.stringify(params.name)}`);
371
- }
372
- }
373
-
374
- // ── tool handlers ─────────────────────────────────────────────────────────
375
-
376
- function toolsFor(noop: boolean): MCPTool[] {
377
- if (noop) {
378
- return [toolSwitchProject, toolSpawnSession, toolCopyToClipboard];
379
- }
380
- // fnc_restart is always registered: Claude Code does not propagate
381
- // CLAUDE_CODE_SESSION_ID into MCP stdio subprocess envs (upstream
382
- // #24371 closed "not planned"), so any env-based gate would
383
- // permanently omit the tool. Session id flows through the model
384
- // instead — it reads $CLAUDE_CODE_SESSION_ID via Bash and passes it
385
- // as the fnc_restart session_id argument.
386
- return [toolRestart, toolSwitchProject, toolSpawnSession];
387
- }
388
-
389
- function readStringArg(args: Record<string, unknown>, key: string): string {
390
- const v = args[key];
391
- return typeof v === 'string' ? v : '';
392
- }
393
-
394
- function readBoolArg(args: Record<string, unknown>, key: string): boolean | undefined {
395
- const v = args[key];
396
- return typeof v === 'boolean' ? v : undefined;
397
- }
398
-
399
- async function callRestart(
400
- opts: MCPServerOptions,
401
- dial: DialFn,
402
- id: unknown,
403
- args: Record<string, unknown>,
404
- ): Promise<void> {
405
- if (!opts.socketPath) {
406
- sendToolError(opts.stdout, id, SOCKET_UNAVAILABLE_MSG);
407
- return;
408
- }
409
- const sid = readStringArg(args, 'session_id');
410
- if (!sid) {
411
- sendToolError(
412
- opts.stdout,
413
- id,
414
- 'fnc_restart requires session_id: read it from your shell env ($CLAUDE_CODE_SESSION_ID) via Bash and pass it as the session_id argument.',
415
- );
416
- return;
417
- }
418
- if (!SESSION_ID_PATTERN.test(sid)) {
419
- sendToolError(
420
- opts.stdout,
421
- id,
422
- `session_id ${JSON.stringify(sid)} is not a valid UUID; the value of $CLAUDE_CODE_SESSION_ID should match the 8-4-4-4-12 hex form.`,
423
- );
424
- return;
425
- }
426
- const req: RestartRequest = {
427
- op: 'restart',
428
- session_id: sid,
429
- model: readStringArg(args, 'model'),
430
- effort: readStringArg(args, 'effort'),
431
- permission_mode: readStringArg(args, 'permission_mode'),
432
- allowed_tools: readStringArg(args, 'allowed_tools'),
433
- agent: readStringArg(args, 'agent'),
434
- brief: readBoolArg(args, 'brief'),
435
- chrome: readBoolArg(args, 'chrome'),
436
- ide: readBoolArg(args, 'ide'),
437
- verbose: readBoolArg(args, 'verbose'),
438
- };
439
- await dialAndRelay(opts, dial, id, req);
440
- }
441
-
442
- async function callSwitch(
443
- opts: MCPServerOptions,
444
- dial: DialFn,
445
- id: unknown,
446
- args: Record<string, unknown>,
447
- ): Promise<void> {
448
- if (!opts.socketPath) {
449
- sendToolError(opts.stdout, id, SOCKET_UNAVAILABLE_MSG);
450
- return;
451
- }
452
- const req: SwitchRequest = {
453
- op: 'switch',
454
- destination: readStringArg(args, 'destination'),
455
- name: readStringArg(args, 'name'),
456
- summary: readStringArg(args, 'summary'),
457
- confirmed: readBoolArg(args, 'confirmed') === true,
458
- session_id: readStringArg(args, 'session_id'),
459
- model: readStringArg(args, 'model'),
460
- effort: readStringArg(args, 'effort'),
461
- permission_mode: readStringArg(args, 'permission_mode'),
462
- allowed_tools: readStringArg(args, 'allowed_tools'),
463
- agent: readStringArg(args, 'agent'),
464
- brief: readBoolArg(args, 'brief'),
465
- chrome: readBoolArg(args, 'chrome'),
466
- ide: readBoolArg(args, 'ide'),
467
- verbose: readBoolArg(args, 'verbose'),
468
- };
469
- await dialAndRelay(opts, dial, id, req);
470
- }
471
-
472
- async function callSpawn(
473
- opts: MCPServerOptions,
474
- dial: DialFn,
475
- id: unknown,
476
- args: Record<string, unknown>,
477
- ): Promise<void> {
478
- if (!opts.socketPath) {
479
- sendToolError(opts.stdout, id, SOCKET_UNAVAILABLE_MSG);
480
- return;
481
- }
482
- const req: SpawnRequest = {
483
- op: 'spawn',
484
- destination: readStringArg(args, 'destination'),
485
- name: readStringArg(args, 'name'),
486
- summary: readStringArg(args, 'summary'),
487
- confirmed: readBoolArg(args, 'confirmed') === true,
488
- model: readStringArg(args, 'model'),
489
- effort: readStringArg(args, 'effort'),
490
- permission_mode: readStringArg(args, 'permission_mode'),
491
- allowed_tools: readStringArg(args, 'allowed_tools'),
492
- agent: readStringArg(args, 'agent'),
493
- brief: readBoolArg(args, 'brief'),
494
- chrome: readBoolArg(args, 'chrome'),
495
- ide: readBoolArg(args, 'ide'),
496
- verbose: readBoolArg(args, 'verbose'),
497
- };
498
- await dialAndRelay(opts, dial, id, req);
499
- }
500
-
501
- async function callCopy(
502
- opts: MCPServerOptions,
503
- dial: DialFn,
504
- id: unknown,
505
- args: Record<string, unknown>,
506
- ): Promise<void> {
507
- if (!opts.socketPath) {
508
- sendToolError(opts.stdout, id, SOCKET_UNAVAILABLE_MSG);
509
- return;
510
- }
511
- const req: CopyRequest = {
512
- op: 'copy_to_clipboard',
513
- text: readStringArg(args, 'text'),
514
- };
515
- await dialAndRelay(opts, dial, id, req);
516
- }
517
-
518
- async function dialAndRelay(
519
- opts: MCPServerOptions,
520
- dial: DialFn,
521
- id: unknown,
522
- req: Request,
523
- ): Promise<void> {
524
- let resp: Response;
525
- try {
526
- resp = await dial(opts.socketPath, req);
527
- } catch (err) {
528
- sendToolError(opts.stdout, id, errorMessage(err));
529
- return;
530
- }
531
- sendToolResult(opts.stdout, id, resp);
532
- }
533
-
534
- // ── result / error helpers ────────────────────────────────────────────────
535
-
536
- function sendResult(stdout: Writable, id: unknown, result: unknown): void {
537
- writeResponse(stdout, { jsonrpc: '2.0', id, result });
538
- }
539
-
540
- function sendError(
541
- stdout: Writable,
542
- id: unknown,
543
- code: number,
544
- message: string,
545
- ): void {
546
- writeResponse(stdout, {
547
- jsonrpc: '2.0',
548
- id: id === undefined ? null : id,
549
- error: { code, message },
550
- });
551
- }
552
-
553
- /**
554
- * Send an MCP tool-level error result (isError=true, content with the
555
- * error message). Distinct from a JSON-RPC protocol error — the JSON-RPC
556
- * call succeeded but the tool operation failed.
557
- */
558
- function sendToolError(stdout: Writable, id: unknown, msg: string): void {
559
- sendResult(stdout, id, {
560
- isError: true,
561
- content: [{ type: 'text', text: msg }],
562
- } satisfies MCPCallToolResult);
563
- }
564
-
565
- /**
566
- * Marshal the Response as JSON and return it as a single text content
567
- * item. Claude reads JSON tool results fine; the Action + Message +
568
- * Command + ClipboardOK + CountdownSeconds + Error fields carry all the
569
- * UX guidance the prompt needs.
570
- */
571
- function sendToolResult(stdout: Writable, id: unknown, resp: Response): void {
572
- let text: string;
573
- try {
574
- text = JSON.stringify(resp);
575
- } catch (err) {
576
- sendToolError(stdout, id, `internal marshal error: ${errorMessage(err)}`);
577
- return;
578
- }
579
- sendResult(stdout, id, {
580
- content: [{ type: 'text', text }],
581
- } satisfies MCPCallToolResult);
582
- }
583
-
584
- function writeResponse(stdout: Writable, resp: JSONRPCResponse): void {
585
- stdout.write(`${JSON.stringify(resp)}\n`);
586
- }
587
-
588
- // ── stdin line reader ─────────────────────────────────────────────────────
589
-
590
- /**
591
- * Buffer reader for newline-delimited stdin. Holds a leftover Buffer
592
- * across `readLine` calls so the stream can deliver multiple lines per
593
- * chunk and partial lines across chunks.
594
- */
595
- class LineReader {
596
- private buf: Buffer = Buffer.alloc(0);
597
- private ended = false;
598
- private pending: ((line: string | undefined) => void) | undefined;
599
-
600
- constructor(private readonly stream: Readable) {
601
- stream.on('data', (chunk: Buffer) => {
602
- this.buf = Buffer.concat([this.buf, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
603
- this.tryDeliver();
604
- });
605
- stream.on('end', () => {
606
- this.ended = true;
607
- this.tryDeliver();
608
- });
609
- stream.on('close', () => {
610
- this.ended = true;
611
- this.tryDeliver();
612
- });
613
- }
614
-
615
- readLine(): Promise<string | undefined> {
616
- return new Promise<string | undefined>((resolve) => {
617
- this.pending = resolve;
618
- this.tryDeliver();
619
- });
620
- }
621
-
622
- private tryDeliver(): void {
623
- if (!this.pending) return;
624
- const nl = this.buf.indexOf(0x0a);
625
- if (nl >= 0) {
626
- const line = this.buf.subarray(0, nl + 1).toString('utf8');
627
- this.buf = this.buf.subarray(nl + 1);
628
- const cb = this.pending;
629
- this.pending = undefined;
630
- cb(line);
631
- return;
632
- }
633
- if (this.ended) {
634
- const cb = this.pending;
635
- this.pending = undefined;
636
- if (this.buf.length === 0) {
637
- cb(undefined);
638
- } else {
639
- const tail = this.buf.toString('utf8');
640
- this.buf = Buffer.alloc(0);
641
- cb(tail);
642
- }
643
- }
644
- }
645
- }