@fnclaude/cli 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fnclaude/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "fnclaude CLI implementation (TypeScript rewrite, in progress)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -29,6 +29,7 @@
29
29
  "provenance": true
30
30
  },
31
31
  "dependencies": {
32
- "@anthropic-ai/sdk": "^0.98.0"
32
+ "@anthropic-ai/sdk": "^0.98.0",
33
+ "node-pty": "1.1.0"
33
34
  }
34
35
  }
package/src/index.ts CHANGED
@@ -78,3 +78,20 @@ export {
78
78
  transferDenyBareOK,
79
79
  transferDenyFlags,
80
80
  } from './args/preserve.js';
81
+
82
+ // PTY runner + shared helpers (ring buffer, cross-cwd detection,
83
+ // reconstructArgv, ensureCWD).
84
+ export {
85
+ clearScreen,
86
+ crossCwdRe,
87
+ detectCrossCwd,
88
+ ensureCWD,
89
+ RING_BUFFER_SIZE,
90
+ reconstructArgv,
91
+ RingBuffer,
92
+ runWithPTY,
93
+ type CrossCwdMatch,
94
+ type EnsureCWDHandle,
95
+ type RunOptions,
96
+ type RunResult,
97
+ } from './pty.js';
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Unix PTY runner — spawn claude under a node-pty pseudo-terminal, tee its
3
+ * output to stdout + a ring buffer, forward stdin / SIGWINCH, integrate the
4
+ * AF_UNIX socket listener for handoff, and return the exit code + tail +
5
+ * (optional) handoff argv.
6
+ *
7
+ * Ported from src/pty_run_unix.go in the Go reference (fnclaude@fnrhombus).
8
+ *
9
+ * Library choice: `node-pty` (Microsoft, MIT, currently v1.1.0). Verified
10
+ * to load under Bun 1.3.x via the N-API compat layer — both spawn() and the
11
+ * onData/onExit/kill surface work as documented. There's no native Bun PTY
12
+ * primitive yet; if/when Bun ships one, this file is the natural place to
13
+ * swap implementations behind the shared RunOptions API.
14
+ */
15
+
16
+ import { spawn as ptySpawn, type IPty } from 'node-pty';
17
+ import { envFromConfig } from '../config.js';
18
+ import { handoffEnv } from '../handoff.js';
19
+ import { SocketListener } from '../mcp/socketListener.js';
20
+ import {
21
+ ensureCWD,
22
+ RING_BUFFER_SIZE,
23
+ RingBuffer,
24
+ type RunOptions,
25
+ type RunResult,
26
+ } from '../pty.js';
27
+
28
+ // ── helpers ────────────────────────────────────────────────────────────────
29
+
30
+ /** Convert a `KEY=VALUE` string array into the object shape node-pty wants. */
31
+ function envArrayToObject(
32
+ base: NodeJS.ProcessEnv,
33
+ extras: readonly string[],
34
+ ): { [k: string]: string | undefined } {
35
+ // Start from the inherited env. Last-wins, so extras override on
36
+ // duplicate keys (matches Go's append semantics in exec.Command).
37
+ const out: { [k: string]: string | undefined } = { ...base };
38
+ for (const kv of extras) {
39
+ const eq = kv.indexOf('=');
40
+ if (eq < 0) continue;
41
+ out[kv.slice(0, eq)] = kv.slice(eq + 1);
42
+ }
43
+ return out;
44
+ }
45
+
46
+ function getTerminalSize(): { cols: number; rows: number } {
47
+ // node:tty `WriteStream` exposes columns/rows when stdout is a TTY.
48
+ const cols = process.stdout.columns ?? 80;
49
+ const rows = process.stdout.rows ?? 24;
50
+ return { cols, rows };
51
+ }
52
+
53
+ function isTTY(stream: { isTTY?: boolean }): boolean {
54
+ return stream.isTTY === true;
55
+ }
56
+
57
+ // ── runWithPTY ─────────────────────────────────────────────────────────────
58
+
59
+ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
60
+ const { claudeArgv, launchCWD, cfg, handoff } = opts;
61
+
62
+ // claudeArgv[0] is the conventional program name and is ignored — node-pty
63
+ // takes file + args separately (mirrors exec.Command).
64
+ if (claudeArgv.length === 0) {
65
+ process.stderr.write('fnclaude: empty argv passed to runWithPTY\n');
66
+ return { exitCode: 1, tail: null, handoffArgv: null };
67
+ }
68
+
69
+ // Build the env. Order matches Go: os env → exec.env → handoff env.
70
+ // Last-wins on dupes, so handoff env beats user-supplied dupes.
71
+ const envExtras: string[] = [...envFromConfig(cfg)];
72
+ if (handoff !== null) {
73
+ envExtras.push(...handoffEnv(handoff.mode, handoff.socketPath));
74
+ }
75
+ const childEnv = envArrayToObject(process.env, envExtras);
76
+
77
+ // Start the AF_UNIX listener BEFORE the child so the socket is ready the
78
+ // moment claude (and thus the `fnclaude mcp` subprocess) starts. On
79
+ // listener-startup failure we abort the run — handoff is core behavior,
80
+ // not optional.
81
+ let listener: SocketListener | null = null;
82
+ if (handoff !== null) {
83
+ try {
84
+ listener = await SocketListener.start({
85
+ spec: handoff,
86
+ cfg,
87
+ launchCWD,
88
+ origArgs: handoff.originalArgs,
89
+ });
90
+ } catch (err) {
91
+ process.stderr.write(
92
+ `fnclaude: socket listener failed to start: ${(err as Error).message}\n`,
93
+ );
94
+ return { exitCode: 1, tail: null, handoffArgv: null };
95
+ }
96
+ }
97
+
98
+ // Resuming a session whose stored cwd no longer exists used to surface as
99
+ // a misleading ENOENT-against-claude-binary. Fabricate the tree before
100
+ // spawn, then immediately unwind it once claude has chdir'd in.
101
+ let cleanupCWD: (() => Promise<void>) | null = null;
102
+ try {
103
+ const h = await ensureCWD(launchCWD);
104
+ cleanupCWD = h.cleanup;
105
+ } catch (err) {
106
+ process.stderr.write(`fnclaude: ${(err as Error).message}\n`);
107
+ if (listener !== null) await listener.close();
108
+ return { exitCode: 1, tail: null, handoffArgv: null };
109
+ }
110
+
111
+ // Spawn under the PTY.
112
+ const { cols, rows } = getTerminalSize();
113
+ let pty: IPty;
114
+ try {
115
+ pty = ptySpawn(claudeArgv[0] as string, claudeArgv.slice(1), {
116
+ name: process.env.TERM ?? 'xterm-256color',
117
+ cols,
118
+ rows,
119
+ cwd: launchCWD,
120
+ env: childEnv,
121
+ encoding: null, // emit raw Buffers so the ring tail is byte-accurate
122
+ });
123
+ } catch (err) {
124
+ if (cleanupCWD !== null) await cleanupCWD().catch(() => undefined);
125
+ if (listener !== null) await listener.close();
126
+ process.stderr.write(
127
+ `fnclaude: failed to start claude with PTY: ${(err as Error).message}\n`,
128
+ );
129
+ return { exitCode: 1, tail: null, handoffArgv: null };
130
+ }
131
+
132
+ // Unwind any fabricated cwd tree now that the child has been spawned —
133
+ // claude's kernel cwd is held by inode reference, so the path on disk
134
+ // is no longer load-bearing.
135
+ if (cleanupCWD !== null) {
136
+ try {
137
+ await cleanupCWD();
138
+ } catch (err) {
139
+ process.stderr.write(`fnclaude: ${(err as Error).message}\n`);
140
+ }
141
+ }
142
+
143
+ // Put the controlling terminal into raw mode so the PTY behaves
144
+ // transparently (key-by-key, no local echo, etc.).
145
+ let restoreRaw: (() => void) | null = null;
146
+ if (isTTY(process.stdin)) {
147
+ const stdinRaw = process.stdin;
148
+ const wasRaw = stdinRaw.isRaw;
149
+ try {
150
+ stdinRaw.setRawMode(true);
151
+ restoreRaw = () => {
152
+ try {
153
+ stdinRaw.setRawMode(wasRaw);
154
+ } catch {
155
+ // best-effort — terminal may already be torn down
156
+ }
157
+ };
158
+ } catch {
159
+ // not a real TTY in some test harnesses — skip raw mode silently
160
+ }
161
+ }
162
+
163
+ // Ring buffer for post-exit cross-cwd scanning.
164
+ const ring = new RingBuffer(RING_BUFFER_SIZE);
165
+
166
+ // Tee PTY output → stdout + ring buffer. node-pty with encoding:null
167
+ // emits Buffer chunks; the type declaration still says string, but at
168
+ // runtime it's Buffer when encoding is null (the lib's docstring is
169
+ // explicit on this).
170
+ pty.onData((chunk: unknown) => {
171
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string);
172
+ ring.write(buf);
173
+ process.stdout.write(buf);
174
+ });
175
+
176
+ // Forward SIGWINCH (terminal resize) to the PTY. Listener is detached in
177
+ // the finally block.
178
+ const onWinch = (): void => {
179
+ const sz = getTerminalSize();
180
+ try {
181
+ pty.resize(sz.cols, sz.rows);
182
+ } catch {
183
+ // ignore — child may have already exited
184
+ }
185
+ };
186
+ process.on('SIGWINCH', onWinch);
187
+
188
+ // Pump stdin → PTY master. We only forward when stdin is a real TTY —
189
+ // otherwise (test harness, piped invocation, headless run) we don't want
190
+ // to drain a non-TTY stdin pipe which could close the PTY prematurely.
191
+ // We attach a 'data' handler rather than pipe() so we can detach cleanly
192
+ // on exit without destroying stdin (which would leak into the parent's
193
+ // post-PTY state).
194
+ let onStdinData: ((chunk: Buffer) => void) | null = null;
195
+ if (isTTY(process.stdin)) {
196
+ onStdinData = (chunk: Buffer): void => {
197
+ try {
198
+ pty.write(chunk);
199
+ } catch {
200
+ // child gone — ignore
201
+ }
202
+ };
203
+ process.stdin.on('data', onStdinData);
204
+ if (typeof process.stdin.resume === 'function') process.stdin.resume();
205
+ }
206
+
207
+ // Handoff: when the listener fires triggered(), terminate claude.
208
+ // SIGTERM + brief grace + SIGKILL mirrors the legacy SIGUSR1 path
209
+ // — the listener marks "switch fired" and the parent gets out of
210
+ // the PTY loop ASAP.
211
+ let handoffKillTimer: NodeJS.Timeout | null = null;
212
+ if (listener !== null) {
213
+ void listener.triggered().then(() => {
214
+ try {
215
+ pty.kill('SIGTERM');
216
+ } catch {
217
+ // ignore
218
+ }
219
+ handoffKillTimer = setTimeout(() => {
220
+ try {
221
+ pty.kill('SIGKILL');
222
+ } catch {
223
+ // ignore
224
+ }
225
+ }, 200);
226
+ });
227
+ }
228
+
229
+ // Wait for the child to exit. Use a one-shot guard so we capture only
230
+ // the FIRST exit event — node-pty under Bun has been observed to emit
231
+ // a follow-up `exit` for the previous pty when a new one is started in
232
+ // the same process, which would pollute the next call's result.
233
+ const exitResult = await new Promise<{ exitCode: number; signal?: number }>(
234
+ (resolve) => {
235
+ let fired = false;
236
+ const disposable = pty.onExit((e) => {
237
+ if (fired) return;
238
+ fired = true;
239
+ disposable.dispose();
240
+ resolve(e);
241
+ });
242
+ },
243
+ );
244
+
245
+ // Tear down listeners / restore terminal state.
246
+ process.off('SIGWINCH', onWinch);
247
+ if (onStdinData !== null) {
248
+ process.stdin.off('data', onStdinData);
249
+ if (typeof process.stdin.pause === 'function') process.stdin.pause();
250
+ }
251
+ if (restoreRaw !== null) restoreRaw();
252
+ if (handoffKillTimer !== null) clearTimeout(handoffKillTimer);
253
+
254
+ // node-pty's exit shape: { exitCode, signal? }.
255
+ //
256
+ // Under Node: exitCode is set when the child exited normally; signal is
257
+ // set (with the signal number) when terminated by a signal. We could map
258
+ // signal-death to POSIX's `128 + signal`.
259
+ //
260
+ // Under Bun (1.3.x), node-pty's `signal` field is unreliable — observed
261
+ // value `1` on both normal exits AND deliberate SIGTERM kills, so we
262
+ // can't trust it to distinguish "exited normally" from "killed". The
263
+ // safest cross-runtime answer is to use `exitCode` as truth: it tracks
264
+ // the real exit code on both runtimes, and on signal-death it reports
265
+ // 0 (which is the correct "process didn't choose its exit code" answer).
266
+ //
267
+ // Callers that need to know "was this a handoff kill?" should inspect
268
+ // `handoffArgv !== null` (the listener's stash), not the exit code.
269
+ const exitCode = exitResult.exitCode;
270
+
271
+ let handoffArgv: string[] | null = null;
272
+ if (listener !== null) {
273
+ handoffArgv = listener.getHandoffArgv();
274
+ await listener.close();
275
+ }
276
+
277
+ return { exitCode, tail: ring.bytes(), handoffArgv };
278
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Windows PTY runner — Windows stub. No PTY allocation, no ring-buffer
3
+ * scanning. claude is spawned with inherited stdio and the result tail is
4
+ * null so detectCrossCwd never matches — cross-cwd-resume is a no-op on
5
+ * Windows for now.
6
+ *
7
+ * Auto-handoff parity is implemented here in the same shape as Unix: when
8
+ * `handoff` is set, fnclaude starts the AF_UNIX socket listener before
9
+ * spawning claude and injects FNCLAUDE_HANDOFF / FNC_SOCKET into the child
10
+ * env. Node's `net.createServer` over an AF_UNIX path works on Windows 10
11
+ * build 17063+. When the listener fires triggered, the parent kills claude.
12
+ *
13
+ * Ported from src/pty_run_windows.go in the Go reference (fnclaude@fnrhombus).
14
+ */
15
+
16
+ import { spawn as childSpawn } from 'node:child_process';
17
+ import { envFromConfig } from '../config.js';
18
+ import { handoffEnv } from '../handoff.js';
19
+ import { SocketListener } from '../mcp/socketListener.js';
20
+ import { ensureCWD, type RunOptions, type RunResult } from '../pty.js';
21
+
22
+ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
23
+ const { claudeArgv, launchCWD, cfg, handoff } = opts;
24
+ if (claudeArgv.length === 0) {
25
+ process.stderr.write('fnclaude: empty argv passed to runWithPTY\n');
26
+ return { exitCode: 1, tail: null, handoffArgv: null };
27
+ }
28
+
29
+ // Build env. Order matches Unix: os env → exec.env → handoff env;
30
+ // last-wins on dupes.
31
+ const envExtras: string[] = [...envFromConfig(cfg)];
32
+ if (handoff !== null) {
33
+ envExtras.push(...handoffEnv(handoff.mode, handoff.socketPath));
34
+ }
35
+ const childEnv: NodeJS.ProcessEnv = { ...process.env };
36
+ for (const kv of envExtras) {
37
+ const eq = kv.indexOf('=');
38
+ if (eq < 0) continue;
39
+ childEnv[kv.slice(0, eq)] = kv.slice(eq + 1);
40
+ }
41
+
42
+ // Start the AF_UNIX listener before the child.
43
+ let listener: SocketListener | null = null;
44
+ if (handoff !== null) {
45
+ try {
46
+ listener = await SocketListener.start({
47
+ spec: handoff,
48
+ cfg,
49
+ launchCWD,
50
+ origArgs: handoff.originalArgs,
51
+ });
52
+ } catch (err) {
53
+ process.stderr.write(
54
+ `fnclaude: socket listener failed to start: ${(err as Error).message}\n`,
55
+ );
56
+ return { exitCode: 1, tail: null, handoffArgv: null };
57
+ }
58
+ }
59
+
60
+ // Fabricate the cwd tree if missing. Windows can't safely tear the cwd
61
+ // out from under a running child the way Unix can; defer the cleanup
62
+ // until the child exits.
63
+ let cleanupCWD: (() => Promise<void>) | null = null;
64
+ try {
65
+ const h = await ensureCWD(launchCWD);
66
+ cleanupCWD = h.cleanup;
67
+ } catch (err) {
68
+ process.stderr.write(`fnclaude: ${(err as Error).message}\n`);
69
+ if (listener !== null) await listener.close();
70
+ return { exitCode: 1, tail: null, handoffArgv: null };
71
+ }
72
+
73
+ let exitCode = 0;
74
+ try {
75
+ const child = childSpawn(claudeArgv[0] as string, claudeArgv.slice(1), {
76
+ cwd: launchCWD,
77
+ env: childEnv,
78
+ stdio: 'inherit',
79
+ // On Windows, this matches Go's exec.Command default — no shell.
80
+ shell: false,
81
+ });
82
+
83
+ // Handoff: when the listener fires, kill the child. Windows doesn't
84
+ // honor SIGTERM/SIGKILL through Node's signal API; child.kill() maps
85
+ // to TerminateProcess which is the closest equivalent.
86
+ if (listener !== null) {
87
+ void listener.triggered().then(() => {
88
+ try {
89
+ child.kill();
90
+ } catch {
91
+ // ignore
92
+ }
93
+ });
94
+ }
95
+
96
+ exitCode = await new Promise<number>((resolve) => {
97
+ child.on('exit', (code, signal) => {
98
+ if (code !== null) resolve(code);
99
+ else if (signal !== null) resolve(1);
100
+ else resolve(0);
101
+ });
102
+ child.on('error', (err) => {
103
+ process.stderr.write(`fnclaude: failed to start claude: ${err.message}\n`);
104
+ resolve(1);
105
+ });
106
+ });
107
+ } finally {
108
+ if (cleanupCWD !== null) {
109
+ try {
110
+ await cleanupCWD();
111
+ } catch (err) {
112
+ process.stderr.write(`fnclaude: ${(err as Error).message}\n`);
113
+ }
114
+ }
115
+ }
116
+
117
+ let handoffArgv: string[] | null = null;
118
+ if (listener !== null) {
119
+ handoffArgv = listener.getHandoffArgv();
120
+ await listener.close();
121
+ }
122
+
123
+ return { exitCode, tail: null, handoffArgv };
124
+ }
package/src/pty.ts ADDED
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Shared PTY scaffolding — RingBuffer, cross-cwd detection regex,
3
+ * reconstructArgv helper, ensureCWD safety wrapper, and the platform-
4
+ * dispatching `runWithPTY` entry point.
5
+ *
6
+ * Ported from src/pty_run.go in the Go reference (fnclaude@fnrhombus).
7
+ *
8
+ * Platform-specific spawn lives in:
9
+ * - src/pty/unix.ts — node-pty under POSIX
10
+ * - src/pty/windows.ts — direct child_process.spawn, no PTY (stub)
11
+ */
12
+
13
+ import { mkdir, rmdir, stat } from 'node:fs/promises';
14
+ import type { Stats } from 'node:fs';
15
+ import { dirname } from 'node:path';
16
+ import type { Config } from './config.js';
17
+ import type { HandoffSpec } from './handoff.js';
18
+ import { isFlag, isMagicWord, preserveArgs, splitLeadingMagic } from './args/preserve.js';
19
+
20
+ // ── RingBuffer ─────────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Capacity of the PTY output tail kept for the post-exit cross-cwd scan.
24
+ *
25
+ * Sized to comfortably hold the cross-cwd message plus all the screen-cleanup
26
+ * escapes claude emits while tearing down its TUI on exit. An earlier 4 KB
27
+ * value was just big enough for the original captured fixture but failed in
28
+ * the wild when claude 2.1.143 emitted more trailing cleanup before exit —
29
+ * the message rotated out of the tail and the intercept silently failed.
30
+ */
31
+ export const RING_BUFFER_SIZE = 64 * 1024;
32
+
33
+ /**
34
+ * Fixed-capacity circular byte buffer. Writes that overflow the capacity
35
+ * discard the oldest data. Only the most recent `cap` bytes are kept, which
36
+ * is all we need for post-exit pattern scanning.
37
+ *
38
+ * Implementation note: backed by a Node Buffer (vs Uint8Array) so the
39
+ * outbound `bytes()` slice is already in the form that node:net /
40
+ * RegExp.exec(buffer.toString('utf8' | 'binary')) callers expect.
41
+ */
42
+ export class RingBuffer {
43
+ private readonly buf: Buffer;
44
+ readonly cap: number;
45
+ private pos = 0;
46
+ private full = false;
47
+
48
+ constructor(capacity: number) {
49
+ if (!Number.isInteger(capacity) || capacity <= 0) {
50
+ throw new RangeError(`RingBuffer capacity must be a positive integer, got ${capacity}`);
51
+ }
52
+ this.cap = capacity;
53
+ this.buf = Buffer.alloc(capacity);
54
+ }
55
+
56
+ /** Append data, dropping oldest bytes when full. */
57
+ write(p: Buffer | Uint8Array | string): void {
58
+ const data = typeof p === 'string' ? Buffer.from(p) : Buffer.from(p);
59
+ if (data.length === 0) return;
60
+ // Hot path for small writes: per-byte loop matches Go's reference impl.
61
+ // For large writes (> cap) skip ahead so we don't churn over discarded
62
+ // bytes that will immediately be overwritten.
63
+ let start = 0;
64
+ if (data.length > this.cap) {
65
+ start = data.length - this.cap;
66
+ this.full = true;
67
+ this.pos = 0;
68
+ }
69
+ for (let i = start; i < data.length; i++) {
70
+ this.buf[this.pos] = data[i] as number;
71
+ this.pos = (this.pos + 1) % this.cap;
72
+ if (this.pos === 0) this.full = true;
73
+ }
74
+ }
75
+
76
+ /** Return ring contents in chronological order (oldest first). */
77
+ bytes(): Buffer {
78
+ if (!this.full) {
79
+ return Buffer.from(this.buf.subarray(0, this.pos));
80
+ }
81
+ return Buffer.concat([
82
+ this.buf.subarray(this.pos),
83
+ this.buf.subarray(0, this.pos),
84
+ ]);
85
+ }
86
+ }
87
+
88
+ // ── cross-cwd detection ────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Matches the cd-and-resume line claude prints when the selected session
92
+ * belongs to a different directory. SOURCE OF TRUTH — keep byte-for-byte
93
+ * identical to src/pty_run.go's `crossCwdRe`.
94
+ *
95
+ * We can't anchor on the "This conversation is from a different directory."
96
+ * preamble: claude's TUI emits cursor-right escapes (e.g. `\x1b[1C`) between
97
+ * words instead of literal spaces, so that sentence is never plain-text in
98
+ * the PTY stream. The "To resume, run:" line, by contrast, is rendered as
99
+ * plain ASCII with real spaces, as is the `cd <path> && claude --resume <uuid>`
100
+ * command — both anchors survive the TUI rendering intact.
101
+ *
102
+ * The `[\s\S]*?` between anchors swallows whatever ANSI / CR / cursor-move
103
+ * goo appears between the two lines (varies by terminal width and TUI
104
+ * layout — observed: `\x1b[K\r\x1b[1C\x1b[1B`).
105
+ */
106
+ export const crossCwdRe =
107
+ /To resume, run:[\s\S]*?cd (\S+) && claude --resume ([0-9a-fA-F-]{36})/g;
108
+
109
+ export interface CrossCwdMatch {
110
+ dest: string;
111
+ uuid: string;
112
+ }
113
+
114
+ /**
115
+ * Scan `tail` for the cross-cwd redirect message. Returns null when no
116
+ * match is found. When multiple matches appear (unlikely but defensive),
117
+ * the LAST match wins.
118
+ */
119
+ export function detectCrossCwd(tail: Buffer): CrossCwdMatch | null {
120
+ // Reset regex internal state — crossCwdRe is `g`-flagged.
121
+ crossCwdRe.lastIndex = 0;
122
+ // Decode as Latin-1 so every byte maps to a code unit; the regex matches
123
+ // ASCII anchors so the multi-byte representation of any non-ASCII bytes
124
+ // never participates in a match. This is the JS equivalent of Go's
125
+ // []byte-scanning behavior.
126
+ const s = tail.toString('latin1');
127
+ let last: RegExpExecArray | null = null;
128
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex loop
129
+ for (let m: RegExpExecArray | null; (m = crossCwdRe.exec(s)) !== null; ) {
130
+ last = m;
131
+ }
132
+ if (last === null) return null;
133
+ return { dest: last[1]!, uuid: last[2]! };
134
+ }
135
+
136
+ // ── reconstructArgv ────────────────────────────────────────────────────────
137
+
138
+ /**
139
+ * Build the new fnclaude argument list when silently relaunching after a
140
+ * cross-cwd session resume.
141
+ *
142
+ * `origArgs` is `process.argv.slice(2)` from the original invocation.
143
+ * `dest` is the destination directory extracted from claude's message;
144
+ * `uuid` is the session id to resume.
145
+ *
146
+ * Algorithm (delegated to preserveArgs): keep leading magic words, strip
147
+ * positional path tokens, keep everything from the first flag onward (no
148
+ * denylist — cross-cwd resume preserves all flags).
149
+ *
150
+ * Result: preserved_magic + [dest] + ["--resume", uuid] + rest.
151
+ *
152
+ * Note: if the original argv already contained --resume / -r / --continue /
153
+ * -c, the picker wouldn't have been shown, the cross-cwd pattern wouldn't
154
+ * have been emitted, and this function wouldn't be called. No special-case
155
+ * is needed for those flags.
156
+ */
157
+ export function reconstructArgv(
158
+ origArgs: readonly string[],
159
+ dest: string,
160
+ uuid: string,
161
+ ): string[] {
162
+ const preserved = preserveArgs(origArgs, null, null);
163
+ const { magic, rest } = splitLeadingMagic(preserved);
164
+ return [...magic, dest, '--resume', uuid, ...rest];
165
+ }
166
+
167
+ // Re-export magic helpers so callers can do everything via the pty module.
168
+ export { isFlag, isMagicWord, splitLeadingMagic };
169
+
170
+ // ── clearScreen ────────────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * Write the ANSI escape sequence that clears the screen and moves the
174
+ * cursor to the top-left. Called before relaunching to hide the brief
175
+ * flicker of the "different directory" message that already scrolled to
176
+ * the terminal before we detected it.
177
+ */
178
+ export function clearScreen(out: NodeJS.WriteStream = process.stdout): void {
179
+ out.write('\x1b[2J\x1b[H');
180
+ }
181
+
182
+ // ── ensureCWD ──────────────────────────────────────────────────────────────
183
+
184
+ export interface EnsureCWDHandle {
185
+ /**
186
+ * Best-effort tear-down of any directory tree fabricated by ensureCWD.
187
+ * Walks back through the dirs we created (deepest first). A dir that
188
+ * was already removed by something else is treated as success
189
+ * (postcondition already satisfied). A dir that's unexpectedly
190
+ * non-empty surfaces as a thrown error.
191
+ */
192
+ cleanup(): Promise<void>;
193
+ }
194
+
195
+ /**
196
+ * Guarantee `dir` exists at the moment of process spawn.
197
+ *
198
+ * Motivation: when fnclaude resumes a session whose stored cwd no longer
199
+ * exists on disk, the kernel returns ENOENT during exec — but Node /
200
+ * Bun formats that against the binary path ("ENOENT … spawn …"), which
201
+ * falsely blames the claude binary. The fix is to ensure the cwd exists
202
+ * before spawn. When it doesn't, we fabricate the full tree, then
203
+ * IMMEDIATELY unwind it after the child has been spawned — once claude
204
+ * has chdir'd into the dir its kernel cwd is held by inode reference and
205
+ * the path on disk is no longer needed.
206
+ *
207
+ * If the path exists but isn't a directory, ensureCWD rejects without
208
+ * touching the filesystem. If the path doesn't exist and an ancestor is
209
+ * a file, ensureCWD likewise rejects without touching the filesystem.
210
+ */
211
+ export async function ensureCWD(dir: string): Promise<EnsureCWDHandle> {
212
+ let info: Stats | null = null;
213
+ try {
214
+ info = await stat(dir);
215
+ } catch (err) {
216
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
217
+ }
218
+ if (info !== null) {
219
+ if (!info.isDirectory()) {
220
+ throw new Error(`session cwd ${dir} exists but is not a directory`);
221
+ }
222
+ return { cleanup: async () => undefined };
223
+ }
224
+
225
+ // Walk up to find the deepest pre-existing ancestor, recording every
226
+ // missing level shallowest-first. We mkdir each level explicitly (rather
227
+ // than calling mkdir({recursive: true})) so cleanup only touches dirs
228
+ // we actually created.
229
+ const missing: string[] = [];
230
+ let p = dir;
231
+ for (;;) {
232
+ missing.unshift(p);
233
+ const parent = dirname(p);
234
+ if (parent === p) {
235
+ throw new Error(`session cwd ${dir} does not exist and has no existing ancestor`);
236
+ }
237
+ let parentInfo: Stats | null = null;
238
+ try {
239
+ parentInfo = await stat(parent);
240
+ } catch (err) {
241
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
242
+ }
243
+ if (parentInfo !== null) {
244
+ if (!parentInfo.isDirectory()) {
245
+ throw new Error(
246
+ `session cwd ${dir} cannot be created: ancestor ${parent} is not a directory`,
247
+ );
248
+ }
249
+ break;
250
+ }
251
+ p = parent;
252
+ }
253
+
254
+ const created: string[] = []; // shallowest-first; cleanup reverses
255
+ for (const level of missing) {
256
+ try {
257
+ await mkdir(level, { mode: 0o755 });
258
+ } catch (err) {
259
+ // Roll back what we already created so we leave the filesystem
260
+ // exactly as we found it.
261
+ for (let i = created.length - 1; i >= 0; i--) {
262
+ try {
263
+ await rmdir(created[i]!);
264
+ } catch {
265
+ // best-effort
266
+ }
267
+ }
268
+ throw new Error(
269
+ `session cwd ${dir} does not exist and could not be created: ${(err as Error).message}`,
270
+ );
271
+ }
272
+ created.push(level);
273
+ }
274
+
275
+ return {
276
+ cleanup: async () => {
277
+ for (let i = created.length - 1; i >= 0; i--) {
278
+ const level = created[i]!;
279
+ try {
280
+ // rmdir() — non-recursive — so a non-empty dir surfaces as an
281
+ // error rather than nuking unexpected content.
282
+ await rmdir(level);
283
+ } catch (err) {
284
+ const code = (err as NodeJS.ErrnoException).code;
285
+ if (code === 'ENOENT') continue; // already gone — fine
286
+ throw new Error(`could not clean up auto-created ${level}: ${(err as Error).message}`);
287
+ }
288
+ }
289
+ },
290
+ };
291
+ }
292
+
293
+ // ── runWithPTY ─────────────────────────────────────────────────────────────
294
+
295
+ /**
296
+ * Result returned by `runWithPTY`. `tail` is the ring buffer contents at
297
+ * the moment the child exited; `handoffArgv` is populated only when the
298
+ * socket listener fired `triggered()` and stashed a relaunch argv.
299
+ *
300
+ * On Windows the tail is null (no PTY, no ring buffer, cross-cwd-resume
301
+ * is a no-op).
302
+ */
303
+ export interface RunResult {
304
+ exitCode: number;
305
+ tail: Buffer | null;
306
+ handoffArgv: string[] | null;
307
+ }
308
+
309
+ export interface RunOptions {
310
+ /**
311
+ * argv to invoke. claudeArgv[0] is conventionally the program name and
312
+ * is ignored by the spawn; claudeArgv.slice(1) is passed as positional
313
+ * args to the child.
314
+ */
315
+ claudeArgv: string[];
316
+ launchCWD: string;
317
+ cfg: Config;
318
+ /** Null disables handoff (no env injection, no listener). */
319
+ handoff: HandoffSpec | null;
320
+ }
321
+
322
+ /**
323
+ * Spawn claude under a PTY (POSIX) or with inherited stdio (Windows),
324
+ * starting the AF_UNIX listener first when `handoff` is set so the socket
325
+ * is ready the moment the child starts.
326
+ *
327
+ * The implementation lives in pty/unix.ts or pty/windows.ts; this is the
328
+ * dispatcher.
329
+ */
330
+ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
331
+ if (process.platform === 'win32') {
332
+ const mod = await import('./pty/windows.js');
333
+ return mod.runWithPTY(opts);
334
+ }
335
+ const mod = await import('./pty/unix.js');
336
+ return mod.runWithPTY(opts);
337
+ }