@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.
- package/bin/fnc.js +34 -79
- package/package.json +6 -9
- package/share/fnclaude/templates/handoff.template.md +11 -0
- package/src/argv/classify.ts +48 -0
- package/src/argv/expand.ts +51 -0
- package/src/argv/intake.ts +52 -0
- package/src/argv/magic.ts +103 -0
- package/src/argv/parse.ts +213 -0
- package/src/argv/preserve-args.ts +333 -0
- package/src/argv/sentinel.ts +41 -0
- package/src/argv/short-flags.ts +152 -0
- package/src/config/load.ts +116 -0
- package/src/handoff/awaiter.ts +140 -0
- package/src/handoff/clean-env.ts +45 -0
- package/src/handoff/kill-and-exec.ts +110 -0
- package/src/handoff/spawn-launcher.ts +185 -0
- package/src/handoff/summary-file.ts +86 -0
- package/src/handoff/trigger.ts +90 -0
- package/src/help-version.ts +151 -0
- package/src/launch/compose-env.ts +34 -0
- package/src/launch/cross-cwd-parse.ts +69 -0
- package/src/launch/cross-cwd-relaunch.ts +95 -0
- package/src/launch/find-claude.ts +52 -0
- package/src/launch/live-permission-reader.ts +133 -0
- package/src/launch/ring-buffer.ts +92 -0
- package/src/main.ts +580 -437
- package/src/mcp/dispatch.ts +240 -0
- package/src/mcp/handlers/clipboard-backends.ts +176 -0
- package/src/mcp/handlers/clipboard.ts +62 -0
- package/src/mcp/handlers/restart.ts +156 -0
- package/src/mcp/handlers/spawn.ts +219 -0
- package/src/mcp/handlers/switch.ts +272 -0
- package/src/mcp/inject-config.ts +59 -0
- package/src/mcp/jsonrpc-server.ts +154 -0
- package/src/mcp/listener.ts +141 -0
- package/src/mcp/parent-dispatch.ts +154 -0
- package/src/mcp/socket-path.ts +48 -0
- package/src/mcp/wire.ts +181 -0
- package/src/name/auto-name.ts +162 -0
- package/src/name/llm-prompt.ts +14 -0
- package/src/name/sanitize.ts +57 -0
- package/src/name/sdk-llm.ts +42 -0
- package/src/noop/seed.ts +63 -0
- package/src/noop/template-source.ts +62 -0
- package/src/path/ensure-cwd.ts +95 -0
- package/src/path/resolve.ts +58 -0
- package/src/prompts/dir.ts +61 -0
- package/src/prompts/load.ts +100 -0
- package/src/prompts/select.ts +43 -0
- package/src/repo/clone-exec.ts +37 -0
- package/src/repo/clone.ts +45 -0
- package/src/repo/gh-runner.ts +68 -0
- package/src/repo/host-aliases.ts +58 -0
- package/src/repo/owner-lookup.ts +71 -0
- package/src/repo/ref.ts +146 -0
- package/src/repo/repo-settings.ts +99 -0
- package/src/repo/resolve-input.ts +179 -0
- package/src/repo/template.ts +92 -0
- package/src/warnings/buffer.ts +39 -0
- package/src/worktree/auto-tmux.ts +45 -0
- package/src/worktree/git-list.ts +73 -0
- package/src/worktree/intercept.ts +150 -0
- package/bin/preflight.js +0 -66
- package/prompts/agent-pitfall.md +0 -1
- package/prompts/noop-router.md +0 -186
- package/prompts/project-switch.md +0 -64
- package/prompts/restart.md +0 -50
- package/prompts/spawn.md +0 -62
- package/src/argParser.ts +0 -367
- package/src/args/preserve.ts +0 -338
- package/src/args.ts +0 -239
- package/src/argv.ts +0 -219
- package/src/autoname.ts +0 -273
- package/src/clipboard.ts +0 -149
- package/src/config.ts +0 -369
- package/src/errors.ts +0 -13
- package/src/handoff.ts +0 -108
- package/src/help.ts +0 -139
- package/src/hostAliases.ts +0 -139
- package/src/index.ts +0 -120
- package/src/mcp/client.ts +0 -645
- package/src/mcp/protocol.ts +0 -445
- package/src/mcp/socketListener.ts +0 -540
- package/src/noop.ts +0 -106
- package/src/passthrough.ts +0 -36
- package/src/paths.ts +0 -55
- package/src/prompts.ts +0 -279
- package/src/pty/unix.ts +0 -429
- package/src/pty/windows.ts +0 -125
- package/src/pty.ts +0 -380
- package/src/repoRef.ts +0 -158
- package/src/repoSettings.ts +0 -144
- package/src/resolver.ts +0 -519
- package/src/sanitize.ts +0 -120
- package/src/sessionState.ts +0 -220
- package/src/silentRelaunch.ts +0 -178
- package/src/spawn.ts +0 -163
- package/src/template.ts +0 -44
- package/src/warnings.ts +0 -34
- 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
|
+
}
|