@fnclaude/cli 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -203
- 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
package/src/sessionState.ts
DELETED
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
// Port of src/session_state.go (fnclaude/fnclaude Go reference).
|
|
2
|
-
//
|
|
3
|
-
// CWD encoding for Claude Code's project dir naming scheme, and JSONL
|
|
4
|
-
// permission-mode last-wins scan over a session log.
|
|
5
|
-
|
|
6
|
-
import { appendFileSync, readFileSync } from 'node:fs';
|
|
7
|
-
import { randomUUID } from 'node:crypto';
|
|
8
|
-
import { homedir } from 'node:os';
|
|
9
|
-
import { join } from 'node:path';
|
|
10
|
-
|
|
11
|
-
function home(): string {
|
|
12
|
-
return process.env.HOME ?? homedir();
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Encode a cwd into the directory name Claude Code uses under
|
|
17
|
-
* `~/.claude/projects/`. The scheme: every character that is NOT in
|
|
18
|
-
* `[A-Za-z0-9-]` is replaced with `-`. An absolute path like
|
|
19
|
-
* `/home/tom/src/fnclaude@fnrhombus` becomes
|
|
20
|
-
* `-home-tom-src-fnclaude-fnrhombus`. Verified empirically against real
|
|
21
|
-
* on-disk session directories — claude replaces `/`, `@`, `+`, `_`, `.`,
|
|
22
|
-
* and likely every other non-alphanumeric; the safe rule is the allowlist
|
|
23
|
-
* above.
|
|
24
|
-
*/
|
|
25
|
-
export function encodeCWDForProjects(cwd: string): string {
|
|
26
|
-
let out = '';
|
|
27
|
-
for (const ch of cwd) {
|
|
28
|
-
const code = ch.charCodeAt(0);
|
|
29
|
-
const isLower = code >= 97 && code <= 122;
|
|
30
|
-
const isUpper = code >= 65 && code <= 90;
|
|
31
|
-
const isDigit = code >= 48 && code <= 57;
|
|
32
|
-
const isDash = ch === '-';
|
|
33
|
-
out += isLower || isUpper || isDigit || isDash ? ch : '-';
|
|
34
|
-
}
|
|
35
|
-
return out;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Resolve the path to claude's per-session JSONL log for `sessionID`
|
|
40
|
-
* running in `launchCWD`. The file lives under `~/.claude/projects/`
|
|
41
|
-
* using the encoded-cwd directory name. The caller is responsible for
|
|
42
|
-
* checking existence.
|
|
43
|
-
*/
|
|
44
|
-
export function sessionJSONLPath(launchCWD: string, sessionID: string): string {
|
|
45
|
-
const encoded = encodeCWDForProjects(launchCWD);
|
|
46
|
-
return join(home(), '.claude', 'projects', encoded, `${sessionID}.jsonl`);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Return the most recent permission-mode value recorded in claude's
|
|
51
|
-
* session JSONL for `sessionID` under `launchCWD`.
|
|
52
|
-
*
|
|
53
|
-
* Claude Code persists permission mode by appending records of the form
|
|
54
|
-
*
|
|
55
|
-
* {"type":"permission-mode","permissionMode":"acceptEdits|auto|bypassPermissions|default|dontAsk|plan"}
|
|
56
|
-
*
|
|
57
|
-
* to the per-session JSONL. The file is append-only, so a forward linear
|
|
58
|
-
* scan with last-wins semantics is correct (and adequate at the file
|
|
59
|
-
* sizes real sessions reach).
|
|
60
|
-
*
|
|
61
|
-
* Returns `undefined` if the file is missing, unreadable, or contains no
|
|
62
|
-
* permission-mode records. Callers should fall back to startup-arg
|
|
63
|
-
* preservation in that case.
|
|
64
|
-
*
|
|
65
|
-
* Only records whose `type` field is literally `"permission-mode"` are
|
|
66
|
-
* considered. Other record types (user / assistant / system messages) may
|
|
67
|
-
* also serialize a `permissionMode` field, but that's a cached snapshot —
|
|
68
|
-
* not authoritative.
|
|
69
|
-
*/
|
|
70
|
-
export function readLivePermissionMode(
|
|
71
|
-
launchCWD: string,
|
|
72
|
-
sessionID: string,
|
|
73
|
-
): string | undefined {
|
|
74
|
-
const path = sessionJSONLPath(launchCWD, sessionID);
|
|
75
|
-
let data: string;
|
|
76
|
-
try {
|
|
77
|
-
data = readFileSync(path, 'utf8');
|
|
78
|
-
} catch {
|
|
79
|
-
return undefined;
|
|
80
|
-
}
|
|
81
|
-
let latest: string | undefined;
|
|
82
|
-
for (const line of data.split('\n')) {
|
|
83
|
-
if (line.length === 0) continue;
|
|
84
|
-
let r: { type?: unknown; permissionMode?: unknown };
|
|
85
|
-
try {
|
|
86
|
-
r = JSON.parse(line) as typeof r;
|
|
87
|
-
} catch {
|
|
88
|
-
continue; // malformed line — ignore
|
|
89
|
-
}
|
|
90
|
-
if (r.type === 'permission-mode' && typeof r.permissionMode === 'string' && r.permissionMode) {
|
|
91
|
-
latest = r.permissionMode;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return latest;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Overrides that may have landed alongside the restart. When any of these
|
|
99
|
-
* are set the appended reminder names them so the resumed model can briefly
|
|
100
|
-
* acknowledge the change before continuing the pre-restart work.
|
|
101
|
-
*/
|
|
102
|
-
export interface RestartReminderOverrides {
|
|
103
|
-
model?: string;
|
|
104
|
-
effort?: string;
|
|
105
|
-
permissionMode?: string;
|
|
106
|
-
agent?: string;
|
|
107
|
-
/** True iff `--ide` is being added on the relaunch. */
|
|
108
|
-
ide?: boolean;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Read the trailing entries of `data` and return the most recent `uuid`
|
|
113
|
-
* field, or `undefined` if none found. Used to link the appended reminder
|
|
114
|
-
* into the JSONL parent-chain.
|
|
115
|
-
*/
|
|
116
|
-
function lastEntryUUID(data: string): string | undefined {
|
|
117
|
-
const lines = data.split('\n');
|
|
118
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
119
|
-
const line = lines[i];
|
|
120
|
-
if (!line || line.length === 0) continue;
|
|
121
|
-
let parsed: { uuid?: unknown };
|
|
122
|
-
try {
|
|
123
|
-
parsed = JSON.parse(line) as typeof parsed;
|
|
124
|
-
} catch {
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
if (typeof parsed.uuid === 'string' && parsed.uuid.length > 0) {
|
|
128
|
-
return parsed.uuid;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return undefined;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Render the system-reminder body text, optionally naming overrides. */
|
|
135
|
-
export function renderRestartReminderContent(
|
|
136
|
-
overrides?: RestartReminderOverrides,
|
|
137
|
-
): string {
|
|
138
|
-
const parts: string[] = [];
|
|
139
|
-
if (overrides?.model) {
|
|
140
|
-
parts.push(`model swap to ${overrides.model}`);
|
|
141
|
-
}
|
|
142
|
-
if (overrides?.effort) {
|
|
143
|
-
parts.push(`effort=${overrides.effort}`);
|
|
144
|
-
}
|
|
145
|
-
if (overrides?.permissionMode) {
|
|
146
|
-
parts.push(`permission-mode=${overrides.permissionMode}`);
|
|
147
|
-
}
|
|
148
|
-
if (overrides?.agent) {
|
|
149
|
-
parts.push(`agent=${overrides.agent}`);
|
|
150
|
-
}
|
|
151
|
-
if (overrides?.ide) {
|
|
152
|
-
parts.push('--ide connected');
|
|
153
|
-
}
|
|
154
|
-
const overrideClause =
|
|
155
|
-
parts.length > 0
|
|
156
|
-
? ` Restart-specific overrides applied: ${parts.join(', ')} — acknowledge briefly, then continue.`
|
|
157
|
-
: '';
|
|
158
|
-
return (
|
|
159
|
-
'<system-reminder>\n' +
|
|
160
|
-
'This session was restarted via fnc_restart (all prior context and the ' +
|
|
161
|
-
'session JSONL are preserved). Resume the work that was in flight ' +
|
|
162
|
-
'before the restart — finish the task, monitor what you were ' +
|
|
163
|
-
'monitoring, surface results — rather than treating this as a fresh ' +
|
|
164
|
-
'session.' +
|
|
165
|
-
overrideClause +
|
|
166
|
-
'\n</system-reminder>'
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Append an `isMeta:true` user-message bearing a `<system-reminder>` block
|
|
172
|
-
* to the session JSONL at `launchCWD` / `sessionID`. Best-effort: missing
|
|
173
|
-
* or unreadable JSONL is silently tolerated (the restart should still
|
|
174
|
-
* proceed; the reminder is a UX nicety, not a hard requirement).
|
|
175
|
-
*
|
|
176
|
-
* Shape matches the entries Claude Code itself emits for inline reminders
|
|
177
|
-
* — `type:"user"`, `message:{role:"user",content:"<system-reminder>…</system-reminder>"}`,
|
|
178
|
-
* `isMeta:true`. The `parentUuid` is linked to the most recent entry's
|
|
179
|
-
* `uuid` so the resumed session reads it as a fresh terminal user turn.
|
|
180
|
-
*/
|
|
181
|
-
export function appendRestartReminder(
|
|
182
|
-
launchCWD: string,
|
|
183
|
-
sessionID: string,
|
|
184
|
-
overrides?: RestartReminderOverrides,
|
|
185
|
-
): void {
|
|
186
|
-
const path = sessionJSONLPath(launchCWD, sessionID);
|
|
187
|
-
let existing: string;
|
|
188
|
-
try {
|
|
189
|
-
existing = readFileSync(path, 'utf8');
|
|
190
|
-
} catch {
|
|
191
|
-
// No JSONL — nothing to append to. The relaunched claude will start
|
|
192
|
-
// fresh anyway, so the reminder would be off-target.
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
// JSONL parentUuid is on the wire — keep null encoding for "no parent"
|
|
196
|
-
// entries to match Claude Code's own writer.
|
|
197
|
-
const parentUuid = lastEntryUUID(existing) ?? null;
|
|
198
|
-
const entry = {
|
|
199
|
-
parentUuid,
|
|
200
|
-
isSidechain: false,
|
|
201
|
-
type: 'user' as const,
|
|
202
|
-
message: {
|
|
203
|
-
role: 'user' as const,
|
|
204
|
-
content: renderRestartReminderContent(overrides),
|
|
205
|
-
},
|
|
206
|
-
isMeta: true,
|
|
207
|
-
uuid: randomUUID(),
|
|
208
|
-
timestamp: new Date().toISOString(),
|
|
209
|
-
userType: 'external' as const,
|
|
210
|
-
cwd: launchCWD,
|
|
211
|
-
sessionId: sessionID,
|
|
212
|
-
};
|
|
213
|
-
try {
|
|
214
|
-
appendFileSync(path, `${JSON.stringify(entry)}\n`);
|
|
215
|
-
} catch {
|
|
216
|
-
// Best-effort — disk full, permission denied, raced unlink, etc. The
|
|
217
|
-
// restart proceeds; user gets the historical "Restarted." idle behavior
|
|
218
|
-
// rather than a hard failure.
|
|
219
|
-
}
|
|
220
|
-
}
|
package/src/silentRelaunch.ts
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
// Port of silentRelaunch / silentRelaunchHandoff from src/pty_run_unix.go +
|
|
2
|
-
// src/pty_run_windows.go in the Go reference.
|
|
3
|
-
//
|
|
4
|
-
// silentRelaunch is what fnclaude calls when claude has exited with a
|
|
5
|
-
// cross-cwd-resume marker in its tail output (user selected a session from
|
|
6
|
-
// another directory via the Ctrl+A picker). It replaces the current process
|
|
7
|
-
// with a fresh fnclaude pointed at the new cwd + session UUID.
|
|
8
|
-
//
|
|
9
|
-
// silentRelaunchHandoff is the auto-handoff sibling: invoked when the
|
|
10
|
-
// AF_UNIX socket listener received an OpRestart or OpSwitch confirmation
|
|
11
|
-
// during the run, killed claude, and stashed the argv for the next launch.
|
|
12
|
-
//
|
|
13
|
-
// On POSIX both use `process.execve` (Bun 1.3.14+) to replace the process
|
|
14
|
-
// image — semantically identical to Go's `syscall.Exec`. On Windows execve
|
|
15
|
-
// throws `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`, so we fall back to spawn-
|
|
16
|
-
// and-propagate-exit-code (mirrors the Go Windows stub).
|
|
17
|
-
|
|
18
|
-
import { spawn } from 'node:child_process';
|
|
19
|
-
import process from 'node:process';
|
|
20
|
-
import { clearScreen, reconstructArgv } from './pty.js';
|
|
21
|
-
import { resolveSelfPath } from './paths.js';
|
|
22
|
-
|
|
23
|
-
// `process.execve` is a Bun-native POSIX-only API (1.3.14+). @types/bun
|
|
24
|
-
// hasn't typed it yet — declare the shape inline so TS strict mode is happy.
|
|
25
|
-
// The signature mirrors Bun's native binding: argv[0] is conventionally the
|
|
26
|
-
// program name; env is the full environment (KEY=VALUE strings).
|
|
27
|
-
interface ExecveProcess {
|
|
28
|
-
execve?: (path: string, argv: string[], env: Record<string, string | undefined>) => never;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Replace the current process image with a fresh fnclaude invocation, using
|
|
33
|
-
* `dest` as the new cwd and `uuid` as the session to resume.
|
|
34
|
-
*
|
|
35
|
-
* On POSIX this NEVER returns on success — execve replaces the running
|
|
36
|
-
* process. On failure it writes to stderr and returns, letting the caller
|
|
37
|
-
* propagate claude's exit code.
|
|
38
|
-
*
|
|
39
|
-
* On Windows execve is unavailable; we approximate by spawning a fresh
|
|
40
|
-
* fnclaude as a child, waiting for it to exit, and calling `process.exit`
|
|
41
|
-
* with its code (NEVER returns on success on Windows either, but for a
|
|
42
|
-
* different reason — the exit() in the child-completion handler).
|
|
43
|
-
*
|
|
44
|
-
* `origArgs` is the original `process.argv.slice(2)` from the launching
|
|
45
|
-
* fnclaude invocation; reconstructArgv preserves leading magic words and
|
|
46
|
-
* post-positional flags while swapping in the new cwd + --resume <uuid>.
|
|
47
|
-
*/
|
|
48
|
-
export function silentRelaunch(
|
|
49
|
-
origArgs: readonly string[],
|
|
50
|
-
dest: string,
|
|
51
|
-
uuid: string,
|
|
52
|
-
out: NodeJS.WriteStream = process.stdout,
|
|
53
|
-
): void {
|
|
54
|
-
let self: string;
|
|
55
|
-
try {
|
|
56
|
-
self = resolveSelfPath();
|
|
57
|
-
} catch (err) {
|
|
58
|
-
process.stderr.write(
|
|
59
|
-
`fnclaude: cannot determine executable, cannot relaunch: ${(err as Error).message}\n`,
|
|
60
|
-
);
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const newArgs = reconstructArgv(origArgs, dest, uuid);
|
|
65
|
-
|
|
66
|
-
clearScreen(out);
|
|
67
|
-
|
|
68
|
-
// execve argv[0] is conventionally the program name (matches Go's
|
|
69
|
-
// syscall.Exec contract).
|
|
70
|
-
const argv = [self, ...newArgs];
|
|
71
|
-
execOrSpawn(self, argv);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Replace the current process with a fresh fnclaude using `argv` as the new
|
|
76
|
-
* arg list. The socket listener has already constructed argv with the
|
|
77
|
-
* leading "fnclaude" token stripped; we prepend the self path as argv[0].
|
|
78
|
-
*
|
|
79
|
-
* Same POSIX vs Windows behavior as `silentRelaunch`.
|
|
80
|
-
*/
|
|
81
|
-
export function silentRelaunchHandoff(
|
|
82
|
-
argv: readonly string[],
|
|
83
|
-
out: NodeJS.WriteStream = process.stdout,
|
|
84
|
-
): void {
|
|
85
|
-
let self: string;
|
|
86
|
-
try {
|
|
87
|
-
self = resolveSelfPath();
|
|
88
|
-
} catch (err) {
|
|
89
|
-
process.stderr.write(
|
|
90
|
-
`fnclaude: cannot determine executable, cannot relaunch: ${(err as Error).message}\n`,
|
|
91
|
-
);
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
clearScreen(out);
|
|
96
|
-
|
|
97
|
-
const full = [self, ...argv];
|
|
98
|
-
execOrSpawn(self, full, /* handoff */ true);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Shared dispatcher: POSIX uses execve (process replacement); Windows
|
|
103
|
-
* spawns a fresh child and exits with its code.
|
|
104
|
-
*
|
|
105
|
-
* `handoff` distinguishes the error message text so logs disambiguate the
|
|
106
|
-
* two callsites.
|
|
107
|
-
*/
|
|
108
|
-
function execOrSpawn(self: string, argv: string[], handoff = false): void {
|
|
109
|
-
const label = handoff ? 'handoff exec' : 'exec relaunch';
|
|
110
|
-
|
|
111
|
-
if (process.platform === 'win32') {
|
|
112
|
-
// No execve on Windows. Approximate process replacement: spawn a child
|
|
113
|
-
// with inherited stdio and exit with its code once it finishes.
|
|
114
|
-
spawnAndExit(self, argv);
|
|
115
|
-
return; // unreachable in practice — spawnAndExit calls process.exit
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// POSIX path — call Bun's native execve. NEVER returns on success.
|
|
119
|
-
//
|
|
120
|
-
// NOTE: `process.env` here is deliberate (mirrors Go's `os.Environ()`).
|
|
121
|
-
// The exec replaces the current process with a fresh fnclaude, NOT
|
|
122
|
-
// claude. The relaunched fnclaude will reload its own config and
|
|
123
|
-
// re-apply [exec.env] before starting its claude child — merging config
|
|
124
|
-
// env in here would double-inject those vars into the relaunched
|
|
125
|
-
// fnclaude's own environment.
|
|
126
|
-
//
|
|
127
|
-
// Behavioral note: Bun's `process.execve` is uncatchable on failure —
|
|
128
|
-
// when the kernel rejects the exec (ENOENT / EACCES / ENOEXEC / etc.)
|
|
129
|
-
// Bun's runtime prints a SystemError to stderr and aborts the process
|
|
130
|
-
// with SIGABRT (exit code 134). That differs from Go's `syscall.Exec`
|
|
131
|
-
// which returns the error. The Go code's "if exec fails, fall through"
|
|
132
|
-
// path is therefore unreachable on Bun — we get an abort instead.
|
|
133
|
-
//
|
|
134
|
-
// Implication for callers: don't rely on a returnable execve failure as
|
|
135
|
-
// a recoverable path; any silentRelaunch* invocation that doesn't replace
|
|
136
|
-
// the process is fatal. In practice that's the right semantics — if the
|
|
137
|
-
// self path is unusable, we can't continue, and a noisy abort is more
|
|
138
|
-
// informative than silently propagating a stale exit code.
|
|
139
|
-
const execve = (process as ExecveProcess).execve;
|
|
140
|
-
if (typeof execve !== 'function') {
|
|
141
|
-
process.stderr.write(
|
|
142
|
-
`fnclaude: process.execve unavailable on this runtime (Bun 1.3.14+ required); cannot ${label}\n`,
|
|
143
|
-
);
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// argv[0] is conventionally the program name; pass the full argv slice
|
|
148
|
-
// with self at the front. Never returns on success; SIGABRTs on failure.
|
|
149
|
-
execve(self, argv, process.env);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Windows fallback for silentRelaunch* — spawn a child fnclaude with
|
|
154
|
-
* inherited stdio, wait for it to exit, and call process.exit with its
|
|
155
|
-
* code. Mirrors src/pty_run_windows.go's silentRelaunchHandoff.
|
|
156
|
-
*
|
|
157
|
-
* Exported for testability; production callers go through execOrSpawn.
|
|
158
|
-
*/
|
|
159
|
-
export function spawnAndExit(self: string, argv: string[]): void {
|
|
160
|
-
// argv[0] is the program name slot (matches POSIX execve convention).
|
|
161
|
-
// For child_process.spawn we pass the remaining args.
|
|
162
|
-
const child = spawn(self, argv.slice(1), { stdio: 'inherit' });
|
|
163
|
-
child.on('exit', (code, signal) => {
|
|
164
|
-
if (typeof code === 'number') {
|
|
165
|
-
process.exit(code);
|
|
166
|
-
} else if (signal !== null) {
|
|
167
|
-
// Signaled: emit non-zero exit. Match POSIX shell convention of
|
|
168
|
-
// 128 + signal number where we can map it; default to 1.
|
|
169
|
-
process.exit(1);
|
|
170
|
-
} else {
|
|
171
|
-
process.exit(1);
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
child.on('error', (err) => {
|
|
175
|
-
process.stderr.write(`fnclaude: handoff exec failed: ${err.message}\n`);
|
|
176
|
-
process.exit(1);
|
|
177
|
-
});
|
|
178
|
-
}
|
package/src/spawn.ts
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
// Spawn a sibling fnclaude in a new terminal window. Ported from src/spawn.go
|
|
2
|
-
// (fnclaude/fnclaude Go reference).
|
|
3
|
-
//
|
|
4
|
-
// spawnSibling opens a new terminal window (via tmux or a user-configured
|
|
5
|
-
// launcher) and runs fnclaude there. It is "sibling, not child": the spawned
|
|
6
|
-
// argv is a terminal-emulator command that itself launches fnclaude, so the
|
|
7
|
-
// new session is unaffected when this process exits.
|
|
8
|
-
//
|
|
9
|
-
// The indirection via spawnFn lets tests inject a mock without launching
|
|
10
|
-
// real processes.
|
|
11
|
-
|
|
12
|
-
import process from 'node:process';
|
|
13
|
-
import type { Config } from './config.js';
|
|
14
|
-
import { resolveSelfPath } from './paths.js';
|
|
15
|
-
import { substitute } from './template.js';
|
|
16
|
-
|
|
17
|
-
// ── env cleaning ───────────────────────────────────────────────────────────
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Drop env vars that would mislead the new fnclaude session:
|
|
21
|
-
*
|
|
22
|
-
* - FNC_SOCKET: points at *this* session's listener. The sibling must
|
|
23
|
-
* compute its own; leaking ours makes it dial back into us.
|
|
24
|
-
* - FNCLAUDE_HANDOFF: injected for *this* claude child; not the sibling's.
|
|
25
|
-
* - CLAUDE_CODE_SESSION_ID: scopes to this session only.
|
|
26
|
-
*
|
|
27
|
-
* Everything else (PATH, XDG_*, exec.env contributions) passes through so
|
|
28
|
-
* the sibling inherits the same user environment.
|
|
29
|
-
*/
|
|
30
|
-
export function cleanEnvForSpawn(env: string[]): string[] {
|
|
31
|
-
const drop = new Set(['FNC_SOCKET', 'FNCLAUDE_HANDOFF', 'CLAUDE_CODE_SESSION_ID']);
|
|
32
|
-
const out: string[] = [];
|
|
33
|
-
for (const e of env) {
|
|
34
|
-
const eq = e.indexOf('=');
|
|
35
|
-
const key = eq < 0 ? e : e.slice(0, eq);
|
|
36
|
-
if (!drop.has(key)) out.push(e);
|
|
37
|
-
}
|
|
38
|
-
return out;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// ── autoDetectSpawnCommand ─────────────────────────────────────────────────
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Return a built-in launcher template when the host environment unambiguously
|
|
45
|
-
* declares how to open a new window. Empty string means no match — caller
|
|
46
|
-
* falls back to paste-flow.
|
|
47
|
-
*
|
|
48
|
-
* Only $TMUX is detected. Being inside tmux is an explicit declaration of the
|
|
49
|
-
* windowing layer; "open a new tmux window in this session" is unambiguously
|
|
50
|
-
* what the user wants. Earlier versions also sniffed $KITTY_WINDOW_ID,
|
|
51
|
-
* $TERM_PROGRAM=WezTerm, $WT_SESSION, etc. — those were heuristic
|
|
52
|
-
* conveniences that failed silently for any unlisted terminal. One
|
|
53
|
-
* mechanism (auto.spawnCommand) surfaced in the paste-flow message is
|
|
54
|
-
* strictly better than an allowlist that grows forever.
|
|
55
|
-
*/
|
|
56
|
-
export function autoDetectSpawnCommand(): string {
|
|
57
|
-
if (process.env.TMUX) {
|
|
58
|
-
return 'tmux new-window -d {bin} {dest} --name {name} @{summary}';
|
|
59
|
-
}
|
|
60
|
-
return '';
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ── buildSpawnArgv ─────────────────────────────────────────────────────────
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Tokenize tmpl on whitespace then substitute the four supported placeholders
|
|
67
|
-
* within each token. No shell involvement: each whitespace-delimited token
|
|
68
|
-
* becomes one argv entry verbatim, so a {dest} expanding to a path with
|
|
69
|
-
* spaces remains a single argv entry.
|
|
70
|
-
*/
|
|
71
|
-
export function buildSpawnArgv(
|
|
72
|
-
tmpl: string,
|
|
73
|
-
bin: string,
|
|
74
|
-
dest: string,
|
|
75
|
-
name: string,
|
|
76
|
-
summary: string,
|
|
77
|
-
): string[] {
|
|
78
|
-
const vars: Record<string, string> = { bin, dest, name, summary };
|
|
79
|
-
return tmpl.split(/\s+/).filter((t) => t.length > 0).map((t) => substitute(t, vars));
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// ── SpawnFn type + spawnSibling ────────────────────────────────────────────
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* The function type that actually starts the process. Tests inject a mock;
|
|
86
|
-
* production uses the default (Bun.spawn detached).
|
|
87
|
-
*
|
|
88
|
-
* Returns true when a launcher was resolved and started; false when no
|
|
89
|
-
* launcher is configured and no terminal could be auto-detected (caller
|
|
90
|
-
* falls back to paste-flow). Throws on resolution/start failure.
|
|
91
|
-
*/
|
|
92
|
-
export type SpawnFn = (argv: string[], env: string[]) => void;
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Default SpawnFn: invoke argv[0] with argv[1:] via Bun.spawn, detached.
|
|
96
|
-
* We release the handle immediately — the launcher (tmux, etc.) typically
|
|
97
|
-
* returns in milliseconds after dispatching the new window; we don't care
|
|
98
|
-
* about its exit code.
|
|
99
|
-
*/
|
|
100
|
-
function defaultSpawnFn(argv: string[], env: string[]): void {
|
|
101
|
-
const [cmd, ...args] = argv;
|
|
102
|
-
if (!cmd) throw new Error('spawn called with empty argv');
|
|
103
|
-
// Bun.spawn: env is a Record<string,string> or string[]. We pass the
|
|
104
|
-
// key=value array directly — Bun accepts it.
|
|
105
|
-
const proc = Bun.spawn([cmd, ...args], {
|
|
106
|
-
env: Object.fromEntries(
|
|
107
|
-
env.map((e) => {
|
|
108
|
-
const i = e.indexOf('=');
|
|
109
|
-
return i < 0 ? [e, ''] : [e.slice(0, i), e.slice(i + 1)];
|
|
110
|
-
}),
|
|
111
|
-
),
|
|
112
|
-
stdin: null,
|
|
113
|
-
stdout: null,
|
|
114
|
-
stderr: null,
|
|
115
|
-
});
|
|
116
|
-
// Don't await — release and move on.
|
|
117
|
-
void proc.exited.catch(() => undefined);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* spawnSibling launches a sibling fnclaude in a new window.
|
|
122
|
-
*
|
|
123
|
-
* Returns true when a launcher was resolved and started successfully; false
|
|
124
|
-
* when no launcher is configured AND no terminal could be auto-detected
|
|
125
|
-
* (caller falls back to paste-flow). Throws when a launcher was resolved but
|
|
126
|
-
* failed to start.
|
|
127
|
-
*
|
|
128
|
-
* extraArgs is appended after the template-expanded portion, e.g. override
|
|
129
|
-
* flags the caller wants to pass to the new session.
|
|
130
|
-
*/
|
|
131
|
-
export async function spawnSibling(
|
|
132
|
-
cfg: Config,
|
|
133
|
-
dest: string,
|
|
134
|
-
name: string,
|
|
135
|
-
summaryPath: string,
|
|
136
|
-
extraArgs: string[],
|
|
137
|
-
spawnFn: SpawnFn = defaultSpawnFn,
|
|
138
|
-
): Promise<boolean> {
|
|
139
|
-
const bin = resolveSelfPath();
|
|
140
|
-
|
|
141
|
-
let tmpl = cfg.auto.spawnCommand;
|
|
142
|
-
if (!tmpl) {
|
|
143
|
-
tmpl = autoDetectSpawnCommand();
|
|
144
|
-
}
|
|
145
|
-
if (!tmpl) {
|
|
146
|
-
return false;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const argv = buildSpawnArgv(tmpl, bin, dest, name, summaryPath);
|
|
150
|
-
if (argv.length === 0) {
|
|
151
|
-
throw new Error(`spawn template produced empty argv: ${JSON.stringify(tmpl)}`);
|
|
152
|
-
}
|
|
153
|
-
const fullArgv = [...argv, ...extraArgs];
|
|
154
|
-
|
|
155
|
-
const env = cleanEnvForSpawn(
|
|
156
|
-
Object.entries(process.env)
|
|
157
|
-
.filter(([, v]) => v !== undefined)
|
|
158
|
-
.map(([k, v]) => `${k}=${v}`),
|
|
159
|
-
);
|
|
160
|
-
|
|
161
|
-
spawnFn(fullArgv, env);
|
|
162
|
-
return true;
|
|
163
|
-
}
|
package/src/template.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
// Generic `{placeholder}` template substitution. Ported from src/spawn.go
|
|
2
|
-
// (buildSpawnArgv's inline logic) with a factored-out substitute helper.
|
|
3
|
-
//
|
|
4
|
-
// Placeholder vocabulary: any `{key}` in the template string is replaced with
|
|
5
|
-
// the corresponding value from vars. Missing keys are left verbatim — no
|
|
6
|
-
// error — so callers can safely pass a template that uses only a subset of
|
|
7
|
-
// available placeholders.
|
|
8
|
-
//
|
|
9
|
-
// Unterminated `{` (no matching `}`) is passed through literally, matching
|
|
10
|
-
// the Go reference's behaviour.
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* substitute replaces every `{key}` occurrence in tpl with the corresponding
|
|
14
|
-
* value from vars. Keys absent from vars are left as-is (`{key}` verbatim).
|
|
15
|
-
*/
|
|
16
|
-
export function substitute(tpl: string, vars: Record<string, string>): string {
|
|
17
|
-
let out = '';
|
|
18
|
-
let i = 0;
|
|
19
|
-
while (i < tpl.length) {
|
|
20
|
-
const c = tpl[i]!;
|
|
21
|
-
if (c !== '{') {
|
|
22
|
-
out += c;
|
|
23
|
-
i++;
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
// Find the matching '}'
|
|
27
|
-
const end = tpl.indexOf('}', i + 1);
|
|
28
|
-
if (end < 0) {
|
|
29
|
-
// Unterminated '{' — pass through literally.
|
|
30
|
-
out += c;
|
|
31
|
-
i++;
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
const key = tpl.slice(i + 1, end);
|
|
35
|
-
if (Object.prototype.hasOwnProperty.call(vars, key)) {
|
|
36
|
-
out += vars[key]!;
|
|
37
|
-
} else {
|
|
38
|
-
// Unknown placeholder — leave verbatim.
|
|
39
|
-
out += tpl.slice(i, end + 1);
|
|
40
|
-
}
|
|
41
|
-
i = end + 1;
|
|
42
|
-
}
|
|
43
|
-
return out;
|
|
44
|
-
}
|
package/src/warnings.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
// Deferred-warning sink. Ported from src/warnings.go in the Go reference.
|
|
2
|
-
//
|
|
3
|
-
// fnclaude accumulates non-fatal warnings issued during setup and flushes
|
|
4
|
-
// them to stderr AFTER claude exits. Warnings printed before claude launches
|
|
5
|
-
// scroll off-screen too fast to read; flushing on exit shows them in the
|
|
6
|
-
// user's shell where they have time to actually be seen.
|
|
7
|
-
//
|
|
8
|
-
// There is no module-global queue here — every loader (loadConfig,
|
|
9
|
-
// loadRepoSettings, loadHostAliases, loadPrompts) returns its warnings
|
|
10
|
-
// alongside its result, and `main.ts` threads them into a single local
|
|
11
|
-
// list that `flushWarnings` drains at the deferred-flush point. The old
|
|
12
|
-
// global queue made test fixtures share state across files and forced
|
|
13
|
-
// callers to know about a sink module they otherwise didn't depend on;
|
|
14
|
-
// the explicit-thread shape is the fix.
|
|
15
|
-
//
|
|
16
|
-
// Fatal errors that prevent launch entirely (e.g. claude binary not on PATH)
|
|
17
|
-
// should still print directly to stderr and exit non-zero — those don't
|
|
18
|
-
// need deferring because there's no claude session about to drown them out.
|
|
19
|
-
|
|
20
|
-
import process from 'node:process';
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Print each warning to `stream` on its own line. Returns the number of
|
|
24
|
-
* warnings written (useful for tests). Empty input is a no-op.
|
|
25
|
-
*/
|
|
26
|
-
export function flushWarnings(
|
|
27
|
-
warnings: readonly string[],
|
|
28
|
-
stream: NodeJS.WriteStream = process.stderr,
|
|
29
|
-
): number {
|
|
30
|
-
for (const w of warnings) {
|
|
31
|
-
stream.write(`${w}\n`);
|
|
32
|
-
}
|
|
33
|
-
return warnings.length;
|
|
34
|
-
}
|