@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,219 @@
1
+ /**
2
+ * §8.3 — `fnc_spawn_session` handler.
3
+ *
4
+ * Spec (design.mcp.md §4.3 + spawn.go + socket_listener.go.handleSpawn):
5
+ *
6
+ * - Required args: `destination`, `name`, `summary`. **No `session_id`** —
7
+ * spawn starts fresh, with no preservation of flags or session state.
8
+ * - Optional args: override fields (model / effort / permission_mode /
9
+ * allowed_tools / agent / brief / chrome / ide / verbose).
10
+ *
11
+ * Algorithm:
12
+ *
13
+ * 1. Validate required args. Missing/empty → ActionError.
14
+ * 2. Write `summary` to a unique 0600 file under XDG_RUNTIME_DIR.
15
+ * 3. Build override args from `applyOverrides([], req)`. No preservation
16
+ * → only override-derived flag tokens appear. Result feeds the spawn
17
+ * template's surrounding context AND the paste-flow command string.
18
+ * 4. Build the cleaned env (`cleanEnvForSpawn` strips FNC_SOCKET,
19
+ * FNCLAUDE_HANDOFF, CLAUDE_CODE_SESSION_ID).
20
+ * 5. Decide launcher: `auto.spawnCommand` → `$TMUX` → paste-flow.
21
+ * 6. On launcher success → ActionDone. On no-launcher → ActionPasteFlow
22
+ * + clipboard write of the rendered relaunch command.
23
+ *
24
+ * Unlike §8.1 (restart) and §8.2 (switch), spawn NEVER stashes argv or
25
+ * fires the handoff trigger — the current session keeps running. The
26
+ * spawned sibling is its own independent fnclaude (own socket, own MCP
27
+ * env, own claude). design.mcp.md §4.3.
28
+ *
29
+ * Wire-protocol contract: returns a `WireResponse`. The handler never
30
+ * throws — internal failures (launcher errors, file-write errors,
31
+ * missing args) flow back as `{ action: 'error', error }`.
32
+ *
33
+ * Dependencies are injected via `createSpawnHandler` so tests can
34
+ * exercise the algorithm without touching real launchers, real
35
+ * clipboards, or real summary files. Production wiring in main.ts
36
+ * spreads the deps from the parent's runtime context.
37
+ */
38
+
39
+ import {
40
+ applyOverrides,
41
+ type OverrideRequest,
42
+ } from '../../argv/preserve-args.ts';
43
+ import { cleanEnvForSpawn } from '../../handoff/clean-env.ts';
44
+ import {
45
+ chooseAndSpawn,
46
+ defaultSpawn,
47
+ renderSpawnCommand,
48
+ type SpawnFn,
49
+ } from '../../handoff/spawn-launcher.ts';
50
+ import { writeSummaryFile } from '../../handoff/summary-file.ts';
51
+ import type { ParentDispatchHandler } from '../parent-dispatch.ts';
52
+ import type { WireRequest, WireResponse } from '../wire.ts';
53
+
54
+ export interface SpawnHandlerConfig {
55
+ /** `cfg.auto.spawnCommand` — undefined or empty = "not configured". */
56
+ autoSpawnCommand?: string | undefined;
57
+ }
58
+
59
+ export interface SpawnHandlerDeps {
60
+ config: SpawnHandlerConfig;
61
+ /** Env source (test seam). Production passes `process.env`. */
62
+ processEnv: NodeJS.ProcessEnv | Record<string, string | undefined>;
63
+ /** Absolute fnclaude bin path for `{bin}` substitution. */
64
+ fncBinPath: string;
65
+ /**
66
+ * Override the launcher spawner (test seam). Production omits this and
67
+ * gets `defaultSpawn` (a thin `Bun.spawn` adapter).
68
+ */
69
+ spawnLauncher?: SpawnFn;
70
+ /**
71
+ * Handler used for paste-flow fallback to put the rendered relaunch
72
+ * command on the clipboard. Same signature as §8.4's `handleCopyToClipboard`.
73
+ * Production wires §8.4's handler here; tests inject a stub.
74
+ */
75
+ handleCopyToClipboard?: (req: WireRequest) => Promise<WireResponse>;
76
+ /**
77
+ * Override the summary-file writer (test seam). Production omits this
78
+ * and gets the real `writeSummaryFile`.
79
+ */
80
+ writeSummaryFile?: (args: { content: string }) => Promise<string>;
81
+ }
82
+
83
+ /**
84
+ * Build the spawn dispatch handler with injected deps. Returns a
85
+ * `ParentDispatchHandler` (the shape `createParentDispatcher` expects).
86
+ */
87
+ export function createSpawnHandler(deps: SpawnHandlerDeps): ParentDispatchHandler {
88
+ const spawnFn = deps.spawnLauncher ?? defaultSpawn;
89
+ const copyHandler = deps.handleCopyToClipboard;
90
+ const writeSummary =
91
+ deps.writeSummaryFile ?? ((args: { content: string }) => writeSummaryFile({ content: args.content }));
92
+
93
+ return async (req: WireRequest): Promise<WireResponse> => {
94
+ // 1. Validate required args.
95
+ const dest = typeof req.destination === 'string' ? req.destination : '';
96
+ const name = typeof req.name === 'string' ? req.name : '';
97
+ const summary = typeof req.summary === 'string' ? req.summary : '';
98
+
99
+ if (dest === '') {
100
+ return errorResponse('spawn requires a destination');
101
+ }
102
+ if (name === '') {
103
+ return errorResponse('spawn requires a name');
104
+ }
105
+ if (summary === '') {
106
+ return errorResponse('spawn requires a summary');
107
+ }
108
+
109
+ // 2. Persist the summary to disk.
110
+ let summaryPath: string;
111
+ try {
112
+ summaryPath = await writeSummary({ content: summary });
113
+ } catch (err) {
114
+ return errorResponse(`write summary: ${(err as Error).message}`);
115
+ }
116
+
117
+ // 3. Build the override flag tokens. No preservation — spawn is a
118
+ // fresh start, so the input slice is empty and `applyOverrides`
119
+ // emits ONLY override-derived flags.
120
+ const extraArgs = applyOverrides([], extractOverrides(req));
121
+
122
+ // 4. Build the cleaned spawn env.
123
+ const spawnEnv = cleanEnvForSpawn(deps.processEnv);
124
+
125
+ // 5. Pick launcher and dispatch.
126
+ let result: ReturnType<typeof chooseAndSpawn>;
127
+ try {
128
+ result = chooseAndSpawn({
129
+ autoSpawnCommand: deps.config.autoSpawnCommand ?? '',
130
+ env: deps.processEnv,
131
+ spawnEnv,
132
+ fncBin: deps.fncBinPath,
133
+ dest,
134
+ name,
135
+ summary: summaryPath,
136
+ extraArgs,
137
+ spawn: spawnFn,
138
+ });
139
+ } catch (err) {
140
+ return errorResponse(`spawn: ${(err as Error).message}`);
141
+ }
142
+
143
+ if (result.ok) {
144
+ return {
145
+ action: 'done',
146
+ message: `Spawned sibling fnclaude for ${dest} in a new window.`,
147
+ };
148
+ }
149
+
150
+ // 6. No launcher resolved — paste-flow fallback. Surface the
151
+ // auto.spawnCommand config knob so users in unrecognized terminals
152
+ // discover the customization point without reading source.
153
+ const command = renderSpawnCommand({
154
+ dest,
155
+ name,
156
+ summary: summaryPath,
157
+ extraArgs,
158
+ });
159
+ const clipboardOk = await tryCopyToClipboard(copyHandler, command);
160
+ const message = clipboardOk
161
+ ? 'No spawn launcher configured for this terminal — the relaunch command is on your clipboard; paste it into a new terminal window. Set `auto.spawnCommand` in ~/.config/fnclaude/config.toml to enable auto-spawn (use {bin}, {dest}, {name}, {summary} placeholders).'
162
+ : 'No spawn launcher configured for this terminal — copy this command and run it in a new terminal window. Set `auto.spawnCommand` in ~/.config/fnclaude/config.toml to enable auto-spawn (use {bin}, {dest}, {name}, {summary} placeholders):';
163
+
164
+ return {
165
+ action: 'paste_flow',
166
+ message,
167
+ command,
168
+ clipboard_ok: clipboardOk,
169
+ };
170
+ };
171
+ }
172
+
173
+ function errorResponse(error: string): WireResponse {
174
+ return { action: 'error', error };
175
+ }
176
+
177
+ /**
178
+ * Pull the override fields off the request envelope and shape them as
179
+ * an `OverrideRequest` for `applyOverrides`. Non-string / non-bool
180
+ * values are ignored — the wire is loose by design (§7.6) and any
181
+ * caller writing the wrong type per field gets the "preserve" branch.
182
+ */
183
+ function extractOverrides(req: WireRequest): OverrideRequest {
184
+ const out: OverrideRequest = {};
185
+ if (typeof req.model === 'string' && req.model !== '') out.model = req.model;
186
+ if (typeof req.effort === 'string' && req.effort !== '') out.effort = req.effort;
187
+ if (typeof req.permission_mode === 'string' && req.permission_mode !== '') {
188
+ out.permissionMode = req.permission_mode;
189
+ }
190
+ if (typeof req.allowed_tools === 'string' && req.allowed_tools !== '') {
191
+ out.allowedTools = req.allowed_tools;
192
+ }
193
+ if (typeof req.agent === 'string' && req.agent !== '') out.agent = req.agent;
194
+ if (typeof req.brief === 'boolean') out.brief = req.brief;
195
+ if (typeof req.chrome === 'boolean') out.chrome = req.chrome;
196
+ if (typeof req.ide === 'boolean') out.ide = req.ide;
197
+ if (typeof req.verbose === 'boolean') out.verbose = req.verbose;
198
+ return out;
199
+ }
200
+
201
+ /**
202
+ * Best-effort clipboard write for paste-flow Responses. Routes the
203
+ * command string through the injected §8.4 handler. Any failure
204
+ * (no handler injected, handler returns non-`done`, handler throws)
205
+ * collapses to `false` — the response still surfaces, claude just
206
+ * tells the user to copy manually.
207
+ */
208
+ async function tryCopyToClipboard(
209
+ copyHandler: ((req: WireRequest) => Promise<WireResponse>) | undefined,
210
+ text: string,
211
+ ): Promise<boolean> {
212
+ if (copyHandler === undefined) return false;
213
+ try {
214
+ const r = await copyHandler({ op: 'copy_to_clipboard', text });
215
+ return r.clipboard_ok === true;
216
+ } catch {
217
+ return false;
218
+ }
219
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * §8.2 — `fnc_switch_project` handler.
3
+ *
4
+ * Spec (docs/design.mcp.md §4.2):
5
+ * - Required args: `destination`, `name`, `summary`.
6
+ * - Optional: override fields (`model`, `effort`, `permission_mode`,
7
+ * `allowed_tools`, `agent`, `brief`, `chrome`, `ide`, `verbose`) and
8
+ * `session_id` (for live permission-mode capture).
9
+ *
10
+ * Algorithm:
11
+ * 1. Validate the three required strings; missing one → `action: 'error'`.
12
+ * 2. Write `summary` to `<base>/fnclaude-handoff-content-<16hex>.md`
13
+ * (mode 0600) via `writeSummaryFile`.
14
+ * 3. If `permission_mode === 'never'`: build the relaunch command,
15
+ * copy it to the clipboard, return `action: 'paste_flow'`. The
16
+ * current session keeps running.
17
+ * 4. Otherwise:
18
+ * - `preserved = preserveArgs(origArgs, TRANSFER_DENY_FLAGS, TRANSFER_DENY_BARE_OK)`
19
+ * - `withOverrides = applyOverrides(preserved, req)`
20
+ * - If no permission_mode override AND no preserved permission-mode
21
+ * AND `session_id` is present → live-capture from session JSONL
22
+ * and append `--permission-mode <live>`.
23
+ * - `{magic, rest} = splitLeadingMagic(withOverrides)`
24
+ * - argv = `[...magic, destination, ...rest, '--name', name, '@' + summaryPath]`
25
+ * - `trigger.stashArgv(argv)` + `trigger.fire()`.
26
+ * - Return `action: 'done'`.
27
+ *
28
+ * Side effects (clipboard exec, file write, trigger fire) all flow
29
+ * through injected dependencies so unit tests stay hermetic. Production
30
+ * callers in main.ts wire the real `handoffTrigger` singleton and the
31
+ * real clipboard / summary-file modules.
32
+ */
33
+
34
+ import {
35
+ applyOverrides,
36
+ preserveArgs,
37
+ splitLeadingMagic,
38
+ TRANSFER_DENY_BARE_OK,
39
+ TRANSFER_DENY_FLAGS,
40
+ type OverrideRequest,
41
+ } from '../../argv/preserve-args.ts';
42
+ import { writeSummaryFile, type BaseDirResolver } from '../../handoff/summary-file.ts';
43
+ import type { HandoffTrigger } from '../../handoff/trigger.ts';
44
+ import type { ParentDispatchHandler } from '../parent-dispatch.ts';
45
+ import type { WireRequest, WireResponse } from '../wire.ts';
46
+ import { handleCopyToClipboard } from './clipboard.ts';
47
+
48
+ /**
49
+ * Read the most recent live permission-mode value from claude's
50
+ * per-session JSONL. Returns null when no record exists or the file
51
+ * isn't reachable. The default factory returns null (live capture is a
52
+ * Wave-2 §8.1/§8.2 add; the §8.2 commit ships the wiring, the JSONL
53
+ * reader lands separately).
54
+ */
55
+ export type LivePermissionModeReader = (sessionId: string) => string | null;
56
+
57
+ const NULL_LIVE_PERMISSION_READER: LivePermissionModeReader = () => null;
58
+
59
+ /**
60
+ * Write-summary seam — defaults to {@link writeSummaryFile}. Tests pass
61
+ * a stub that records the call and returns a deterministic path.
62
+ */
63
+ export type WriteSummaryFn = (args: {
64
+ summary: string;
65
+ baseDir?: BaseDirResolver;
66
+ }) => Promise<{ path: string }>;
67
+
68
+ /**
69
+ * Clipboard seam — defaults to {@link handleCopyToClipboard}. Same
70
+ * signature as the §8.4 handler so production wiring is a direct
71
+ * reference, no adapter needed.
72
+ */
73
+ export type CopyToClipboardFn = (req: WireRequest) => Promise<WireResponse>;
74
+
75
+ export interface CreateSwitchHandlerArgs {
76
+ /**
77
+ * The os.argv[1:] snapshot captured at fnclaude startup. Used to
78
+ * preserve user-supplied flags across the relaunch (minus the
79
+ * transfer denylist).
80
+ */
81
+ origArgs: readonly string[];
82
+ /** Shared handoff trigger — receives `stashArgv` + `fire` on success. */
83
+ trigger: HandoffTrigger;
84
+ /**
85
+ * Optional live permission-mode reader (defaults to a null stub).
86
+ * Production wiring passes the session-JSONL reader from §8.1/§8.2.
87
+ */
88
+ livePermissionModeReader?: LivePermissionModeReader;
89
+ /** Optional summary-file write seam (defaults to `writeSummaryFile`). */
90
+ writeSummary?: WriteSummaryFn;
91
+ /** Optional clipboard handler seam (defaults to `handleCopyToClipboard`). */
92
+ handleCopyToClipboard?: CopyToClipboardFn;
93
+ /** Optional base-dir resolver — forwarded to the summary-file writer. */
94
+ baseDirResolver?: BaseDirResolver;
95
+ }
96
+
97
+ /**
98
+ * Build a `ParentDispatchHandler` bound to the parent's startup state.
99
+ * The returned function is what main.ts plugs into
100
+ * `createParentDispatcher`'s `handlers.switch` slot.
101
+ */
102
+ export function createSwitchHandler(args: CreateSwitchHandlerArgs): ParentDispatchHandler {
103
+ const liveReader = args.livePermissionModeReader ?? NULL_LIVE_PERMISSION_READER;
104
+ const writeSummary = args.writeSummary ?? writeSummaryFile;
105
+ const copyToClipboard = args.handleCopyToClipboard ?? handleCopyToClipboard;
106
+ const baseDir = args.baseDirResolver;
107
+
108
+ return async (req: WireRequest): Promise<WireResponse> => {
109
+ const destination = stringField(req, 'destination');
110
+ const name = stringField(req, 'name');
111
+ const summary = stringField(req, 'summary');
112
+
113
+ if (destination === '' || name === '' || summary === '') {
114
+ const missing: string[] = [];
115
+ if (destination === '') missing.push('destination');
116
+ if (name === '') missing.push('name');
117
+ if (summary === '') missing.push('summary');
118
+ return {
119
+ action: 'error',
120
+ error: `switch requires ${missing.join(', ')}`,
121
+ };
122
+ }
123
+
124
+ let summaryPath: string;
125
+ try {
126
+ const r = await writeSummary({ summary, baseDir });
127
+ summaryPath = r.path;
128
+ } catch (err) {
129
+ return {
130
+ action: 'error',
131
+ error: `write summary: ${(err as Error).message}`,
132
+ };
133
+ }
134
+
135
+ const overrides = overrideRequestFrom(req);
136
+ const sessionId = stringField(req, 'session_id');
137
+
138
+ // Never-mode paste-flow branch. Render the command, copy it, return
139
+ // action='paste_flow'. The current session keeps running — no
140
+ // stashArgv, no fire.
141
+ //
142
+ // 'never' is a control signal, not a real permission-mode value:
143
+ // strip it from the overrides before rendering so the relaunch
144
+ // command the user pastes is a valid invocation. Go canonical
145
+ // (which branches on cfg.Auto.Handoff="never" instead) gets the
146
+ // same result by not having a `--permission-mode never` token to
147
+ // begin with.
148
+ if (overrides.permissionMode === 'never') {
149
+ const pasteOverrides: OverrideRequest = { ...overrides, permissionMode: undefined };
150
+ const preserved = preserveArgs(args.origArgs, TRANSFER_DENY_FLAGS, TRANSFER_DENY_BARE_OK);
151
+ const withOverrides = applyOverrides(preserved, pasteOverrides);
152
+ const { magic, rest } = splitLeadingMagic(withOverrides);
153
+ const command = renderSwitchCommand(magic, destination, rest, name, summaryPath);
154
+ const clipResp = await copyToClipboard({
155
+ op: 'copy_to_clipboard',
156
+ text: command,
157
+ });
158
+ const clipboardOk = clipResp.clipboard_ok === true;
159
+ const message = clipboardOk
160
+ ? "I've prepared the handoff command (already on your clipboard)."
161
+ : 'Copy this command and run it:';
162
+ return {
163
+ action: 'paste_flow',
164
+ message,
165
+ command,
166
+ clipboard_ok: clipboardOk,
167
+ };
168
+ }
169
+
170
+ // Normal switch branch. Preserve user flags from startup (minus
171
+ // the transfer denylist), apply MCP overrides, auto-capture the
172
+ // live permission mode when neither side supplied one, then split
173
+ // magic from rest and build the relaunch argv.
174
+ const preserved = preserveArgs(args.origArgs, TRANSFER_DENY_FLAGS, TRANSFER_DENY_BARE_OK);
175
+ let withOverrides = applyOverrides(preserved, overrides);
176
+
177
+ const hasPermissionOverride =
178
+ overrides.permissionMode !== undefined && overrides.permissionMode !== '';
179
+ if (!hasPermissionOverride && !flagPresent(withOverrides, '--permission-mode') && sessionId !== '') {
180
+ const live = liveReader(sessionId);
181
+ if (live !== null && live !== '') {
182
+ withOverrides = [...withOverrides, '--permission-mode', live];
183
+ }
184
+ }
185
+ const { magic, rest } = splitLeadingMagic(withOverrides);
186
+
187
+ const argv: string[] = [
188
+ ...magic,
189
+ destination,
190
+ ...rest,
191
+ '--name',
192
+ name,
193
+ `@${summaryPath}`,
194
+ ];
195
+ args.trigger.stashArgv(argv);
196
+ args.trigger.fire();
197
+ return { action: 'done' };
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Build the user-visible relaunch command string for paste-flow
203
+ * responses. Mirrors `renderSwitchCommand` in the Go canonical
204
+ * (src/socket_listener.go:451). Magic words come first, then the
205
+ * destination, then the preserved/override flags, then
206
+ * `--name <name> @<summaryPath>`.
207
+ *
208
+ * Each token is shell-safe enough as-is: override values come from a
209
+ * controlled vocabulary (model aliases, effort levels, permission
210
+ * modes, allowedTools comma-list, agent names), and the summary path
211
+ * is `<base>/fnclaude-handoff-content-<hex>.md` — no whitespace, no
212
+ * metacharacters.
213
+ */
214
+ function renderSwitchCommand(
215
+ magic: readonly string[],
216
+ destination: string,
217
+ rest: readonly string[],
218
+ name: string,
219
+ summaryPath: string,
220
+ ): string {
221
+ const parts: string[] = ['fnclaude', ...magic, destination, ...rest, '--name', name, `@${summaryPath}`];
222
+ return parts.join(' ');
223
+ }
224
+
225
+ /**
226
+ * Coerce wire-request fields into an `OverrideRequest`. Wire keys use
227
+ * snake_case per the JSON contract; the override struct uses
228
+ * camelCase. Unknown / missing / wrong-type fields collapse to
229
+ * undefined so `applyOverrides` falls through to its preserve branch.
230
+ */
231
+ function overrideRequestFrom(req: WireRequest): OverrideRequest {
232
+ return {
233
+ model: stringFieldOrUndef(req, 'model'),
234
+ effort: stringFieldOrUndef(req, 'effort'),
235
+ permissionMode: stringFieldOrUndef(req, 'permission_mode'),
236
+ allowedTools: stringFieldOrUndef(req, 'allowed_tools'),
237
+ agent: stringFieldOrUndef(req, 'agent'),
238
+ brief: boolFieldOrUndef(req, 'brief'),
239
+ chrome: boolFieldOrUndef(req, 'chrome'),
240
+ ide: boolFieldOrUndef(req, 'ide'),
241
+ verbose: boolFieldOrUndef(req, 'verbose'),
242
+ };
243
+ }
244
+
245
+ function stringField(req: WireRequest, key: string): string {
246
+ const v = req[key];
247
+ return typeof v === 'string' ? v : '';
248
+ }
249
+
250
+ function stringFieldOrUndef(req: WireRequest, key: string): string | undefined {
251
+ const v = req[key];
252
+ return typeof v === 'string' ? v : undefined;
253
+ }
254
+
255
+ function boolFieldOrUndef(req: WireRequest, key: string): boolean | undefined {
256
+ const v = req[key];
257
+ return typeof v === 'boolean' ? v : undefined;
258
+ }
259
+
260
+ /**
261
+ * Whether `args` carries `flag` as a bare token or as `flag=value`.
262
+ * Mirrors Go canonical's `flagPresent` (src/socket_listener.go:260).
263
+ */
264
+ function flagPresent(argsArr: readonly string[], flag: string): boolean {
265
+ const prefix = `${flag}=`;
266
+ for (const t of argsArr) {
267
+ if (t === flag || t.startsWith(prefix)) {
268
+ return true;
269
+ }
270
+ }
271
+ return false;
272
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Self-MCP `--mcp-config` injection (§7.4).
3
+ *
4
+ * Builds an inline JSON config that points claude at the fnclaude binary
5
+ * as an MCP server, then appends `--mcp-config <json>` to the claude argv
6
+ * the launcher is about to spawn. The subprocess claude spawns from this
7
+ * config dials the parent over $FNC_SOCKET — see design.mcp.md §2.1.
8
+ *
9
+ * Wire shape (design.md §29, design.mcp.md §2.1):
10
+ *
11
+ * {"mcpServers":{"fnclaude":{"command":"<bunExec>","args":["<fncBin>","mcp"]}}}
12
+ *
13
+ * Noop sessions add `"--noop"` to args. The Go canonical resolves the exe
14
+ * path via `filepath.EvalSymlinks(os.Executable())`; the TS equivalent is
15
+ * `realpathSync(process.argv[1] ?? '')` paired with `process.execPath` for
16
+ * the runtime that will actually `exec` it. The two-element shape (bun +
17
+ * script path, vs. a single bundled binary in Go) is necessary because
18
+ * fnc.js is a script — claude spawning bare `<fncBin>` would invoke node,
19
+ * not bun, and node can't run the bun-only main.ts.
20
+ *
21
+ * Gate: print mode (-p / --print) doesn't get the config — claude is being
22
+ * driven non-interactively, no MCP tools would be useful. The launcher
23
+ * also skips the call entirely on win32 where there's no listener.
24
+ */
25
+
26
+ export interface InjectMcpConfigArgs {
27
+ claudeArgs: readonly string[];
28
+ /** Path to the bun executable that will run the MCP subprocess (typically process.execPath). */
29
+ bunExec: string;
30
+ /** Absolute path to the fnc bin script (typically realpathSync(process.argv[1])). */
31
+ fncBin: string;
32
+ /** True when the launcher used the noop fallback; appends "--noop" to args. */
33
+ noop: boolean;
34
+ /** False for -p / --print sessions; skips injection per design.md §29 gate. */
35
+ interactive: boolean;
36
+ }
37
+
38
+ export function injectMcpConfig(args: InjectMcpConfigArgs): string[] {
39
+ // Gate per design.md §29: only interactive sessions get the config.
40
+ if (!args.interactive) return [...args.claudeArgs];
41
+ // Bail out if the launcher couldn't resolve its own path. Without an
42
+ // absolute fnc bin the spawned subprocess wouldn't be able to find
43
+ // itself; better to skip than to inject a broken config.
44
+ if (args.fncBin === '') return [...args.claudeArgs];
45
+
46
+ const subprocessArgs: string[] = [args.fncBin, 'mcp'];
47
+ if (args.noop) subprocessArgs.push('--noop');
48
+
49
+ const config = {
50
+ mcpServers: {
51
+ fnclaude: {
52
+ command: args.bunExec,
53
+ args: subprocessArgs,
54
+ },
55
+ },
56
+ };
57
+
58
+ return [...args.claudeArgs, '--mcp-config', JSON.stringify(config)];
59
+ }