@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.
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 -219
  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/main.ts CHANGED
@@ -1,477 +1,620 @@
1
- // Port of run() + main() from src/main.go (Go reference).
1
+ // `fnc`: launch `claude` in the resolved cwd (or the noop fallback when
2
+ // no positional was given). Bun-only (top-level await, Bun.spawn).
2
3
  //
3
- // The integration layer. Composes the already-ported modules into the
4
- // fnclaude orchestration loop:
5
- //
6
- // 1. Parse argv (with --help / --version / `mcp` subcommand short-circuits)
7
- // 2. Resolve user-typed cwd into an absolute path (path + repo lookups)
8
- // 3. Apply -w worktree intercept (may swap cwd to an existing worktree)
9
- // 4. Auto-name if the invocation qualifies (--, prompt, no --name, etc.)
10
- // 5. Sanitize any --name / -n values to a path-safe slug
11
- // 6. Build the claude argv (extra-dirs, self-MCP, auto-tmux, prompts)
12
- // 7. Spawn claude under a PTY with the AF_UNIX socket listener active
13
- // 8. On exit:
14
- // - If the listener fired (auto-handoff): silentRelaunchHandoff
15
- // - Else if claude printed a cross-cwd marker: silentRelaunch
16
- // - Else: propagate claude's exit code
4
+ // This file is the launcher entry. Argv parsing, path resolution, and
5
+ // feature transforms live in their own modules under src/; main composes
6
+ // them in order.
17
7
 
8
+ import { realpathSync } from 'node:fs';
9
+ import { mkdir } from 'node:fs/promises';
18
10
  import { homedir } from 'node:os';
19
- import process from 'node:process';
20
- import { isAbsolute, join } from 'node:path';
21
- import {
22
- defaultLlmClient,
23
- claudeCliFn,
24
- extractPrompt,
25
- generateName,
26
- shouldAutoName,
27
- type LlmClientFn,
28
- } from './autoname.js';
29
- import { parseArgs } from './argParser.js';
30
- import {
31
- brandResolved,
32
- withPassthroughUpdate,
33
- withResolved,
34
- type InterceptedArgs,
35
- type ResolvedArgs,
36
- } from './args.js';
37
- import { buildArgv } from './argv.js';
38
- import { loadConfig, type Config } from './config.js';
39
- import { handoffSocketPath, type HandoffSpec } from './handoff.js';
40
- import { helpText, version, wantsHelp, wantsVersion } from './help.js';
41
- import { loadHostAliases } from './hostAliases.js';
42
- import { expandTildePath } from './paths.js';
43
- import { loadPrompts, type PromptSet } from './prompts.js';
44
- import { detectCrossCwd, runWithPTY } from './pty.js';
45
- import { loadRepoSettings } from './repoSettings.js';
46
- import { Resolve, type RepoSettings, type ResolveDeps } from './resolver.js';
47
- import { sanitizeNamesInPassthrough } from './sanitize.js';
48
- import { runMCPServer } from './mcp/client.js';
49
- import { seedNoop } from './noop.js';
50
- import { silentRelaunch, silentRelaunchHandoff } from './silentRelaunch.js';
51
- import { applyWorktreeIntercept, type GitRunner } from './worktree.js';
52
- import { flushWarnings } from './warnings.js';
53
- import { errorMessage } from './errors.js';
54
-
55
- /**
56
- * `RunIO` — process-shaped seams. Streams, paths, the launch environment,
57
- * and the external behaviour the pipeline depends on (claude binary
58
- * lookup, PTY runner, relaunch, MCP dispatcher, noop seeder, autoname
59
- * LLM call). Plus the *inner* dependency seams of the pipeline modules
60
- * themselves `gitRunner` (consumed by applyWorktreeIntercept) and
61
- * `resolveDeps` (consumed by Resolve) — surfaced here so tests can swap
62
- * the I/O each module does without a wrapper layer of outer functions.
63
- *
64
- * Earlier shape had both outer (`RunDeps.applyWorktreeIntercept`) and
65
- * inner (`applyWorktreeIntercept`'s `GitRunner` parameter) seams for the
66
- * same boundary. Collapsed to the inner seam only — the outer one was
67
- * dead weight in production (the function never varies) and in tests it
68
- * just wrapped the inner seam with two extra lines of closure plumbing.
69
- */
70
- export interface RunIO {
71
- /** Source argv (typically `process.argv.slice(2)`). */
72
- argv?: readonly string[];
73
- /** Stream where the help/version/error text is written. */
74
- stdout?: NodeJS.WriteStream;
75
- stderr?: NodeJS.WriteStream;
76
- /** User's home directory. */
77
- home?: string;
78
- /** Shell cwd at startup. */
79
- cwd?: string;
80
-
81
- /** PATH lookup for the claude binary; returns undefined when not found. */
82
- lookupClaude?: (name: string) => string | undefined;
83
- /** Override the run-with-pty step. */
84
- runWithPTY?: typeof runWithPTY;
85
- /** Override the silent-relaunch step (cross-cwd resume). */
86
- silentRelaunch?: typeof silentRelaunch;
87
- /** Override the silent-relaunch-handoff step. */
88
- silentRelaunchHandoff?: typeof silentRelaunchHandoff;
89
- /** Override runMCPServer (the `mcp` subcommand dispatcher). */
90
- runMCPServer?: typeof runMCPServer;
91
- /** Override seedNoop (best-effort dir seeder). */
92
- seedNoop?: typeof seedNoop;
93
- /** Override generateName for auto-name (skip the LLM call). */
94
- generateName?: typeof generateName;
95
-
96
- /**
97
- * GitRunner for applyWorktreeIntercept. Tests that want to drive a
98
- * fake `git worktree list` reply pass one here; production uses the
99
- * module's `defaultGitRunner`.
100
- */
101
- gitRunner?: GitRunner;
102
-
103
- /**
104
- * Resolver I/O seams (path-exists check, gh CLI, clone). Tests pass a
105
- * stub set so the resolver runs without touching the network or
106
- * filesystem; production uses `productionDeps()` from resolver.ts.
107
- */
108
- resolveDeps?: ResolveDeps;
11
+ import { dirname, join } from 'node:path';
12
+
13
+ import { readArgv } from './argv/intake.ts';
14
+ import { expandAliases } from './argv/expand.ts';
15
+ import { parseArgs } from './argv/parse.ts';
16
+ import { expandShortFlags } from './argv/short-flags.ts';
17
+ import { loadConfig } from './config/load.ts';
18
+ import { reexecSelf, startHandoffAwaiter } from './handoff/awaiter.ts';
19
+ import { handoffTrigger } from './handoff/trigger.ts';
20
+ import { getVersion, helpText, wantsHelp, wantsVersion } from './help-version.ts';
21
+ import { composeEnv } from './launch/compose-env.ts';
22
+ import { decideCrossCwdRelaunch } from './launch/cross-cwd-relaunch.ts';
23
+ import { findClaude } from './launch/find-claude.ts';
24
+ import { readLivePermissionMode } from './launch/live-permission-reader.ts';
25
+ import { RingBuffer } from './launch/ring-buffer.ts';
26
+ import { isMcpSubcommand, parseMcpFlags, runMcpServer } from './mcp/dispatch.ts';
27
+ import { handleCopyToClipboard } from './mcp/handlers/clipboard.ts';
28
+ import { createRestartHandler } from './mcp/handlers/restart.ts';
29
+ import { createSpawnHandler } from './mcp/handlers/spawn.ts';
30
+ import { createSwitchHandler } from './mcp/handlers/switch.ts';
31
+ import { injectMcpConfig } from './mcp/inject-config.ts';
32
+ import { startMcpListener } from './mcp/listener.ts';
33
+ import { createParentDispatcher, stubParentHandlers } from './mcp/parent-dispatch.ts';
34
+ import { computeSocketPath } from './mcp/socket-path.ts';
35
+ import { autoName, shouldAutoName } from './name/auto-name.ts';
36
+ import { AUTO_NAME_MODEL, AUTO_NAME_SYSTEM_PROMPT } from './name/llm-prompt.ts';
37
+ import { sanitizeForPath } from './name/sanitize.ts';
38
+ import { sdkLlmCall } from './name/sdk-llm.ts';
39
+ import { findPromptSentinel, promptBody } from './argv/sentinel.ts';
40
+ import { seedNoopDir } from './noop/seed.ts';
41
+ import { resolveTemplateSourcePath } from './noop/template-source.ts';
42
+ import { ensureCwd } from './path/ensure-cwd.ts';
43
+ import { resolvePromptsDir } from './prompts/dir.ts';
44
+ import { injectFragments, loadFragments } from './prompts/load.ts';
45
+ import { isInteractiveSession, selectFragments } from './prompts/select.ts';
46
+ import { buildCloneUrl, computeCloneDestination } from './repo/clone.ts';
47
+ import { cloneRepo } from './repo/clone-exec.ts';
48
+ import { runGhApi, runGhClone } from './repo/gh-runner.ts';
49
+ import { loadHostAliases } from './repo/host-aliases.ts';
50
+ import { findOwner } from './repo/owner-lookup.ts';
51
+ import { loadRepoSettings } from './repo/repo-settings.ts';
52
+ import { resolveInput } from './repo/resolve-input.ts';
53
+ import { createWarningBuffer } from './warnings/buffer.ts';
54
+ import { shouldInjectTmux } from './worktree/auto-tmux.ts';
55
+ import { listWorktrees } from './worktree/git-list.ts';
56
+ import { applyWorktreeIntercept } from './worktree/intercept.ts';
57
+
58
+ const argv = readArgv();
59
+
60
+ // Non-fatal warnings accumulate here; we flush after claude exits so the
61
+ // user actually sees them. Terminal errors (the `exit(2)` / `exit(127)`
62
+ // paths below) bypass the buffer and write straight to stderr — they're
63
+ // the reason we're not launching, not background noise. Design: §27.
64
+ const warnings = createWarningBuffer();
65
+
66
+ // Internal test hook: dump raw argv before any other work. Lets e2e tests
67
+ // verify the preflight + intake chain preserves `--` without spawning anything.
68
+ if (process.env.FNC_INTERNAL_DUMP_ARGV === '1') {
69
+ process.stdout.write(`${JSON.stringify(argv)}\n`);
70
+ process.exit(0);
71
+ }
72
+
73
+ if (wantsHelp(argv)) {
74
+ process.stdout.write(helpText);
75
+ process.exit(0);
76
+ }
77
+
78
+ if (wantsVersion(argv)) {
79
+ const version = await getVersion();
80
+ process.stdout.write(`fnc ${version}\n`);
81
+ process.exit(0);
109
82
  }
110
83
 
111
- /**
112
- * `RunConfig` pre-loaded data the pipeline reads. When a field is
113
- * supplied here, the corresponding loader (loadConfig / loadPrompts /
114
- * loadRepoSettings / loadHostAliases) is skipped and the supplied value
115
- * is used directly. Tests build a hermetic config payload up-front; in
116
- * production every field is omitted and the loaders run for real.
117
- *
118
- * These were previously expressed as `loadConfig: typeof loadConfig`
119
- * function seams in the unified `RunDeps`. The 1:1 thin-wrapper pattern
120
- * was double-injection — in production they have zero variance, and in
121
- * tests they were always loader stubs that returned a fixed payload.
122
- * Storing the payload directly removes a layer of function plumbing.
123
- */
124
- export interface RunConfig {
125
- /** Pre-loaded config. Omit to call `loadConfig()`. */
126
- config?: Config;
127
- /** Pre-loaded prompts. Omit to call `loadPrompts()`. */
128
- prompts?: PromptSet;
129
- /** Pre-loaded repo settings. Omit to call `loadRepoSettings(home, cwd)`. */
130
- repoSettings?: RepoSettings;
131
- /** Pre-loaded host aliases. Omit to call `loadHostAliases(home)`. */
132
- hostAliases?: Record<string, string>;
84
+ if (isMcpSubcommand(argv)) {
85
+ const exitCode = await runMcpServer(parseMcpFlags(argv.slice(1)));
86
+ process.exit(exitCode);
133
87
  }
134
88
 
135
- /**
136
- * Top-level deps for `run()` two named groups (`io` and `data`),
137
- * each optional, each with optional fields. Tests typically populate
138
- * only the fields they care about. Production omits everything (passes
139
- * `{}` or nothing) and lets every default kick in.
140
- */
141
- export interface RunDeps {
142
- /** Process-shaped seams (streams, env, external behaviours). */
143
- io?: RunIO;
144
- /** Pre-loaded data payloads (skips the corresponding loaders). */
145
- data?: RunConfig;
89
+ // Parse argv into structured launcher inputs. Magic positionals, fnclaude-eaten
90
+ // flags, subcommands, and the passthrough split happen here.
91
+ const parsed = parseArgs(argv);
92
+ if (!parsed.ok) {
93
+ process.stderr.write(`${parsed.error}\n`);
94
+ process.exit(2);
146
95
  }
147
96
 
148
- /**
149
- * Read the user's argv, preferring `FNC_ARGS_JSON` over `process.argv`.
150
- *
151
- * The umbrella shim (packages/fnclaude/bin/fnc.js) sets `FNC_ARGS_JSON`
152
- * on the env it spawns Bun with, because Bun strips the first `--`
153
- * from a script's argv (confirmed empirically across `bun script.js`,
154
- * `bun --`, `bun run`, and shebang invocations). Passing user args via
155
- * the env var sidesteps that Bun never sees them as its own argv.
156
- *
157
- * We DELETE the var after consumption so any child processes the cli
158
- * spawns (claude, gh, the relaunch chain) don't inherit a stale value.
159
- *
160
- * Defensive: malformed or wrong-shape values fall through to
161
- * `process.argv.slice(2)` rather than throwing — a corrupted env var
162
- * shouldn't break the cli, just degrade to the pre-fix behaviour.
163
- */
164
- function readArgvFromEnvOrProcess(): readonly string[] {
165
- const envArgs = process.env.FNC_ARGS_JSON;
166
- if (envArgs !== undefined) {
167
- delete process.env.FNC_ARGS_JSON;
168
- try {
169
- const parsed: unknown = JSON.parse(envArgs);
170
- if (
171
- Array.isArray(parsed) &&
172
- parsed.every((x): x is string => typeof x === 'string')
173
- ) {
174
- return parsed;
97
+ // Load fnclaude config (auto.tmux + other settings the launcher consults).
98
+ const HOME = homedir();
99
+ const shellCwd = process.cwd();
100
+ const configBase = process.env.XDG_CONFIG_HOME ?? join(HOME, '.config');
101
+ const config = await loadConfig({ path: join(configBase, 'fnclaude', 'config.toml') });
102
+
103
+ // Load settings before resolution. Resolution-time settings only need user +
104
+ // managed tiers (project/local require knowing projectRoot, which only matters
105
+ // after launch). The managed-settings path is Linux-only for now; macOS &
106
+ // Windows resolution to come.
107
+ const settings = loadRepoSettings({
108
+ userPath: join(HOME, '.claude', 'settings.json'),
109
+ projectPath: join(shellCwd, '.claude', 'settings.json'),
110
+ localPath: join(shellCwd, '.claude', 'settings.local.json'),
111
+ managedPath: '/etc/claude-code/managed-settings.json',
112
+ });
113
+ const hostAliases = loadHostAliases({
114
+ systemPath: '/usr/share/fnrhombus/host-aliases.json',
115
+ userPath: join(HOME, '.local', 'share', 'fnrhombus', 'host-aliases.json'),
116
+ });
117
+
118
+ // Resolve the first positional (path or repo ref) to a launch cwd. The
119
+ // resolver handles path short-circuit (/, ~, ~/) AND repo-ref refs whose owner
120
+ // is already known (URL forms, owner/name, name@owner, gh:owner/name). Bare
121
+ // names and clone execution route through the gh-CLI branches below; ambiguous
122
+ // matches surface a clean error.
123
+ const resolved = resolveInput({
124
+ input: parsed.firstPath,
125
+ shellCwd,
126
+ home: HOME,
127
+ xdgConfigHome: process.env.XDG_CONFIG_HOME,
128
+ settings: { cloneTemplate: settings.cloneTemplate, hostAliases },
129
+ });
130
+
131
+ let cwd: string;
132
+ let usedNoopFallback = false;
133
+ let workspaceFromRef = '';
134
+ switch (resolved.kind) {
135
+ case 'launch':
136
+ cwd = resolved.launchCwd;
137
+ usedNoopFallback = resolved.usedNoopFallback;
138
+ workspaceFromRef = resolved.workspace;
139
+ if (usedNoopFallback) {
140
+ await mkdir(cwd, { recursive: true });
141
+ // Seed handoff.template.md on first noop-fallback launches (design.md
142
+ // §19). Resolves the template source from <exe-dir> sibling layouts
143
+ // and only copies if dest is missing — never clobbers an existing
144
+ // hand-edited template. Failures here don't block the launch.
145
+ const binPathForSeed = process.argv[1] ?? '';
146
+ const exeDirForSeed = binPathForSeed !== '' ? dirname(realpathSync(binPathForSeed)) : process.cwd();
147
+ const tmplSource = resolveTemplateSourcePath({
148
+ envOverride: process.env.FNC_NOOP_TEMPLATE_PATH,
149
+ exeDir: exeDirForSeed,
150
+ });
151
+ await seedNoopDir({ noopDir: cwd, templateSourcePath: tmplSource.path });
152
+ }
153
+ break;
154
+ case 'needs-clone': {
155
+ // Repo ref resolved cleanly but the destination doesn't exist on disk.
156
+ // Clone it, then launch in the new directory.
157
+ process.stderr.write(`fnclaude: cloning ${resolved.url} → ${resolved.destination}\n`);
158
+ const cloneR = await cloneRepo({
159
+ url: resolved.url,
160
+ destination: resolved.destination,
161
+ ghClone: runGhClone,
162
+ mkdirp: async (path) => {
163
+ await mkdir(path, { recursive: true });
164
+ },
165
+ });
166
+ if (!cloneR.ok) {
167
+ process.stderr.write(`fnclaude: ${cloneR.error}\n`);
168
+ process.exit(2);
169
+ }
170
+ cwd = resolved.destination;
171
+ workspaceFromRef = resolved.workspace;
172
+ break;
173
+ }
174
+ case 'needs-owner-lookup': {
175
+ // Bare-name ref — ask gh which org owns a repo by this name, then
176
+ // re-route through the resolver as if owner had been on the input.
177
+ const ownerR = await findOwner({ name: resolved.name, ghApi: runGhApi });
178
+ if (!ownerR.ok) {
179
+ if (ownerR.reason === 'gh-failed') {
180
+ process.stderr.write(
181
+ `fnclaude: bare name "${resolved.name}" — gh CLI lookup failed (not authenticated? no network?). Try \`gh auth login\` or pass owner explicitly (\`${resolved.name}@<owner>\` or \`<owner>/${resolved.name}\`).\n`,
182
+ );
183
+ } else {
184
+ process.stderr.write(
185
+ `fnclaude: no repo named "${resolved.name}" found under your gh user or any of your orgs.\n`,
186
+ );
175
187
  }
176
- } catch {
177
- // Fall through to process.argv.
188
+ process.exit(2);
189
+ }
190
+ // Build a synthetic ref for the resolved owner and recompute destination.
191
+ const syntheticRef = {
192
+ host: '',
193
+ owner: ownerR.owner,
194
+ name: resolved.name,
195
+ workspace: resolved.workspace,
196
+ original: resolved.name,
197
+ };
198
+ if (settings.cloneTemplate === '') {
199
+ process.stderr.write(
200
+ `fnclaude: cloneTemplate is not configured in repoSettings; cannot resolve bare-name refs.\n`,
201
+ );
202
+ process.exit(2);
203
+ }
204
+ const destR = computeCloneDestination({
205
+ ref: syntheticRef,
206
+ template: settings.cloneTemplate,
207
+ hostAliases,
208
+ home: HOME,
209
+ });
210
+ if (!destR.ok) {
211
+ process.stderr.write(`fnclaude: ${destR.error}\n`);
212
+ process.exit(2);
213
+ }
214
+ // If the destination already exists, just launch there. Otherwise clone.
215
+ const { existsSync } = await import('node:fs');
216
+ if (existsSync(destR.path)) {
217
+ cwd = destR.path;
218
+ workspaceFromRef = resolved.workspace;
219
+ break;
178
220
  }
221
+ const url = buildCloneUrl(syntheticRef);
222
+ process.stderr.write(`fnclaude: cloning ${url} → ${destR.path}\n`);
223
+ const cloneR = await cloneRepo({
224
+ url,
225
+ destination: destR.path,
226
+ ghClone: runGhClone,
227
+ mkdirp: async (path) => {
228
+ await mkdir(path, { recursive: true });
229
+ },
230
+ });
231
+ if (!cloneR.ok) {
232
+ process.stderr.write(`fnclaude: ${cloneR.error}\n`);
233
+ process.exit(2);
234
+ }
235
+ cwd = destR.path;
236
+ workspaceFromRef = resolved.workspace;
237
+ break;
238
+ }
239
+ case 'ambiguous': {
240
+ const both = resolved.cloneDestination ?? resolved.repoRef ?? '?';
241
+ process.stderr.write(
242
+ `fnclaude: ambiguous reference — could be the local directory ${resolved.path} OR ${both}. Disambiguate by typing './<name>' for the local path.\n`,
243
+ );
244
+ process.exit(2);
179
245
  }
180
- return process.argv.slice(2);
246
+ case 'error':
247
+ process.stderr.write(`fnclaude: ${resolved.error}\n`);
248
+ process.exit(2);
181
249
  }
182
250
 
183
- function lookupClaudeFromPath(name: string): string | undefined {
184
- // Bun's PATH lookup: Bun.which() returns null when not found. Coerce to
185
- // undefined to keep the absent-value sentinel consistent with the rest of
186
- // the codebase.
187
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
188
- const bunWhich = (globalThis as any).Bun?.which;
189
- if (typeof bunWhich === 'function') {
190
- return bunWhich(name) ?? undefined;
191
- }
192
- // Fallback: walk PATH ourselves. Avoid `which` shell-out — synchronous and
193
- // brittle. Use spawnSync('which', [name]) only if Bun.which is unavailable
194
- // and we're not on Windows.
195
- return undefined;
251
+ // Worktree intercept: when -w <name> is set, possibly swap cwd to an
252
+ // existing worktree's path. The intercept also pushes `--worktree`/`--name`
253
+ // into passthrough as appropriate per spec §10.
254
+ //
255
+ // `+workspace` suffix on a repo ref (parsed by resolveInput) feeds into here
256
+ // as if the user had typed `-w <workspace>` — but explicit `-w` always wins.
257
+ const effectiveWorktreeSet = parsed.worktreeSet || workspaceFromRef !== '';
258
+ const effectiveWorktreeArg = parsed.worktreeSet ? parsed.worktreeArg : workspaceFromRef;
259
+ const intercept = applyWorktreeIntercept({
260
+ worktreeSet: effectiveWorktreeSet,
261
+ worktreeArg: effectiveWorktreeArg,
262
+ launchCwd: cwd,
263
+ passthrough: parsed.passthrough,
264
+ listWorktrees,
265
+ });
266
+ for (const w of intercept.warnings) warnings.add(w);
267
+ cwd = intercept.launchCwd;
268
+ const parsedWithIntercept = { ...parsed, passthrough: intercept.passthrough };
269
+
270
+ // Build the final claude argv: prepend magic-captured flags (model/effort/
271
+ // subcommand), then expand any short-flag clusters in the passthrough.
272
+ const withAliases = expandAliases(parsedWithIntercept);
273
+ const shortExpanded = expandShortFlags(withAliases);
274
+ if (!shortExpanded.ok) {
275
+ process.stderr.write(`${shortExpanded.error}\n`);
276
+ process.exit(2);
196
277
  }
278
+ let claudeArgs = shortExpanded.tokens;
197
279
 
198
- /**
199
- * The main run loop. Returns the integer exit code that the caller should
200
- * pass to `process.exit`. Never calls `process.exit` itself — that's left
201
- * to `main()` so tests can introspect the return value.
202
- *
203
- * On the silent-relaunch paths (cross-cwd resume or auto-handoff), the
204
- * silentRelaunch* implementations replace the process image on POSIX and
205
- * therefore never return. On any failure mode of the relaunch, we fall
206
- * through to returning claude's exit code, mirroring Go's behavior.
207
- */
208
- export async function run(deps: RunDeps = {}): Promise<number> {
209
- const io = deps.io ?? {};
210
- const data = deps.data ?? {};
211
-
212
- const argv = io.argv ?? readArgvFromEnvOrProcess();
213
- const stdout = io.stdout ?? process.stdout;
214
- const stderr = io.stderr ?? process.stderr;
215
- const home = io.home ?? process.env.HOME ?? homedir();
216
- const shellCWD = io.cwd ?? process.cwd();
217
- const lookupClaude = io.lookupClaude ?? lookupClaudeFromPath;
218
- const runPTY = io.runWithPTY ?? runWithPTY;
219
- const relaunch = io.silentRelaunch ?? silentRelaunch;
220
- const relaunchHandoff = io.silentRelaunchHandoff ?? silentRelaunchHandoff;
221
- const seedNoopFn = io.seedNoop ?? seedNoop;
222
- const generateNameFn = io.generateName ?? generateName;
223
- const runMCPServerFn = io.runMCPServer ?? runMCPServer;
224
-
225
- // Defer-flush warnings on exit, AFTER claude has finished and the user is
226
- // back at their shell. The silent-relaunch path uses execve which skips
227
- // this defer; that's intentional the relaunched fnclaude will re-emit
228
- // any warnings that still apply.
229
- //
230
- // Loaders (loadConfig / loadRepoSettings / loadHostAliases / loadPrompts)
231
- // and other setup steps return their warnings; we accumulate them in this
232
- // local list and drain it via flushWarnings at the deferred-flush point.
233
- // No module-global sink — keeps tests hermetic.
234
- const warnings: string[] = [];
235
- let flushed = false;
236
- const flushOnce = (): void => {
237
- if (flushed) return;
238
- flushed = true;
239
- flushWarnings(warnings, stderr);
280
+ // Auto-tmux: if config has auto.tmux = "worktree" AND this is a brand-new
281
+ // worktree (worktreeSet + no match) AND user didn't opt out, inject --tmux.
282
+ if (
283
+ shouldInjectTmux({
284
+ configAutoTmux: config.autoTmux,
285
+ worktreeSet: parsed.worktreeSet,
286
+ worktreeMatched: intercept.worktreeMatched,
287
+ noTmux: parsed.noTmux,
288
+ passthrough: claudeArgs,
289
+ })
290
+ ) {
291
+ claudeArgs = [...claudeArgs, '--tmux'];
292
+ }
293
+
294
+ // Auto-name: when the user has typed a prompt body via `--` and hasn't given
295
+ // --name / -n (and the session isn't print/resume/continue/from-pr), generate
296
+ // a session name. Spec defaults: 15s timeout, heuristic fallback on error/
297
+ // timeout. When ANTHROPIC_API_KEY is set we hit the API via the SDK directly
298
+ // (saves a claude cold-start); otherwise we shell out to `claude -p`.
299
+ //
300
+ // FNC_INTERNAL_DISABLE_AUTONAME=1 is an internal test escape — when set,
301
+ // autoName is skipped entirely so e2e tests don't have to wait on a real
302
+ // claude -p call (and don't see --name pollute their assertion shapes).
303
+ if (process.env.FNC_INTERNAL_DISABLE_AUTONAME !== '1' && shouldAutoName(parsedWithIntercept)) {
304
+ const sentinelIdx = findPromptSentinel(parsedWithIntercept.passthrough);
305
+ const body = promptBody(parsedWithIntercept.passthrough, sentinelIdx).join(' ').trim();
306
+ const claudePLlmCall = async (prompt: string): Promise<string> => {
307
+ const proc = Bun.spawn(
308
+ ['claude', '-p', '--model', AUTO_NAME_MODEL, `${AUTO_NAME_SYSTEM_PROMPT}\n\nUser request: ${prompt}`],
309
+ { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
310
+ );
311
+ const out = await new Response(proc.stdout).text();
312
+ const exit = await proc.exited;
313
+ if (exit !== 0) throw new Error(`claude -p exited ${exit}`);
314
+ return out;
240
315
  };
316
+ const llmCall = process.env.ANTHROPIC_API_KEY !== undefined ? sdkLlmCall : claudePLlmCall;
317
+ const generated = await autoName({ prompt: body, llmCall, timeoutMs: 15_000 });
318
+ const san = sanitizeForPath(generated);
319
+ const final = san.kind === 'invalid' ? generated : san.value;
320
+ claudeArgs = [...claudeArgs, '--name', final];
321
+ }
241
322
 
242
- try {
243
- // ── --help / --version short-circuits (mirror Go's run()). ────────────
244
- if (wantsHelp(argv)) {
245
- stdout.write(helpText);
246
- return 0;
247
- }
248
- if (wantsVersion(argv)) {
249
- stdout.write(`fnclaude ${version}\n`);
250
- return 0;
251
- }
323
+ // Inject prompt fragments via --append-system-prompt. Selection depends on
324
+ // noop fallback + interactive (non-print) state of the session.
325
+ const fragmentNames = selectFragments({ usedNoopFallback, passthrough: claudeArgs });
326
+ if (fragmentNames.length > 0) {
327
+ // process.argv[1] is the BIN script (bin/fnc.js after preflight, or whatever
328
+ // node invoked). Realpath it so symlinked installs (npm's .bin/ → package
329
+ // bin/) resolve to the actual layout. The "prompts" directory candidates
330
+ // (../prompts, ../share/...) are sibling-relative to that resolved bin.
331
+ const binPath = process.argv[1] ?? '';
332
+ const exeDir = binPath !== '' ? dirname(realpathSync(binPath)) : process.cwd();
333
+ const promptsDir = resolvePromptsDir({
334
+ envOverride: process.env.FNC_PROMPTS_DIR,
335
+ exeDir,
336
+ });
337
+ if (promptsDir.dir !== null) {
338
+ const loaded = loadFragments(fragmentNames, promptsDir.dir);
339
+ for (const w of loaded.warnings) warnings.add(w);
340
+ claudeArgs = injectFragments(claudeArgs, loaded.content);
341
+ } else if (promptsDir.warning !== undefined) {
342
+ warnings.add(promptsDir.warning);
343
+ }
344
+ }
252
345
 
253
- // ── `fnclaude mcp` subcommand dispatch. ──────────────────────────────
254
- if (argv.length >= 1 && argv[0] === 'mcp') {
255
- let noop = false;
256
- for (const a of argv.slice(1)) {
257
- if (a === '--noop') noop = true;
258
- }
259
- return await runMCPServerFn({
260
- noop,
261
- stdin: process.stdin,
262
- stdout: process.stdout,
263
- socketPath: process.env.FNC_SOCKET ?? '',
264
- });
265
- }
346
+ // Compute the MCP socket path. On Unix this also feeds FNC_SOCKET into
347
+ // the child env so the MCP subprocess (which claude spawns per the
348
+ // injected --mcp-config) knows where to dial. On win32, AF_UNIX over
349
+ // Bun.listen({ unix }) isn't supported yet — skip the socket entirely
350
+ // so the launcher still works without self-MCP.
351
+ let mcpSocketPath: string | undefined;
352
+ let mcpListenerStop: (() => Promise<void>) | undefined;
353
+ if (process.platform !== 'win32') {
354
+ mcpSocketPath = computeSocketPath({
355
+ env: process.env,
356
+ pid: process.pid,
357
+ platform: process.platform,
358
+ });
359
+ }
266
360
 
267
- // ── Parse fnclaude's own argv. ────────────────────────────────────────
268
- let parsed;
269
- try {
270
- parsed = parseArgs(argv, home);
271
- } catch (err) {
272
- stderr.write(`${errorMessage(err)}\n`);
273
- return 1;
274
- }
361
+ // Compose the child env: process.env → [exec.env] from config → FNCLAUDE_HANDOFF
362
+ // → FNC_SOCKET. Later entries win against same-name earlier entries per
363
+ // design.md §5.
364
+ const childEnv = composeEnv({
365
+ processEnv: process.env,
366
+ execEnv: config.execEnv,
367
+ handoff: config.autoHandoff,
368
+ socket: mcpSocketPath,
369
+ });
275
370
 
276
- // ── Seed the noop dir iff fallback was used. ─────────────────────────
277
- if (parsed.usedNoopFallback) {
278
- try {
279
- await seedNoopFn(parsed.cwd);
280
- } catch (err) {
281
- warnings.push(`fnclaude: noop seed failed: ${errorMessage(err)}`);
282
- }
283
- }
371
+ // Self-MCP --mcp-config injection (§7.4). Skipped when there's no socket
372
+ // to dial back to (win32 — no listener), and gated to interactive
373
+ // sessions per design.md §29. The fnc bin is realpath'd so symlinked
374
+ // installs (npm's .bin/) resolve to the actual layout; process.execPath
375
+ // is the bun runtime that will exec the subprocess script. Decision: bun
376
+ // + script-path is a two-element shape because fnc.js is a bun script,
377
+ // not a self-contained binary — see decisions.md 2026-05-27 entry.
378
+ if (mcpSocketPath !== undefined) {
379
+ const binPathForMcp = process.argv[1] ?? '';
380
+ const fncBin = binPathForMcp !== '' ? realpathSync(binPathForMcp) : '';
381
+ claudeArgs = injectMcpConfig({
382
+ claudeArgs,
383
+ bunExec: process.execPath,
384
+ fncBin,
385
+ noop: usedNoopFallback,
386
+ interactive: isInteractiveSession(claudeArgs),
387
+ });
388
+ }
284
389
 
285
- // ── Config: pre-loaded or freshly loaded from disk. ──────────────────
286
- let cfg: Config;
287
- if (data.config !== undefined) {
288
- cfg = data.config;
289
- } else {
290
- const loaded = loadConfig();
291
- cfg = loaded.config;
292
- warnings.push(...loaded.warnings);
390
+ // Internal test hook: dump the launch plan as JSON and exit 0 BEFORE spawning
391
+ // claude. Lets e2e tests verify the full pipeline composition (cwd + final
392
+ // claude args) without needing a real claude on PATH or a fake-claude harness.
393
+ if (process.env.FNC_INTERNAL_DUMP_PLAN === '1') {
394
+ // Dump only env values fnclaude actively manages (handoff/socket + execEnv
395
+ // keys) to keep the dump small and predictable in tests. The full process
396
+ // env would leak shell state into snapshots.
397
+ const dumpEnv: Record<string, string> = {};
398
+ if (config.execEnv !== undefined) {
399
+ for (const k of Object.keys(config.execEnv)) {
400
+ if (k in childEnv) dumpEnv[k] = childEnv[k]!;
293
401
  }
402
+ }
403
+ if ('FNCLAUDE_HANDOFF' in childEnv) dumpEnv.FNCLAUDE_HANDOFF = childEnv.FNCLAUDE_HANDOFF!;
404
+ if ('FNC_SOCKET' in childEnv) dumpEnv.FNC_SOCKET = childEnv.FNC_SOCKET!;
405
+ process.stdout.write(
406
+ `${JSON.stringify({ cwd, claudeArgs, usedNoopFallback, env: dumpEnv })}\n`,
407
+ );
408
+ process.exit(0);
409
+ }
294
410
 
295
- // ── Repo-reference resolver (path-or-repo two-lookup). ───────────────
296
- //
297
- // Produces a `ResolvedArgs` either way the resolver path overwrites
298
- // cwd (and possibly worktreeSet/worktreeArg), the tilde-only path
299
- // expands cwd, and the absolute-path / noop-fallback path stamps the
300
- // existing fields straight through.
301
- let resolved: ResolvedArgs;
302
- if (
303
- !parsed.usedNoopFallback &&
304
- parsed.cwd !== '' &&
305
- !isAbsolute(parsed.cwd) &&
306
- !parsed.cwd.startsWith('~')
307
- ) {
308
- let rs: RepoSettings;
309
- if (data.repoSettings !== undefined) {
310
- rs = data.repoSettings;
311
- } else {
312
- const loaded = loadRepoSettings(home, shellCWD);
313
- rs = loaded.settings;
314
- warnings.push(...loaded.warnings);
315
- }
316
- let aliases: Record<string, string>;
317
- if (data.hostAliases !== undefined) {
318
- aliases = data.hostAliases;
319
- } else {
320
- const loaded = loadHostAliases(home);
321
- aliases = loaded.aliases;
322
- warnings.push(...loaded.warnings);
323
- }
324
- let result;
325
- try {
326
- // Resolver's inner deps (path-exists, gh CLI, clone): use the
327
- // injected ones in tests, fall back to productionDeps in real
328
- // runs. Passing `undefined` lets Resolve default-construct
329
- // productionDeps() itself.
330
- result = await (io.resolveDeps
331
- ? Resolve(
332
- {
333
- input: parsed.cwd,
334
- cwd: shellCWD,
335
- home,
336
- settings: rs,
337
- hostAliases: aliases,
338
- },
339
- io.resolveDeps,
340
- )
341
- : Resolve({
342
- input: parsed.cwd,
343
- cwd: shellCWD,
344
- home,
345
- settings: rs,
346
- hostAliases: aliases,
347
- }));
348
- } catch (err) {
349
- stderr.write(`${errorMessage(err)}\n`);
350
- return 1;
351
- }
352
- // If the user's reference had a +workspace suffix AND they didn't
353
- // pass -w explicitly, propagate the workspace to the intercept
354
- // layer.
355
- const promoteWorkspace = !!result.workspace && !parsed.worktreeSet;
356
- resolved = withResolved(parsed, {
357
- cwd: result.path,
358
- ...(promoteWorkspace
359
- ? { worktreeSet: true, worktreeArg: result.workspace! }
360
- : {}),
361
- });
362
- } else if (parsed.cwd.startsWith('~')) {
363
- // Tilde-expand absolute-shaped inputs that didn't go through the
364
- // resolver (resolver expands tildes for its short-circuit path, but
365
- // it isn't called for tilde-prefixed inputs here).
366
- resolved = withResolved(parsed, { cwd: expandTildePath(parsed.cwd) });
367
- } else {
368
- resolved = brandResolved(parsed);
369
- }
411
+ // Verify claude is on PATH before doing any spawn-time setup. Failing here
412
+ // gives a far better error than Bun.spawn's bare ENOENT.
413
+ const claudeBin = findClaude({ pathEnv: process.env.PATH ?? '' });
414
+ if (!claudeBin.ok) {
415
+ process.stderr.write(`${claudeBin.error}\n`);
416
+ process.exit(127);
417
+ }
370
418
 
371
- // ── -w / --worktree intercept. ───────────────────────────────────────
419
+ // Bind the MCP listener (Unix only). Must happen BEFORE Bun.spawn so the
420
+ // subprocess claude launches per --mcp-config can dial back over
421
+ // $FNC_SOCKET. Bind failure is fatal per Go canonical — we can't run
422
+ // without it once tools are wired (§8). design.mcp.md §2.1.
423
+ if (mcpSocketPath !== undefined) {
424
+ try {
425
+ // §7.7 + §8.x: wire per-tool dispatch onto each accepted socket.
426
+ // §8.1 (restart), §8.2 (switch), §8.3 (spawn) and §8.4 (clipboard)
427
+ // replace their stubs; nothing remains stubbed in §8.
372
428
  //
373
- // GitRunner is the inner seam production uses the module's default
374
- // (synchronous `git -C <dir> ...`); tests inject a stub that yields
375
- // the fake `git worktree list --porcelain` shape they want.
376
- const intercepted = io.gitRunner
377
- ? applyWorktreeIntercept(resolved, shellCWD, io.gitRunner)
378
- : applyWorktreeIntercept(resolved, shellCWD);
379
-
380
- // ── Resolve the launch cwd relative to shell cwd. ────────────────────
381
- const launchCWD = isAbsolute(intercepted.cwd)
382
- ? intercepted.cwd
383
- : join(shellCWD, intercepted.cwd);
384
-
385
- // ── Auto-name if qualifying. ──────────────────────────────────────────
386
- let named: InterceptedArgs = intercepted;
387
- if (shouldAutoName(named.passthrough)) {
388
- const prompt = extractPrompt(named.passthrough);
389
- const apiKey = process.env.ANTHROPIC_API_KEY ?? '';
390
- const llmFn: LlmClientFn = apiKey
391
- ? defaultLlmClient(apiKey)
392
- : claudeCliFn(cfg.name.model);
393
- const name = await generateNameFn(prompt, cfg.name, apiKey, llmFn);
394
- named = withPassthroughUpdate(named, {
395
- passthrough: ['--name', name, ...named.passthrough],
396
- });
397
- }
398
-
399
- // ── Sanitize any --name / -n value to a path-safe slug. ──────────────
400
- const sanitizeResult = sanitizeNamesInPassthrough(named.passthrough);
401
- warnings.push(...sanitizeResult.warnings);
402
- const sanitized = withPassthroughUpdate(named, {
403
- passthrough: sanitizeResult.args,
429
+ // Live permission-mode reader binds `cwd` (the launch cwd) at
430
+ // construction so both handlers consume the same
431
+ // `(sessionId) => string | null` shape. Reads claude's
432
+ // `~/.claude/projects/<encoded-cwd>/<sid>.jsonl` for the latest
433
+ // `{type:"permission-mode",...}` record — last-wins, null on miss.
434
+ const livePermissionModeReader = (sessionId: string): string | null =>
435
+ readLivePermissionMode(cwd, sessionId);
436
+ const restartHandler = createRestartHandler({
437
+ origArgs: argv,
438
+ launchCWD: cwd,
439
+ trigger: handoffTrigger,
440
+ livePermissionModeReader,
441
+ });
442
+ const switchHandler = createSwitchHandler({
443
+ origArgs: argv,
444
+ trigger: handoffTrigger,
445
+ livePermissionModeReader,
446
+ });
447
+ const binPathForListener = process.argv[1] ?? '';
448
+ const fncBinAbs = binPathForListener !== '' ? realpathSync(binPathForListener) : '';
449
+ const spawnHandler = createSpawnHandler({
450
+ config: { autoSpawnCommand: config.autoSpawnCommand },
451
+ processEnv: process.env,
452
+ fncBinPath: fncBinAbs,
453
+ handleCopyToClipboard,
404
454
  });
455
+ const dispatcher = createParentDispatcher({
456
+ handlers: {
457
+ ...stubParentHandlers,
458
+ restart: restartHandler,
459
+ switch: switchHandler,
460
+ spawn: spawnHandler,
461
+ copy_to_clipboard: handleCopyToClipboard,
462
+ },
463
+ });
464
+ const listener = await startMcpListener({
465
+ socketPath: mcpSocketPath,
466
+ onConnection: dispatcher,
467
+ });
468
+ mcpListenerStop = listener.stop;
469
+ } catch (err) {
470
+ process.stderr.write(`fnclaude: ${(err as Error).message}\n`);
471
+ process.exit(2);
472
+ }
473
+ }
405
474
 
406
- // ── Build the claude argv. ───────────────────────────────────────────
407
- let prompts: PromptSet;
408
- if (data.prompts !== undefined) {
409
- prompts = data.prompts;
410
- } else {
411
- const loaded = loadPrompts();
412
- prompts = loaded.prompts;
413
- warnings.push(...loaded.warnings);
414
- }
475
+ // Fabricate the cwd tree if missing — Bun.spawn would otherwise return ENOENT
476
+ // blaming the claude binary. The cleanup() unlinks any fabricated dirs right
477
+ // after spawn, since the kernel holds the cwd by inode reference once the
478
+ // child has chdir'd (which posix_spawn does before returning to us).
479
+ const ensured = ensureCwd(cwd);
480
+ if (!ensured.ok) {
481
+ process.stderr.write(`fnclaude: ${ensured.error}\n`);
482
+ process.exit(2);
483
+ }
415
484
 
416
- const claudeArgv = buildArgv(sanitized, shellCWD, cfg, prompts);
485
+ // §9.0: spawn claude via Bun.Terminal on POSIX so the launcher can tee PTY
486
+ // output through a ring buffer for cross-cwd resume detection (§9.1+). On
487
+ // Windows we fall back to stdio inherit until Bun.Terminal lands on win32.
488
+ // Non-TTY contexts (piped stdin, FNC_INTERNAL_DUMP_PLAN tests) also use the
489
+ // inherit shape — raw-mode forwarding requires a real terminal anyway.
490
+ const useTerminal =
491
+ process.platform !== 'win32' &&
492
+ process.stdin.isTTY === true &&
493
+ process.stdout.isTTY === true;
417
494
 
418
- // ── Verify claude is on PATH before starting the PTY. ────────────────
419
- if (lookupClaude('claude') === undefined) {
420
- stderr.write(`fnclaude: claude not found in PATH\n`);
421
- return 1;
422
- }
495
+ // Kernel routes Ctrl-C to the whole foreground pgrp; claude handles its
496
+ // own SIGINT. Swallow it here so fnc survives to read claude's exit code.
497
+ // Under Bun.Terminal the parent isn't in the same pgrp as the child, so
498
+ // these handlers mostly cover the inherit branch — harmless either way.
499
+ process.on('SIGINT', () => {});
500
+ process.on('SIGTERM', () => {});
423
501
 
424
- // ── Build the auto-handoff spec. ─────────────────────────────────────
425
- const hspec: HandoffSpec = {
426
- mode: cfg.auto.handoff,
427
- socketPath: handoffSocketPath(process.pid),
428
- originalArgs: [...argv],
429
- };
502
+ // §9.1: capture the tail of PTY output for §9.2's cross-cwd detection.
503
+ // Hoisted here so it stays reachable after `proc.exited` resolves below.
504
+ // Only meaningful on the useTerminal branch; under stdio inherit the
505
+ // buffer stays empty and post-exit consumers will simply see no match.
506
+ const ringBuffer = new RingBuffer();
430
507
 
431
- const { exitCode, tail, handoffArgv } = await runPTY({
432
- claudeArgv,
433
- launchCWD,
434
- cfg,
435
- handoff: hspec,
508
+ let exitCode: number;
509
+ try {
510
+ let proc: Bun.Subprocess;
511
+ if (useTerminal) {
512
+ // Tee PTY output → process.stdout AND the ring buffer. §9.3 consumes
513
+ // the buffer after exit to scan for claude's cross-cwd resume hint.
514
+ const term = new Bun.Terminal({
515
+ cols: process.stdout.columns ?? 80,
516
+ rows: process.stdout.rows ?? 24,
517
+ data: (_t, chunk) => {
518
+ process.stdout.write(chunk);
519
+ ringBuffer.push(chunk);
520
+ },
436
521
  });
437
522
 
438
- // ── Auto-handoff fires first. ────────────────────────────────────────
439
- if (handoffArgv !== undefined && handoffArgv.length > 0) {
440
- // Flush deferred warnings before relaunch since execve replaces the
441
- // process image (the deferred flush below would be skipped).
442
- flushOnce();
443
- relaunchHandoff(handoffArgv);
444
- // If we get here, execve failed; fall through to cross-cwd detection
445
- // (won't match in practice) and return claude's exit code.
446
- }
523
+ proc = Bun.spawn([claudeBin.path, ...claudeArgs], {
524
+ cwd,
525
+ env: childEnv,
526
+ terminal: term,
527
+ });
447
528
 
448
- // ── Cross-cwd redirect detection. ────────────────────────────────────
449
- if (tail !== undefined) {
450
- const hit = detectCrossCwd(tail);
451
- if (hit !== undefined) {
452
- flushOnce();
453
- relaunch(argv, hit.dest, hit.uuid);
454
- // Same fallthrough as above.
455
- }
456
- }
529
+ // Forward user stdin → PTY. Raw mode so the shell line discipline
530
+ // doesn't eat control sequences (Ctrl-C, arrow keys, etc.) before
531
+ // claude sees them. bun#25779 (control bytes not delivering signals
532
+ // through Bun.Terminal.write) was fixed before 1.3.14 — verified
533
+ // empirically on this version; no byte-interception workaround
534
+ // needed.
535
+ process.stdin.setRawMode(true);
536
+ process.stdin.on('data', (chunk: Buffer) => {
537
+ term.write(chunk);
538
+ });
539
+
540
+ // SIGWINCH no longer reaches claude directly (the PTY is owned by the
541
+ // launcher), so plumb terminal resizes through manually.
542
+ process.stdout.on('resize', () => {
543
+ term.resize(process.stdout.columns ?? 80, process.stdout.rows ?? 24);
544
+ });
545
+ } else {
546
+ proc = Bun.spawn([claudeBin.path, ...claudeArgs], {
547
+ cwd,
548
+ env: childEnv,
549
+ stdin: 'inherit',
550
+ stdout: 'inherit',
551
+ stderr: 'inherit',
552
+ });
553
+ }
554
+
555
+ ensured.cleanup();
457
556
 
458
- return exitCode;
459
- } finally {
460
- flushOnce();
557
+ // §8.5: arm the kill-and-exec awaiter as a side-promise. If an MCP
558
+ // tool dispatches a handoff during the session, the awaiter wakes,
559
+ // SIGTERMs claude (escalating to SIGKILL after 200 ms if needed),
560
+ // waits for proc.exited, then re-execs fnclaude with the stashed
561
+ // argv via Bun.spawn (no native execve binding — see decisions.md).
562
+ // If no handoff fires, this promise sits idle until process exit
563
+ // and gets GC'd; the orphaned-promise pattern matches Go canonical's
564
+ // background goroutine. design.mcp.md §6.
565
+ void startHandoffAwaiter({
566
+ trigger: handoffTrigger,
567
+ proc,
568
+ });
569
+
570
+ exitCode = await proc.exited;
571
+ } finally {
572
+ // Stop the MCP listener + unlink the socket file even if spawn or
573
+ // proc.exited throws. design.mcp.md §7 — socket file cleanup is the
574
+ // parent's job.
575
+ if (mcpListenerStop !== undefined) {
576
+ await mcpListenerStop();
461
577
  }
462
578
  }
463
579
 
464
- /**
465
- * Process entry point. Invokes `run()` and exits with its return code.
466
- * Tests should call `run()` directly so they can introspect the value.
467
- */
468
- export async function main(): Promise<void> {
469
- let code: number;
470
- try {
471
- code = await run();
472
- } catch (err) {
473
- process.stderr.write(`fnclaude: fatal: ${errorMessage(err)}\n`);
474
- code = 1;
475
- }
476
- process.exit(code);
580
+ if (useTerminal) {
581
+ // Restore the terminal so the user's shell prompt comes back in cooked
582
+ // mode. setRawMode is a no-op when stdin isn't a TTY; we only entered
583
+ // raw mode in the useTerminal branch, so this is safe to call.
584
+ process.stdin.setRawMode(false);
585
+ // Stop reading stdin so process.exit doesn't block on an open listener.
586
+ process.stdin.pause();
587
+ }
588
+
589
+ // §9.3: cross-cwd silent relaunch. After a clean exit, scan the ring
590
+ // buffer for claude's "To resume, run: cd X && claude --resume UUID"
591
+ // hint and silently re-exec fnclaude in the new cwd. Gated to skip
592
+ // when an MCP handoff has already stashed argv (that path owns the
593
+ // relaunch) and when claude exited non-zero (don't relaunch on a
594
+ // crash). On Windows the ring buffer stays empty (inherit branch), so
595
+ // the decision always returns false there — keeps the call shape
596
+ // platform-uniform without a separate guard.
597
+ const crossCwdDecision = decideCrossCwdRelaunch({
598
+ exitCode,
599
+ alreadyStashed: handoffTrigger.getStashedArgv() !== null,
600
+ ringSnapshot: ringBuffer.snapshot(),
601
+ origArgs: argv,
602
+ });
603
+ if (crossCwdDecision.relaunch) {
604
+ // Stash so future getStashedArgv() callers see this relaunch as
605
+ // owned. The cross-cwd path is "silent" — we skip the warnings
606
+ // flush below; the new fnclaude process re-evaluates and re-queues
607
+ // anything still applicable.
608
+ handoffTrigger.stashArgv(crossCwdDecision.argv);
609
+ await reexecSelf({ argv: crossCwdDecision.argv });
610
+ // Unreachable: reexecSelf calls process.exit. Typed as Promise<never>.
477
611
  }
612
+
613
+ // Flush accumulated warnings to stderr now that claude has exited and the
614
+ // user is back at their shell prompt where they have time to read them.
615
+ // Silent-relaunch paths (cross-cwd resume above; MCP handoff via the
616
+ // awaiter side-promise) skip this flush — the new fnclaude process
617
+ // re-evaluates and re-queues any still-applicable warnings.
618
+ warnings.flush(process.stderr);
619
+
620
+ process.exit(exitCode);