@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
@@ -0,0 +1,140 @@
1
+ /**
2
+ * §8.5 — Production wiring for the kill-and-exec side-promise.
3
+ *
4
+ * `startHandoffAwaiter` is the parent-side glue that hooks the trigger
5
+ * (fired by MCP dispatch) up to the kill sequence + process re-exec.
6
+ * Spec: docs/design.mcp.md §6.
7
+ *
8
+ * The returned promise is "fire-and-forget" by design — if no handoff
9
+ * ever fires, the awaiter sits idle for the lifetime of the session,
10
+ * then the orphaned promise gets garbage-collected with the rest of
11
+ * main.ts's state when the parent exits naturally. If a handoff DOES
12
+ * fire, the kill sequence runs, claude exits, and then the re-exec
13
+ * either swaps the process image (true execve, not available under
14
+ * Bun) or in the Bun.spawn-based shim, spawns a child, waits, then
15
+ * `process.exit`s with the child's code.
16
+ *
17
+ * Tests pass injected `execve`/`signalSend`/`sleep` so they can
18
+ * exercise the wiring without actually killing or re-executing.
19
+ */
20
+
21
+ import { killAndExec, type KillAndExecArgs, type SignalName } from './kill-and-exec.ts';
22
+ import type { HandoffTrigger } from './trigger.ts';
23
+
24
+ export interface StartHandoffAwaiterArgs {
25
+ trigger: HandoffTrigger;
26
+ proc: Pick<Bun.Subprocess, 'exited' | 'kill'>;
27
+ /** Optional override for the kill-and-exec primitive (test seam). */
28
+ killAndExec?: (a: KillAndExecArgs) => Promise<void>;
29
+ /** Optional override for the signal sender (test seam). */
30
+ signalSend?: (signal: SignalName) => void;
31
+ /** Optional override for sleep (test seam). */
32
+ sleep?: (ms: number) => Promise<void>;
33
+ /** Optional override for the re-exec primitive (test seam). */
34
+ execve?: (argv: string[]) => void | Promise<void>;
35
+ platform?: NodeJS.Platform;
36
+ }
37
+
38
+ /**
39
+ * Start the awaiter. Returns the side-promise (caller is expected to
40
+ * fire-and-forget; no need to await unless you want the kill+exec to
41
+ * complete first). If the trigger never fires, the promise sits
42
+ * pending until process exit.
43
+ */
44
+ export function startHandoffAwaiter(args: StartHandoffAwaiterArgs): Promise<void> {
45
+ const platform = args.platform ?? process.platform;
46
+ const sleep =
47
+ args.sleep ??
48
+ ((ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)));
49
+ const signalSend =
50
+ args.signalSend ??
51
+ ((signal: SignalName): void => {
52
+ // proc.kill returns false if the process has already exited; that's
53
+ // expected after the 200 ms grace window when SIGTERM landed. Don't
54
+ // throw — the parent's only job left is to await `exited` and
55
+ // re-exec.
56
+ try {
57
+ args.proc.kill(signal);
58
+ } catch {
59
+ // ESRCH/EPERM: already-reaped or out-of-our-pgrp. Ignore.
60
+ }
61
+ });
62
+ const execve = args.execve ?? defaultExecve;
63
+ const killFn = args.killAndExec ?? killAndExec;
64
+
65
+ return (async (): Promise<void> => {
66
+ await args.trigger.awaitTrigger();
67
+ const stashedArgv = args.trigger.getStashedArgv();
68
+ if (stashedArgv === null) {
69
+ // Trigger fired but nothing stashed — shouldn't happen under the
70
+ // §6.1 contract (the dispatcher always stashes before firing), but
71
+ // bail rather than re-exec into a nil argv if it ever does.
72
+ return;
73
+ }
74
+ await killFn({
75
+ proc: args.proc,
76
+ stashedArgv,
77
+ signalSend,
78
+ sleep,
79
+ execve,
80
+ platform,
81
+ });
82
+ })();
83
+ }
84
+
85
+ /**
86
+ * Default re-exec primitive. Wraps the reusable `reexecSelf` helper —
87
+ * shared with §9.3's cross-cwd silent relaunch path.
88
+ */
89
+ async function defaultExecve(argv: string[]): Promise<void> {
90
+ await reexecSelf({ argv });
91
+ }
92
+
93
+ /**
94
+ * Process image replacement via Bun.spawn — shared between §8.5's
95
+ * handoff exec and §9.3's cross-cwd silent relaunch.
96
+ *
97
+ * Bun has no `execve` binding — true process image replacement isn't
98
+ * possible. The closest stable analog is
99
+ * `Bun.spawn(process.execPath, [bin, ...argv])`, then await the
100
+ * child's exit and `process.exit` with the child's code.
101
+ *
102
+ * The fnc bin lives at `process.argv[1]` (already resolved by the
103
+ * shim) by default; callers can override via `args.fncBin`. The same
104
+ * runtime that's currently executing (`process.execPath` by default)
105
+ * hosts the child so any preflight/argv-rehydration step runs
106
+ * identically on the relaunch.
107
+ *
108
+ * Never returns on success — `process.exit(code)` ends the parent
109
+ * before this promise resolves. The signature is `Promise<never>` to
110
+ * keep TypeScript honest about the control-flow.
111
+ *
112
+ * Deviation from Go canonical (true execve) is documented in
113
+ * docs/decisions.md.
114
+ */
115
+ export interface ReexecSelfArgs {
116
+ /** Argv to hand the new fnclaude process (excluding bin path / runtime). */
117
+ argv: string[];
118
+ /** Override the Bun executable. Defaults to `process.execPath`. */
119
+ bunExec?: string;
120
+ /**
121
+ * Override the fnc bin path passed to bun as argv[0]. Defaults to
122
+ * `process.argv[1] ?? ''` — the same script that the parent is
123
+ * running.
124
+ */
125
+ fncBin?: string;
126
+ }
127
+
128
+ export async function reexecSelf(args: ReexecSelfArgs): Promise<never> {
129
+ const bunExec = args.bunExec ?? process.execPath;
130
+ const fncBin = args.fncBin ?? process.argv[1] ?? '';
131
+ const child = Bun.spawn([bunExec, fncBin, ...args.argv], {
132
+ cwd: process.cwd(),
133
+ env: process.env,
134
+ stdin: 'inherit',
135
+ stdout: 'inherit',
136
+ stderr: 'inherit',
137
+ });
138
+ const code = await child.exited;
139
+ process.exit(code);
140
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * §8.3 — pure env scrubber for `fnc_spawn_session`.
3
+ *
4
+ * The spawned sibling fnclaude is its own independent session, so the
5
+ * three session-scoped vars must NOT leak across the spawn boundary:
6
+ *
7
+ * - `FNC_SOCKET` points at *this* parent's listener; the sibling has
8
+ * to compute its own socket path. If we leaked it, the new
9
+ * claude's MCP subprocess would dial back into us instead of its
10
+ * own parent.
11
+ * - `FNCLAUDE_HANDOFF` was injected for *this* session's claude.
12
+ * - `CLAUDE_CODE_SESSION_ID` likewise scopes to this session.
13
+ *
14
+ * Everything else (`PATH`, `XDG_*`, exec.env contributions, etc.)
15
+ * passes through unchanged so the sibling inherits the same user
16
+ * environment.
17
+ *
18
+ * Ports Go canonical's `cleanEnvForSpawn` (`fnclaude@fnrhombus/src/spawn.go`).
19
+ * The TS shape is a `Record<string,string>` because Bun.spawn's `env`
20
+ * option takes the object form; Go was producing `KEY=VALUE` slices for
21
+ * `exec.Cmd.Env`. Same semantics either way.
22
+ */
23
+
24
+ const DROP: ReadonlySet<string> = new Set([
25
+ 'FNC_SOCKET',
26
+ 'FNCLAUDE_HANDOFF',
27
+ 'CLAUDE_CODE_SESSION_ID',
28
+ ]);
29
+
30
+ /**
31
+ * Return a fresh env object with the three session-scoped keys removed.
32
+ * Non-string values (undefined slots on process.env) are skipped — the
33
+ * Bun.spawn `env` contract is `Record<string,string>`.
34
+ */
35
+ export function cleanEnvForSpawn(
36
+ env: NodeJS.ProcessEnv | Record<string, string | undefined>,
37
+ ): Record<string, string> {
38
+ const out: Record<string, string> = {};
39
+ for (const [k, v] of Object.entries(env)) {
40
+ if (DROP.has(k)) continue;
41
+ if (typeof v !== 'string') continue;
42
+ out[k] = v;
43
+ }
44
+ return out;
45
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * §8.5 — Kill sequence + process image replacement.
3
+ *
4
+ * When the parent's `awaitTrigger()` resolves (the MCP dispatcher fired
5
+ * a handoff), the parent has to:
6
+ *
7
+ * 1. Send SIGTERM to the claude subprocess.
8
+ * 2. Wait 200 ms.
9
+ * 3. If claude hasn't exited, send SIGKILL.
10
+ * 4. Await `proc.exited` so we know claude is reaped.
11
+ * 5. Replace the parent's process image with `fnclaude <stashed argv>`.
12
+ *
13
+ * On Windows the kill sequence collapses to a single TerminateProcess-
14
+ * equivalent (no graceful path), per design.mcp.md §6.1.
15
+ *
16
+ * **Why a side-effect injection seam:** the real `execve` and the real
17
+ * `process.kill` are end-of-line operations — once they fire, the
18
+ * process is gone or has different bytes in memory. Tests must run them
19
+ * as injected callbacks so the test harness keeps running afterwards.
20
+ *
21
+ * **Why not real execve:** TS/Bun has no `execve` binding (Go's
22
+ * `syscall.Exec` swaps the running process image in place). The closest
23
+ * stable analog under Bun is `Bun.spawn(process.execPath, [bin, ...argv])`
24
+ * — a child that inherits stdio and that the parent waits on. The
25
+ * decision to use spawn-and-wait instead of true execve is documented
26
+ * in docs/decisions.md ("Process image replacement via Bun.spawn").
27
+ *
28
+ * Design: docs/design.mcp.md §6.1, §6.2.
29
+ */
30
+
31
+ export type SignalName = 'SIGTERM' | 'SIGKILL';
32
+
33
+ export interface KillAndExecArgs {
34
+ /** Subprocess to terminate. Only `exited` is awaited; killing is via signalSend. */
35
+ proc: Pick<Bun.Subprocess, 'exited'>;
36
+ /** Argv to relaunch with after claude exits. */
37
+ stashedArgv: string[];
38
+ /**
39
+ * Deliver a signal to the subprocess. Injected so tests can record
40
+ * signals without actually killing anything. Production wires this
41
+ * to `proc.kill(<signal>)`.
42
+ */
43
+ signalSend: (signal: SignalName) => void;
44
+ /** Async sleep, injected so tests don't actually wait. */
45
+ sleep: (ms: number) => Promise<void>;
46
+ /**
47
+ * Process-image-replacement. Injected so tests don't actually re-exec.
48
+ * Production wires this to a `Bun.spawn` + `await child.exited` →
49
+ * `process.exit(<code>)` sequence (see docs/decisions.md for why
50
+ * Bun.spawn instead of native execve).
51
+ */
52
+ execve: (argv: string[]) => void | Promise<void>;
53
+ /** `process.platform`. `win32` collapses the kill sequence to one signal. */
54
+ platform: NodeJS.Platform;
55
+ }
56
+
57
+ const KILL_GRACE_MS = 200;
58
+
59
+ export async function killAndExec(args: KillAndExecArgs): Promise<void> {
60
+ if (args.platform === 'win32') {
61
+ // No graceful path on Windows — the closest analog to TerminateProcess
62
+ // is a hard kill, surfaced through the same signalSend seam (callers
63
+ // map it to `proc.kill()` on win32).
64
+ args.signalSend('SIGKILL');
65
+ } else {
66
+ args.signalSend('SIGTERM');
67
+ await args.sleep(KILL_GRACE_MS);
68
+ // Race-aware: if proc has already exited by now, SIGKILL on a reaped
69
+ // PID is a no-op error at the OS level; we hand it to signalSend
70
+ // anyway and let production wiring (proc.kill) swallow the EPERM/
71
+ // ESRCH. Tests that resolve `exited` on SIGTERM never see a SIGKILL
72
+ // because we check before sending.
73
+ const stillAlive = await isStillRunning(args.proc.exited);
74
+ if (stillAlive) {
75
+ args.signalSend('SIGKILL');
76
+ }
77
+ }
78
+
79
+ // Wait for claude to be fully reaped before we re-exec. On Unix this
80
+ // is the moment after the kernel delivers the signal and the parent
81
+ // collects the exit status; without it the new process image could
82
+ // race against the dying child's last writes to the controlling TTY.
83
+ await args.proc.exited;
84
+
85
+ await args.execve(args.stashedArgv);
86
+ }
87
+
88
+ /**
89
+ * Resolve true iff `exited` has NOT resolved by the next macrotask.
90
+ * Used to gate the SIGKILL escalation — we only want to send the
91
+ * hard-kill if SIGTERM didn't already cause the process to exit during
92
+ * the 200 ms grace window.
93
+ *
94
+ * The `setTimeout(0)` is important: a plain `Promise.resolve()` race
95
+ * loses to an already-settled `exited` only if the .then() chain has
96
+ * already drained. A short macrotask boundary gives the existing
97
+ * promise pipeline (including .then chains hung off `exited` by the
98
+ * test fake or by Bun.Subprocess itself) time to settle before we
99
+ * decide whether to escalate.
100
+ */
101
+ async function isStillRunning(exited: Promise<unknown>): Promise<boolean> {
102
+ const marker = Symbol('still-alive');
103
+ const result = await Promise.race([
104
+ exited.then(() => 'exited' as const),
105
+ new Promise<typeof marker>((resolve) => {
106
+ setTimeout(() => resolve(marker), 0);
107
+ }),
108
+ ]);
109
+ return result === marker;
110
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * §8.3 — spawn-launcher decision + dispatch for `fnc_spawn_session`.
3
+ *
4
+ * Decision order (per design.mcp.md §4.3):
5
+ *
6
+ * 1. `auto.spawnCommand` from config — tokenize on whitespace,
7
+ * substitute `{bin}` / `{dest}` / `{name}` / `{summary}` per token.
8
+ * 2. `$TMUX` in env — use the built-in
9
+ * `tmux new-window -d {bin} {dest} --name {name} @{summary}`
10
+ * template. Being inside tmux is an explicit declaration that
11
+ * tmux is the windowing layer.
12
+ * 3. Nothing — caller falls back to paste-flow.
13
+ *
14
+ * Earlier ports also sniffed `$KITTY_WINDOW_ID`, `$TERM_PROGRAM=WezTerm`,
15
+ * and `$WT_SESSION`. Those allowlist heuristics grew indefinitely and
16
+ * silently failed for everyone else. The `auto.spawnCommand` config
17
+ * knob is strictly better: one surface, every terminal.
18
+ *
19
+ * Ports Go canonical's `spawnSiblingImpl` + `autoDetectSpawnCommand` +
20
+ * `buildSpawnArgv` from `fnclaude@fnrhombus/src/spawn.go`. Substitution
21
+ * is per-token after whitespace splitting — a `{dest}` expanding to a
22
+ * path with spaces stays one argv entry, no shell involvement.
23
+ *
24
+ * Pure module: no I/O, no env reads, no Bun.spawn calls of its own.
25
+ * The caller supplies env, fncBin, the spawn function, and the
26
+ * autoSpawnCommand config value. Tests inject everything.
27
+ */
28
+
29
+ const TMUX_TEMPLATE = 'tmux new-window -d {bin} {dest} --name {name} @{summary}';
30
+
31
+ /**
32
+ * Minimal spawn surface the launcher exercises. The real shape is a
33
+ * thin adapter over `Bun.spawn` in production (see {@link defaultSpawn}).
34
+ */
35
+ export interface SpawnFn {
36
+ (
37
+ argv: readonly string[],
38
+ opts: { env: Record<string, string> },
39
+ ): { unref?: () => void };
40
+ }
41
+
42
+ export interface ChooseAndSpawnArgs {
43
+ /** `cfg.auto.spawnCommand` — empty string means "not configured". */
44
+ autoSpawnCommand: string;
45
+ /** Env to consult for `$TMUX` auto-detection. */
46
+ env: NodeJS.ProcessEnv | Record<string, string | undefined>;
47
+ /** Cleaned env to pass through to the spawn (already scrubbed of session vars). */
48
+ spawnEnv: Record<string, string>;
49
+ /** Absolute fnclaude binary path for `{bin}` substitution. */
50
+ fncBin: string;
51
+ /** `{dest}` substitution — destination project ref / path. */
52
+ dest: string;
53
+ /** `{name}` substitution — session label. */
54
+ name: string;
55
+ /** `{summary}` substitution — absolute path to the written summary file. */
56
+ summary: string;
57
+ /** Override extras to append after the templated argv (override flags from applyOverrides). */
58
+ extraArgs: readonly string[];
59
+ /** Injected spawn fn (production: {@link defaultSpawn}). */
60
+ spawn: SpawnFn;
61
+ }
62
+
63
+ export type ChooseAndSpawnResult =
64
+ | { ok: true }
65
+ /** No launcher resolved — caller falls back to paste-flow. `command` is the
66
+ * rendered relaunch command for the user to paste. */
67
+ | { ok: false; command: string };
68
+
69
+ /**
70
+ * Pick a launcher and dispatch. Returns `{ ok: true }` on a clean
71
+ * `spawn()` call; returns `{ ok: false, command }` when neither
72
+ * `autoSpawnCommand` nor `$TMUX` resolved a template.
73
+ *
74
+ * Throws only when a launcher WAS resolved but `spawn` itself threw —
75
+ * matches Go canonical's `(false, err)` shape (caller surfaces the
76
+ * error response). Empty-argv-from-template is also an error.
77
+ */
78
+ export function chooseAndSpawn(args: ChooseAndSpawnArgs): ChooseAndSpawnResult {
79
+ const tmpl = pickTemplate(args.autoSpawnCommand, args.env);
80
+ if (tmpl === '') {
81
+ return { ok: false, command: renderSpawnCommand(args) };
82
+ }
83
+
84
+ const argv = buildSpawnArgv(tmpl, args.fncBin, args.dest, args.name, args.summary);
85
+ if (argv.length === 0) {
86
+ throw new Error(`spawn template produced empty argv: ${JSON.stringify(tmpl)}`);
87
+ }
88
+ const fullArgv = [...argv, ...args.extraArgs];
89
+
90
+ const proc = args.spawn(fullArgv, { env: args.spawnEnv });
91
+ // Detach so the launcher (tmux, kitty @, etc.) can outlive the parent.
92
+ // tmux's `-d` already does the daemonization; unref is best-effort
93
+ // belt-and-braces for spawners that wait by default.
94
+ try {
95
+ proc.unref?.();
96
+ } catch {
97
+ // ignore — process already detached
98
+ }
99
+ return { ok: true };
100
+ }
101
+
102
+ /**
103
+ * Decide which launcher template to use:
104
+ *
105
+ * - Non-empty `autoSpawnCommand` wins.
106
+ * - `$TMUX` non-empty → built-in tmux template.
107
+ * - Otherwise empty string (caller falls back to paste-flow).
108
+ */
109
+ function pickTemplate(
110
+ autoSpawnCommand: string,
111
+ env: NodeJS.ProcessEnv | Record<string, string | undefined>,
112
+ ): string {
113
+ if (autoSpawnCommand !== '') return autoSpawnCommand;
114
+ const tmux = env.TMUX;
115
+ if (typeof tmux === 'string' && tmux !== '') return TMUX_TEMPLATE;
116
+ return '';
117
+ }
118
+
119
+ /**
120
+ * Whitespace-tokenize `tmpl`, then per-token substitute the four
121
+ * placeholders. No shell involvement — each token becomes one argv
122
+ * entry verbatim. A `{dest}` expanding to a path with spaces stays
123
+ * one argv entry.
124
+ */
125
+ export function buildSpawnArgv(
126
+ tmpl: string,
127
+ bin: string,
128
+ dest: string,
129
+ name: string,
130
+ summary: string,
131
+ ): string[] {
132
+ // .split(/\s+/) leaves a leading "" when tmpl starts with whitespace;
133
+ // filter empties to match Go's strings.Fields behavior.
134
+ const tokens = tmpl.split(/\s+/).filter((t) => t !== '');
135
+ const out: string[] = [];
136
+ for (const t of tokens) {
137
+ out.push(
138
+ t
139
+ .replaceAll('{bin}', bin)
140
+ .replaceAll('{dest}', dest)
141
+ .replaceAll('{name}', name)
142
+ .replaceAll('{summary}', summary),
143
+ );
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /**
149
+ * Render the user-visible relaunch command for paste-flow Responses.
150
+ * Mirrors Go canonical's `renderSpawnCommand`: `fnclaude <dest> --name
151
+ * <name> @<summary> [extra args]`. Override values are controlled-
152
+ * vocabulary strings (model aliases, effort levels, etc.); space-
153
+ * joining them is shell-safe by construction.
154
+ */
155
+ export function renderSpawnCommand(args: {
156
+ dest: string;
157
+ name: string;
158
+ summary: string;
159
+ extraArgs: readonly string[];
160
+ }): string {
161
+ let cmd = `fnclaude ${args.dest} --name ${args.name} @${args.summary}`;
162
+ if (args.extraArgs.length > 0) {
163
+ cmd += ' ' + args.extraArgs.join(' ');
164
+ }
165
+ return cmd;
166
+ }
167
+
168
+ /**
169
+ * Production spawn adapter. Thin wrapper over `Bun.spawn` — pipes nothing,
170
+ * inherits no stdio (the spawned launcher is its own session under its
171
+ * own terminal). Detached so the launcher outlives the parent fnclaude.
172
+ */
173
+ export const defaultSpawn: SpawnFn = (argv, opts) => {
174
+ const proc = Bun.spawn([...argv], {
175
+ env: opts.env,
176
+ stdin: 'ignore',
177
+ stdout: 'ignore',
178
+ stderr: 'ignore',
179
+ });
180
+ return {
181
+ unref(): void {
182
+ proc.unref();
183
+ },
184
+ };
185
+ };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Handoff summary content file — pure write.
3
+ *
4
+ * `fnc_switch_project` and `fnc_spawn_session` write the markdown
5
+ * summary that the destination session auto-loads (via `@<path>` in the
6
+ * relaunch argv) to a fresh file on disk. The path formula:
7
+ *
8
+ * <base>/fnclaude-handoff-content-<16hex>.md
9
+ *
10
+ * `<base>` resolves from `$XDG_RUNTIME_DIR` if set (Linux/systemd
11
+ * tmpfs, mode 700, cleared on logout — restrictive by default so other
12
+ * users on the box can't read the content). Otherwise `os.tmpdir()`
13
+ * (the OS-native fallback: macOS launchd / Windows per-user TEMP both
14
+ * already have restrictive ACLs). See design.md §14.
15
+ *
16
+ * The file itself is written with mode 0600 — same belt-and-suspenders
17
+ * the Go canonical uses, since the surrounding tmpfs already restricts
18
+ * access. Handoff summaries can contain conversation context, tool
19
+ * results, etc.
20
+ *
21
+ * `<16hex>` is 8 bytes of crypto-random hex. PID + nanosecond is
22
+ * NOT used as the primary form here (Go's fallback path) — Node's
23
+ * `crypto.randomBytes` doesn't fail on a healthy system, so a tighter
24
+ * always-random shape stays adequate without the extra branch.
25
+ *
26
+ * The base-dir resolver and random-hex source are both injected for
27
+ * tests. Production callers use the defaults (`defaultBaseDir`,
28
+ * `defaultRandomHex`).
29
+ *
30
+ * Design: docs/design.mcp.md §4.2, docs/design.md §14.
31
+ */
32
+
33
+ import { randomBytes } from 'node:crypto';
34
+ import { writeFile } from 'node:fs/promises';
35
+ import { tmpdir } from 'node:os';
36
+ import { join } from 'node:path';
37
+
38
+ /** Resolve the base directory for handoff content files. */
39
+ export type BaseDirResolver = () => string;
40
+
41
+ /** Generate 16 hex characters (8 bytes of entropy) for the filename token. */
42
+ export type RandomHexFn = () => string;
43
+
44
+ export interface WriteSummaryFileArgs {
45
+ summary: string;
46
+ baseDir?: BaseDirResolver;
47
+ randomHex?: RandomHexFn;
48
+ }
49
+
50
+ export interface WriteSummaryFileResult {
51
+ path: string;
52
+ }
53
+
54
+ /**
55
+ * Write `summary` to `<base>/fnclaude-handoff-content-<16hex>.md` with
56
+ * mode 0600 and return the resolved path. Throws if the write fails —
57
+ * the caller (switch handler) surfaces that as an `action: 'error'`
58
+ * response.
59
+ */
60
+ export async function writeSummaryFile(
61
+ args: WriteSummaryFileArgs,
62
+ ): Promise<WriteSummaryFileResult> {
63
+ const baseDir = (args.baseDir ?? defaultBaseDir)();
64
+ const hex = (args.randomHex ?? defaultRandomHex)();
65
+ const path = join(baseDir, `fnclaude-handoff-content-${hex}.md`);
66
+ await writeFile(path, args.summary, { mode: 0o600 });
67
+ return { path };
68
+ }
69
+
70
+ /**
71
+ * Default base dir: `$XDG_RUNTIME_DIR` if set, else `os.tmpdir()`.
72
+ * Mirrors Go canonical's `handoffBaseDir`.
73
+ */
74
+ export const defaultBaseDir: BaseDirResolver = () => {
75
+ const xdg = process.env.XDG_RUNTIME_DIR;
76
+ if (xdg !== undefined && xdg !== '') {
77
+ return xdg;
78
+ }
79
+ return tmpdir();
80
+ };
81
+
82
+ /**
83
+ * Default random-hex source: 8 bytes of crypto entropy → 16 hex chars.
84
+ * Matches Go canonical's `rand.Read(make([]byte, 8))` + `hex.EncodeToString`.
85
+ */
86
+ export const defaultRandomHex: RandomHexFn = () => randomBytes(8).toString('hex');
@@ -0,0 +1,90 @@
1
+ /**
2
+ * §8.5 — Handoff trigger primitive.
3
+ *
4
+ * The parent's MCP dispatch goroutine fires `Triggered` when a `restart`
5
+ * or non-`never`-mode `switch` arrives; a separate awaiter (started at
6
+ * parent startup) blocks on it until then. Two pieces:
7
+ *
8
+ * 1. `stashArgv` — first-stash-wins shared field for the relaunch
9
+ * argv. Mirrors Go canonical's `sync.Mutex` + nil-check pattern
10
+ * (mcpserver.HandoffTrigger.Stash, see Go src/handoff.go for the
11
+ * reference). The "rare race" in design.mcp.md §8 (concurrent
12
+ * restart + switch) lands here: both stashes succeed at the JSON-
13
+ * RPC layer; only the first one's argv survives to drive the kill.
14
+ *
15
+ * 2. `fire` + `awaitTrigger` — one-shot signal. `fire` is idempotent;
16
+ * a second call is a no-op. `awaitTrigger` returns a promise that
17
+ * resolves the moment `fire` runs, or immediately if `fire` already
18
+ * ran before the await was created. Multiple awaiters are fine —
19
+ * all resolve on the same fire.
20
+ *
21
+ * Design: docs/design.mcp.md §6.1, §8 (concurrent-dispatch race).
22
+ */
23
+
24
+ export interface HandoffTrigger {
25
+ /**
26
+ * Stash the relaunch argv. Returns true on the first call (argv now
27
+ * owned by the trigger), false on every subsequent call (the caller's
28
+ * argv is dropped silently — first-stash-wins semantics).
29
+ */
30
+ stashArgv: (argv: string[]) => boolean;
31
+ /**
32
+ * Read back the stashed argv. Null when no stash has happened yet.
33
+ * The reference is shared — callers must not mutate.
34
+ */
35
+ getStashedArgv: () => string[] | null;
36
+ /**
37
+ * Fire the trigger. Idempotent — a second call is a no-op (the
38
+ * underlying promise stays resolved; no second resolution happens).
39
+ */
40
+ fire: () => void;
41
+ /**
42
+ * Resolve when `fire` has been called (now or in the future). Multiple
43
+ * awaiters all see the same fire. If `fire` already ran, the returned
44
+ * promise resolves on the next microtask.
45
+ */
46
+ awaitTrigger: () => Promise<void>;
47
+ }
48
+
49
+ /**
50
+ * Module-level singleton mirroring Go canonical's
51
+ * `mcpserver.HandoffTrigger`. The parent's MCP dispatch tools and the
52
+ * kill-and-exec awaiter both refer to this one instance; tests that
53
+ * exercise the trigger contract should use `createHandoffTrigger`
54
+ * directly to keep state hermetic.
55
+ */
56
+ export const handoffTrigger: HandoffTrigger = createHandoffTriggerFactory();
57
+
58
+ export function createHandoffTrigger(): HandoffTrigger {
59
+ return createHandoffTriggerFactory();
60
+ }
61
+
62
+ function createHandoffTriggerFactory(): HandoffTrigger {
63
+ let stashed: string[] | null = null;
64
+ let fired = false;
65
+ let resolveFn: (() => void) | null = null;
66
+ const triggered = new Promise<void>((resolve) => {
67
+ resolveFn = resolve;
68
+ });
69
+
70
+ return {
71
+ stashArgv(argv: string[]): boolean {
72
+ if (stashed !== null) return false;
73
+ stashed = argv;
74
+ return true;
75
+ },
76
+ getStashedArgv(): string[] | null {
77
+ return stashed;
78
+ },
79
+ fire(): void {
80
+ if (fired) return;
81
+ fired = true;
82
+ // `resolveFn` is always set by the time fire() can run — the
83
+ // Promise constructor runs its executor synchronously.
84
+ resolveFn!();
85
+ },
86
+ awaitTrigger(): Promise<void> {
87
+ return triggered;
88
+ },
89
+ };
90
+ }