@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 +3 -2
- package/src/index.ts +17 -0
- package/src/pty/unix.ts +278 -0
- package/src/pty/windows.ts +124 -0
- package/src/pty.ts +337 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fnclaude/cli",
|
|
3
|
-
"version": "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';
|
package/src/pty/unix.ts
ADDED
|
@@ -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
|
+
}
|