@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,240 @@
1
+ /**
2
+ * MCP subcommand dispatch. fnc's first arg of "mcp" routes to the embedded
3
+ * JSON-RPC subprocess (the one claude invokes via the injected
4
+ * --mcp-config; see docs/design.mcp.md §2).
5
+ *
6
+ * §2.7 contributed the routing wrapper. §7.5 (here) wires the entry point:
7
+ * - Read $FNC_SOCKET from env; fatal exit 2 if absent.
8
+ * - Build the four tool handlers, each of which dials the parent over
9
+ * the AF_UNIX socket using §7.6's dialAndCall.
10
+ * - Read line-delimited JSON-RPC requests from stdin until EOF, route
11
+ * `tools/call` requests to the appropriate handler, and write JSON
12
+ * responses to stdout. (Full JSON-RPC scaffolding — initialize,
13
+ * tools/list, notifications — lands in §7.3.)
14
+ *
15
+ * Matches Go canonical's dispatch shape (src/main.go:879-887): mcp
16
+ * subcommand recognized ONLY at argv[0], '--noop' is the sole flag that
17
+ * affects server behavior, anything else is ignored.
18
+ */
19
+
20
+ import { dialAndCall, type WireOp, type WireRequest, type WireResponse } from './wire.ts';
21
+
22
+ const SUBCOMMAND = 'mcp';
23
+ const NOOP_FLAG = '--noop';
24
+
25
+ export function isMcpSubcommand(args: readonly string[]): boolean {
26
+ return args.length > 0 && args[0] === SUBCOMMAND;
27
+ }
28
+
29
+ export interface McpFlags {
30
+ noop: boolean;
31
+ }
32
+
33
+ export function parseMcpFlags(tail: readonly string[]): McpFlags {
34
+ return { noop: tail.includes(NOOP_FLAG) };
35
+ }
36
+
37
+ /**
38
+ * The four tool names exposed by the subprocess, per design.mcp.md §4.
39
+ * Order matches the spec table; consumers should not depend on order
40
+ * but `tools/list` rendering is deterministic if they do.
41
+ */
42
+ export const MCP_TOOL_NAMES = [
43
+ 'fnc_restart',
44
+ 'fnc_switch_project',
45
+ 'fnc_spawn_session',
46
+ 'fnc_copy_to_clipboard',
47
+ ] as const;
48
+
49
+ export type McpToolName = (typeof MCP_TOOL_NAMES)[number];
50
+
51
+ /**
52
+ * Mapping from the MCP-visible tool name to the wire `op` value the
53
+ * parent dispatcher routes on. See design.mcp.md §4.
54
+ */
55
+ const TOOL_TO_OP: Record<McpToolName, WireOp> = {
56
+ fnc_restart: 'restart',
57
+ fnc_switch_project: 'switch',
58
+ fnc_spawn_session: 'spawn',
59
+ fnc_copy_to_clipboard: 'copy_to_clipboard',
60
+ };
61
+
62
+ export interface McpTool {
63
+ name: McpToolName;
64
+ handler: (args: unknown) => Promise<WireResponse>;
65
+ }
66
+
67
+ export interface BuildToolsArgs {
68
+ socketPath: string;
69
+ /** Injectable for tests; defaults to the real {@link dialAndCall}. */
70
+ dialAndCall?: (a: {
71
+ socketPath: string;
72
+ request: WireRequest;
73
+ }) => Promise<WireResponse>;
74
+ }
75
+
76
+ /**
77
+ * Construct the four tool handlers. Each handler accepts an MCP tool-args
78
+ * payload (object literal from the model's `tools/call`), wraps it in a
79
+ * WireRequest with the matching `op`, and forwards through `dialAndCall`.
80
+ *
81
+ * §8 will add per-tool input validation in front of `dialAndCall`. This
82
+ * function ships the wiring shape; the validation hooks in beside the
83
+ * `op:` field assignment without changing the call-graph.
84
+ */
85
+ export function buildTools(args: BuildToolsArgs): McpTool[] {
86
+ const dialer = args.dialAndCall ?? dialAndCall;
87
+ return MCP_TOOL_NAMES.map((name) => ({
88
+ name,
89
+ handler: async (payload: unknown): Promise<WireResponse> => {
90
+ const op = TOOL_TO_OP[name];
91
+ const request: WireRequest = { op };
92
+ if (payload !== null && payload !== undefined && typeof payload === 'object') {
93
+ for (const [k, v] of Object.entries(payload as Record<string, unknown>)) {
94
+ // Don't let a caller-supplied `op` field override our routing.
95
+ if (k === 'op') continue;
96
+ (request as Record<string, unknown>)[k] = v;
97
+ }
98
+ }
99
+ return dialer({ socketPath: args.socketPath, request });
100
+ },
101
+ }));
102
+ }
103
+
104
+ /**
105
+ * Entry point for `fnc mcp [--noop]`.
106
+ *
107
+ * Returns the process exit code. The launcher (main.ts) calls
108
+ * `process.exit(exitCode)` with the return value.
109
+ */
110
+ export async function runMcpServer(_flags: McpFlags): Promise<number> {
111
+ const socketPath = process.env.FNC_SOCKET;
112
+ if (socketPath === undefined || socketPath === '') {
113
+ process.stderr.write(
114
+ 'fnc mcp: FNC_SOCKET not set; subprocess must be invoked by fnclaude launcher.\n',
115
+ );
116
+ return 2;
117
+ }
118
+
119
+ const tools = buildTools({ socketPath });
120
+ const toolsByName = new Map(tools.map((t) => [t.name, t]));
121
+
122
+ await runStdinLoop({ toolsByName });
123
+ return 0;
124
+ }
125
+
126
+ interface StdinLoopArgs {
127
+ toolsByName: Map<string, McpTool>;
128
+ }
129
+
130
+ /**
131
+ * Placeholder JSON-RPC loop until §7.3 lands. Reads newline-delimited
132
+ * JSON from stdin; for each `tools/call` request, calls the matching
133
+ * handler and writes a minimal JSON-RPC 2.0 response. For everything
134
+ * else (including the eventual `initialize` / `tools/list`) writes a
135
+ * method-not-found error.
136
+ *
137
+ * The shape here is just enough to surface a real tool-call to the
138
+ * parent over the wire; §7.3 replaces this with the full scaffold.
139
+ */
140
+ async function runStdinLoop(args: StdinLoopArgs): Promise<void> {
141
+ const decoder = new TextDecoder('utf-8');
142
+ let buffer = '';
143
+
144
+ // Read raw bytes from stdin; node:process exposes stdin as an async
145
+ // iterable of Uint8Array chunks.
146
+ for await (const chunk of process.stdin) {
147
+ buffer += decoder.decode(chunk as Uint8Array, { stream: true });
148
+ let nl: number;
149
+ while ((nl = buffer.indexOf('\n')) !== -1) {
150
+ const line = buffer.slice(0, nl).trim();
151
+ buffer = buffer.slice(nl + 1);
152
+ if (line === '') continue;
153
+ await handleLine({ line, toolsByName: args.toolsByName });
154
+ }
155
+ }
156
+ // Flush whatever bytes remain after EOF.
157
+ const tail = buffer.trim();
158
+ if (tail !== '') {
159
+ await handleLine({ line: tail, toolsByName: args.toolsByName });
160
+ }
161
+ }
162
+
163
+ interface JsonRpcRequest {
164
+ jsonrpc?: string;
165
+ id?: number | string | null;
166
+ method?: string;
167
+ params?: unknown;
168
+ }
169
+
170
+ async function handleLine(args: {
171
+ line: string;
172
+ toolsByName: Map<string, McpTool>;
173
+ }): Promise<void> {
174
+ let req: JsonRpcRequest;
175
+ try {
176
+ req = JSON.parse(args.line) as JsonRpcRequest;
177
+ } catch (err) {
178
+ writeRpcError(null, -32700, `parse error: ${(err as Error).message}`);
179
+ return;
180
+ }
181
+
182
+ // Notifications (no `id`) get no response, even on error.
183
+ const id = req.id ?? null;
184
+ const isNotification = req.id === undefined;
185
+
186
+ if (req.method === 'tools/call') {
187
+ const params = (req.params ?? {}) as { name?: string; arguments?: unknown };
188
+ const tool = params.name !== undefined ? args.toolsByName.get(params.name) : undefined;
189
+ if (tool === undefined) {
190
+ if (!isNotification) {
191
+ writeRpcError(id, -32601, `unknown tool: ${params.name ?? '<missing>'}`);
192
+ }
193
+ return;
194
+ }
195
+ try {
196
+ const result = await tool.handler(params.arguments);
197
+ if (!isNotification) writeRpcToolResult(id, result);
198
+ } catch (err) {
199
+ if (!isNotification) {
200
+ writeRpcError(id, -32000, `tool error: ${(err as Error).message}`);
201
+ }
202
+ }
203
+ return;
204
+ }
205
+
206
+ // Full method dispatch (initialize, tools/list, etc.) lands with §7.3.
207
+ if (!isNotification) {
208
+ writeRpcError(id, -32601, `method not implemented yet (§7.3): ${req.method ?? '<missing>'}`);
209
+ }
210
+ }
211
+
212
+ function writeRpcToolResult(
213
+ id: number | string | null,
214
+ result: WireResponse,
215
+ ): void {
216
+ // Per design.mcp.md §2.3: marshal the Response JSON as a single text
217
+ // content item. The model reads `action` / `message` / `command` /
218
+ // `clipboard_ok` out of the embedded JSON string.
219
+ const envelope = {
220
+ jsonrpc: '2.0',
221
+ id,
222
+ result: {
223
+ content: [{ type: 'text', text: JSON.stringify(result) }],
224
+ },
225
+ };
226
+ process.stdout.write(JSON.stringify(envelope) + '\n');
227
+ }
228
+
229
+ function writeRpcError(
230
+ id: number | string | null,
231
+ code: number,
232
+ message: string,
233
+ ): void {
234
+ const envelope = {
235
+ jsonrpc: '2.0',
236
+ id,
237
+ error: { code, message },
238
+ };
239
+ process.stdout.write(JSON.stringify(envelope) + '\n');
240
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * §8.4 — Clipboard backend detection + invocation (pure module).
3
+ *
4
+ * Backend priority per design.md §25 + design.mcp.md §4.4:
5
+ *
6
+ * 1. `wl-copy` (Wayland)
7
+ * 2. `xclip` (X11 — preferred over xsel)
8
+ * 3. `xsel` (X11 fallback)
9
+ * 4. `pbcopy` (macOS)
10
+ * 5. `clip.exe` (Windows / WSL — reachable on WSL via PATH)
11
+ *
12
+ * Detection here is by `which`-style PATH lookup, not by the GOOS+env
13
+ * gating the Go canonical uses. That's intentional for the rewrite: a
14
+ * Linux box without WAYLAND_DISPLAY but with `xclip` on PATH will still
15
+ * work; on WSL `clip.exe` is reachable from a "linux" runtime; on macOS
16
+ * only `pbcopy` is in PATH by default. The injected `which` keeps tests
17
+ * pure.
18
+ *
19
+ * Each backend takes the clipboard payload via stdin and writes nothing
20
+ * useful to stdout. Exit 0 = success. The handler never throws — spawn
21
+ * failures, exec failures, non-zero exits all flow back as `false` so
22
+ * the caller can report `clipboard_ok: false` without lighting up an
23
+ * error path the model doesn't expect.
24
+ */
25
+
26
+ /** Locate an executable on PATH, returning its absolute path or null. */
27
+ export type WhichFn = (name: string) => string | null;
28
+
29
+ /**
30
+ * Minimal spawn surface we exercise from this module. The real
31
+ * implementation is a thin adapter over `Bun.spawn` (see {@link defaultSpawn}).
32
+ * Tests inject a fake to avoid touching real processes.
33
+ */
34
+ export interface SpawnedProc {
35
+ stdin: {
36
+ write(chunk: string | Uint8Array): void;
37
+ end(): void;
38
+ };
39
+ exited: Promise<number>;
40
+ kill(): void;
41
+ }
42
+
43
+ export type SpawnFn = (
44
+ args: readonly string[],
45
+ opts: { stdin: 'pipe' },
46
+ ) => SpawnedProc;
47
+
48
+ export interface Backend {
49
+ name: 'wl-copy' | 'xclip' | 'xsel' | 'pbcopy' | 'clip.exe';
50
+ command: string;
51
+ }
52
+
53
+ const PRIORITY: ReadonlyArray<Backend['name']> = [
54
+ 'wl-copy',
55
+ 'xclip',
56
+ 'xsel',
57
+ 'pbcopy',
58
+ 'clip.exe',
59
+ ];
60
+
61
+ /**
62
+ * Walk the priority list and return the first backend whose executable
63
+ * `which` finds. Returns null if nothing is installed.
64
+ */
65
+ export function detectBackend(args: { which: WhichFn }): Backend | null {
66
+ for (const name of PRIORITY) {
67
+ const command = args.which(name);
68
+ if (command !== null && command !== '') {
69
+ return { name, command };
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function backendArgs(name: Backend['name']): readonly string[] {
76
+ // Selection flags matter for X11 — without `-selection clipboard` xclip
77
+ // writes to the PRIMARY selection (middle-click paste), not the
78
+ // clipboard the user reaches via Ctrl-V. Same shape for xsel's `-ib`.
79
+ switch (name) {
80
+ case 'xclip':
81
+ return ['-selection', 'clipboard'];
82
+ case 'xsel':
83
+ return ['-ib'];
84
+ case 'wl-copy':
85
+ case 'pbcopy':
86
+ case 'clip.exe':
87
+ return [];
88
+ }
89
+ }
90
+
91
+ export interface RunBackendArgs {
92
+ backend: Backend;
93
+ text: string;
94
+ spawn: SpawnFn;
95
+ }
96
+
97
+ /**
98
+ * Invoke the chosen backend, piping `text` to its stdin. Returns true on
99
+ * clean exit (code 0), false on any failure: spawn throwing, stdin write
100
+ * throwing, non-zero exit, or the exited promise rejecting.
101
+ *
102
+ * Crucially, this function never throws. The caller relies on the
103
+ * boolean return to populate `clipboard_ok`.
104
+ */
105
+ export async function runBackend(args: RunBackendArgs): Promise<boolean> {
106
+ const argv = [args.backend.command, ...backendArgs(args.backend.name)];
107
+ let proc: SpawnedProc;
108
+ try {
109
+ proc = args.spawn(argv, { stdin: 'pipe' });
110
+ } catch {
111
+ return false;
112
+ }
113
+
114
+ try {
115
+ proc.stdin.write(args.text);
116
+ proc.stdin.end();
117
+ } catch {
118
+ // stdin closed early or the process died before we could write.
119
+ // Make sure the spawned process isn't lingering, then report
120
+ // failure. Some backends (xclip on Wayland-only boxes, for one)
121
+ // exit immediately on EPIPE and we shouldn't wait on exited
122
+ // forever; the kill is best-effort.
123
+ try {
124
+ proc.kill();
125
+ } catch {
126
+ // ignore
127
+ }
128
+ return false;
129
+ }
130
+
131
+ let exitCode: number;
132
+ try {
133
+ exitCode = await proc.exited;
134
+ } catch {
135
+ return false;
136
+ }
137
+ return exitCode === 0;
138
+ }
139
+
140
+ /**
141
+ * Production `which`: thin wrapper around Bun.which. Returns null when
142
+ * the name isn't on PATH; both `null` and `undefined` from Bun.which are
143
+ * treated as "not found".
144
+ */
145
+ export const defaultWhich: WhichFn = (name) => {
146
+ const r = Bun.which(name);
147
+ return r === null || r === undefined ? null : r;
148
+ };
149
+
150
+ /**
151
+ * Production `spawn`: adapter that yields the minimal SpawnedProc shape
152
+ * over Bun.spawn. We pipe stdin (for the text payload), ignore stdout
153
+ * (no useful output from these backends), and inherit stderr so a real
154
+ * misconfiguration surfaces in the parent's terminal during development.
155
+ */
156
+ export const defaultSpawn: SpawnFn = (args, _opts) => {
157
+ const proc = Bun.spawn([...args], {
158
+ stdin: 'pipe',
159
+ stdout: 'ignore',
160
+ stderr: 'inherit',
161
+ });
162
+ return {
163
+ stdin: {
164
+ write(chunk: string | Uint8Array) {
165
+ proc.stdin.write(chunk);
166
+ },
167
+ end() {
168
+ proc.stdin.end();
169
+ },
170
+ },
171
+ exited: proc.exited,
172
+ kill() {
173
+ proc.kill();
174
+ },
175
+ };
176
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * §8.4 — `fnc_copy_to_clipboard` handler.
3
+ *
4
+ * Spec (design.mcp.md §4.4):
5
+ * - Required arg: `text: string`
6
+ * - Returns `{ action: 'done', clipboard_ok: boolean }`
7
+ * - NEVER errors — clipboard absence flows through the boolean flag,
8
+ * not an error response. Same shape on every failure mode: no
9
+ * backend on PATH, backend exits non-zero, spawn throws, text arg
10
+ * is missing or wrong type.
11
+ *
12
+ * Wave 1 (this PR) ships the pure module only. §7.7's parent
13
+ * dispatcher will route `op: 'copy_to_clipboard'` to this handler in
14
+ * Wave 2.
15
+ */
16
+
17
+ import type { WireRequest, WireResponse } from '../wire.ts';
18
+ import {
19
+ defaultSpawn,
20
+ defaultWhich,
21
+ detectBackend,
22
+ runBackend,
23
+ type SpawnFn,
24
+ type WhichFn,
25
+ } from './clipboard-backends.ts';
26
+
27
+ export interface HandleCopyToClipboardDeps {
28
+ which?: WhichFn;
29
+ spawn?: SpawnFn;
30
+ }
31
+
32
+ /**
33
+ * Process a `copy_to_clipboard` request. Always resolves to a `done`
34
+ * response; the `clipboard_ok` flag carries the actual outcome.
35
+ *
36
+ * Deps are injected for tests; production callers omit them and get the
37
+ * Bun.which / Bun.spawn defaults.
38
+ */
39
+ export async function handleCopyToClipboard(
40
+ req: WireRequest,
41
+ deps: HandleCopyToClipboardDeps = {},
42
+ ): Promise<WireResponse> {
43
+ const which = deps.which ?? defaultWhich;
44
+ const spawn = deps.spawn ?? defaultSpawn;
45
+
46
+ const text = req.text;
47
+ if (typeof text !== 'string') {
48
+ return done(false);
49
+ }
50
+
51
+ const backend = detectBackend({ which });
52
+ if (backend === null) {
53
+ return done(false);
54
+ }
55
+
56
+ const ok = await runBackend({ backend, text, spawn });
57
+ return done(ok);
58
+ }
59
+
60
+ function done(clipboardOk: boolean): WireResponse {
61
+ return { action: 'done', clipboard_ok: clipboardOk };
62
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * §8.1 — `fnc_restart` handler.
3
+ *
4
+ * Ports the Go canonical `handleRestart` from
5
+ * `fnclaude@fnrhombus/src/socket_listener.go` lines 221-256. The
6
+ * restart flow rebuilds the launch argv with the same magic prefix the
7
+ * user originally typed, swaps in `--resume <session_id>` immediately
8
+ * after the cwd positional, applies MCP-supplied overrides, then stashes
9
+ * the result + fires the handoff trigger so §8.5's awaiter can SIGTERM
10
+ * claude and re-exec fnc with the new argv.
11
+ *
12
+ * Algorithm (matches Go canonical):
13
+ * 1. Validate session_id present + UUID 8-4-4-4-12 hex.
14
+ * 2. `preserveArgs(origArgs, ∅, ∅)` — restart uses NO denylist.
15
+ * 3. `applyOverrides(preserved, req)` — splices in MCP overrides.
16
+ * 4. If no caller-supplied permission_mode AND no preserved
17
+ * `--permission-mode`, ask the injected `livePermissionModeReader`
18
+ * for the value claude wrote into the session JSONL. The reader is
19
+ * optional; production wiring stubs it out for now (TODO file IO).
20
+ * 5. Split the leading magic-word run; rebuild argv as:
21
+ * `[...magic, launchCWD, '--resume', sid, ...rest]`
22
+ * 6. `trigger.stashArgv(argv)` (first-stash-wins).
23
+ * 7. `trigger.fire()` to wake §8.5's awaiter.
24
+ * 8. Respond `{ action: 'done' }`.
25
+ *
26
+ * Design: docs/design.mcp.md §4.1, §5; docs/design.md §12-13.
27
+ */
28
+
29
+ import {
30
+ applyOverrides,
31
+ preserveArgs,
32
+ splitLeadingMagic,
33
+ type OverrideRequest,
34
+ } from '../../argv/preserve-args.ts';
35
+ import type { HandoffTrigger } from '../../handoff/trigger.ts';
36
+ import type { ParentDispatchHandler } from '../parent-dispatch.ts';
37
+ import type { WireRequest, WireResponse } from '../wire.ts';
38
+
39
+ const SESSION_ID_RE =
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
+ const EMPTY_DENY: ReadonlySet<string> = new Set<string>();
43
+
44
+ /**
45
+ * Reader for the live permission-mode value claude persists in the
46
+ * session JSONL (`~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`).
47
+ * `launchCWD` is bound at construction time in main.ts, so the reader
48
+ * only takes the per-call `sessionID`. Returns `null` when no value is
49
+ * available (file missing, no matching record, etc.) — the auto-capture
50
+ * append is skipped in that case. Same shape as switch.ts uses, so a
51
+ * single closure can be wired into both handlers.
52
+ */
53
+ export type LivePermissionModeReader = (sessionID: string) => string | null;
54
+
55
+ export interface CreateRestartHandlerArgs {
56
+ /** The user's original argv as fnc saw it at startup (post-readArgv). */
57
+ origArgs: readonly string[];
58
+ /** The directory fnc launched claude into — emitted as the first positional. */
59
+ launchCWD: string;
60
+ /** Shared handoff trigger; first-stash-wins for argv. */
61
+ trigger: HandoffTrigger;
62
+ /** Optional injected reader. Omit to disable live-capture entirely. */
63
+ livePermissionModeReader?: LivePermissionModeReader;
64
+ }
65
+
66
+ /**
67
+ * Build the restart handler with all collaborators bound. Returned
68
+ * function plugs straight into `createParentDispatcher({ handlers: { restart, ... } })`.
69
+ */
70
+ export function createRestartHandler(args: CreateRestartHandlerArgs): ParentDispatchHandler {
71
+ const { origArgs, launchCWD, trigger, livePermissionModeReader } = args;
72
+
73
+ return async (req: WireRequest): Promise<WireResponse> => {
74
+ const sessionID = req.session_id;
75
+ if (typeof sessionID !== 'string' || sessionID === '') {
76
+ return {
77
+ action: 'error',
78
+ error:
79
+ 'restart requires a session id; pass it as the fnc_restart session_id argument (read $CLAUDE_CODE_SESSION_ID via Bash).',
80
+ };
81
+ }
82
+ if (!SESSION_ID_RE.test(sessionID)) {
83
+ return {
84
+ action: 'error',
85
+ error: `session_id ${JSON.stringify(sessionID)} is not a valid UUID; expected the 8-4-4-4-12 hex form.`,
86
+ };
87
+ }
88
+
89
+ // Preserve user flags (no denylist for restart — everything carries).
90
+ const preserved = preserveArgs(origArgs, EMPTY_DENY, EMPTY_DENY);
91
+
92
+ // Apply MCP-supplied overrides. Wire snake_case → OverrideRequest camelCase.
93
+ const overrides = wireToOverrideRequest(req);
94
+ let withOverrides = applyOverrides(preserved, overrides);
95
+
96
+ // Auto-capture live permission-mode when no override was passed AND
97
+ // no preserved flag carries one. Mirrors Go canonical — runs only
98
+ // when an injected reader is available.
99
+ const permissionModeFromReq = req.permission_mode;
100
+ const callerSuppliedPermissionMode =
101
+ typeof permissionModeFromReq === 'string' && permissionModeFromReq !== '';
102
+ if (
103
+ !callerSuppliedPermissionMode &&
104
+ !flagPresent(withOverrides, '--permission-mode') &&
105
+ livePermissionModeReader !== undefined
106
+ ) {
107
+ const live = livePermissionModeReader(sessionID);
108
+ if (live !== null && live !== '') {
109
+ withOverrides = [...withOverrides, '--permission-mode', live];
110
+ }
111
+ }
112
+
113
+ const { magic, rest } = splitLeadingMagic(withOverrides);
114
+ const argv: string[] = [...magic, launchCWD, '--resume', sessionID, ...rest];
115
+
116
+ trigger.stashArgv(argv);
117
+ trigger.fire();
118
+ return { action: 'done' };
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Translate the wire request's snake_case override fields into the
124
+ * `OverrideRequest` shape `applyOverrides` consumes. Fields not present
125
+ * (or of the wrong type) are silently omitted — the caller's MCP layer
126
+ * validates shapes; defensive typing here just keeps applyOverrides safe.
127
+ */
128
+ function wireToOverrideRequest(req: WireRequest): OverrideRequest {
129
+ const out: OverrideRequest = {};
130
+ if (typeof req.model === 'string' && req.model !== '') out.model = req.model;
131
+ if (typeof req.effort === 'string' && req.effort !== '') out.effort = req.effort;
132
+ if (typeof req.permission_mode === 'string' && req.permission_mode !== '') {
133
+ out.permissionMode = req.permission_mode;
134
+ }
135
+ if (typeof req.allowed_tools === 'string' && req.allowed_tools !== '') {
136
+ out.allowedTools = req.allowed_tools;
137
+ }
138
+ if (typeof req.agent === 'string' && req.agent !== '') out.agent = req.agent;
139
+ if (typeof req.brief === 'boolean') out.brief = req.brief;
140
+ if (typeof req.chrome === 'boolean') out.chrome = req.chrome;
141
+ if (typeof req.ide === 'boolean') out.ide = req.ide;
142
+ if (typeof req.verbose === 'boolean') out.verbose = req.verbose;
143
+ return out;
144
+ }
145
+
146
+ /**
147
+ * Returns true when `args` contains `flag` as a standalone token or in
148
+ * `--flag=value` form. Mirrors Go canonical's `flagPresent`.
149
+ */
150
+ function flagPresent(args: readonly string[], flag: string): boolean {
151
+ const eqPrefix = `${flag}=`;
152
+ for (const tok of args) {
153
+ if (tok === flag || tok.startsWith(eqPrefix)) return true;
154
+ }
155
+ return false;
156
+ }