@fnclaude/cli 1.1.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/fnc.js +34 -79
- package/package.json +6 -9
- package/share/fnclaude/templates/handoff.template.md +11 -0
- package/src/argv/classify.ts +48 -0
- package/src/argv/expand.ts +51 -0
- package/src/argv/intake.ts +52 -0
- package/src/argv/magic.ts +103 -0
- package/src/argv/parse.ts +213 -0
- package/src/argv/preserve-args.ts +333 -0
- package/src/argv/sentinel.ts +41 -0
- package/src/argv/short-flags.ts +152 -0
- package/src/config/load.ts +116 -0
- package/src/handoff/awaiter.ts +140 -0
- package/src/handoff/clean-env.ts +45 -0
- package/src/handoff/kill-and-exec.ts +110 -0
- package/src/handoff/spawn-launcher.ts +185 -0
- package/src/handoff/summary-file.ts +86 -0
- package/src/handoff/trigger.ts +90 -0
- package/src/help-version.ts +151 -0
- package/src/launch/compose-env.ts +34 -0
- package/src/launch/cross-cwd-parse.ts +69 -0
- package/src/launch/cross-cwd-relaunch.ts +95 -0
- package/src/launch/find-claude.ts +52 -0
- package/src/launch/live-permission-reader.ts +133 -0
- package/src/launch/ring-buffer.ts +92 -0
- package/src/main.ts +580 -437
- package/src/mcp/dispatch.ts +240 -0
- package/src/mcp/handlers/clipboard-backends.ts +176 -0
- package/src/mcp/handlers/clipboard.ts +62 -0
- package/src/mcp/handlers/restart.ts +156 -0
- package/src/mcp/handlers/spawn.ts +219 -0
- package/src/mcp/handlers/switch.ts +272 -0
- package/src/mcp/inject-config.ts +59 -0
- package/src/mcp/jsonrpc-server.ts +154 -0
- package/src/mcp/listener.ts +141 -0
- package/src/mcp/parent-dispatch.ts +154 -0
- package/src/mcp/socket-path.ts +48 -0
- package/src/mcp/wire.ts +181 -0
- package/src/name/auto-name.ts +162 -0
- package/src/name/llm-prompt.ts +14 -0
- package/src/name/sanitize.ts +57 -0
- package/src/name/sdk-llm.ts +42 -0
- package/src/noop/seed.ts +63 -0
- package/src/noop/template-source.ts +62 -0
- package/src/path/ensure-cwd.ts +95 -0
- package/src/path/resolve.ts +58 -0
- package/src/prompts/dir.ts +61 -0
- package/src/prompts/load.ts +100 -0
- package/src/prompts/select.ts +43 -0
- package/src/repo/clone-exec.ts +37 -0
- package/src/repo/clone.ts +45 -0
- package/src/repo/gh-runner.ts +68 -0
- package/src/repo/host-aliases.ts +58 -0
- package/src/repo/owner-lookup.ts +71 -0
- package/src/repo/ref.ts +146 -0
- package/src/repo/repo-settings.ts +99 -0
- package/src/repo/resolve-input.ts +179 -0
- package/src/repo/template.ts +92 -0
- package/src/warnings/buffer.ts +39 -0
- package/src/worktree/auto-tmux.ts +45 -0
- package/src/worktree/git-list.ts +73 -0
- package/src/worktree/intercept.ts +150 -0
- package/bin/preflight.js +0 -66
- package/prompts/agent-pitfall.md +0 -1
- package/prompts/noop-router.md +0 -186
- package/prompts/project-switch.md +0 -64
- package/prompts/restart.md +0 -50
- package/prompts/spawn.md +0 -62
- package/src/argParser.ts +0 -367
- package/src/args/preserve.ts +0 -338
- package/src/args.ts +0 -239
- package/src/argv.ts +0 -219
- package/src/autoname.ts +0 -273
- package/src/clipboard.ts +0 -149
- package/src/config.ts +0 -369
- package/src/errors.ts +0 -13
- package/src/handoff.ts +0 -108
- package/src/help.ts +0 -139
- package/src/hostAliases.ts +0 -139
- package/src/index.ts +0 -120
- package/src/mcp/client.ts +0 -645
- package/src/mcp/protocol.ts +0 -445
- package/src/mcp/socketListener.ts +0 -540
- package/src/noop.ts +0 -106
- package/src/passthrough.ts +0 -36
- package/src/paths.ts +0 -55
- package/src/prompts.ts +0 -279
- package/src/pty/unix.ts +0 -429
- package/src/pty/windows.ts +0 -125
- package/src/pty.ts +0 -380
- package/src/repoRef.ts +0 -158
- package/src/repoSettings.ts +0 -144
- package/src/resolver.ts +0 -519
- package/src/sanitize.ts +0 -120
- package/src/sessionState.ts +0 -220
- package/src/silentRelaunch.ts +0 -178
- package/src/spawn.ts +0 -163
- package/src/template.ts +0 -44
- package/src/warnings.ts +0 -34
- package/src/worktree.ts +0 -201
package/src/pty/unix.ts
DELETED
|
@@ -1,429 +0,0 @@
|
|
|
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.2.0-beta.13).
|
|
10
|
-
* Pinned to a 1.2.0 beta because 1.1.0 ships only macOS + Windows
|
|
11
|
-
* prebuilds; 1.2.0-beta.2 is the first version with linux-{x64,arm64}
|
|
12
|
-
* prebuilds. Without them, `npm i -g @fnclaude/cli` forces every Linux
|
|
13
|
-
* user to install Python + make + a C++ toolchain so node-gyp can rebuild
|
|
14
|
-
* the native binding at install time. Verified to load under Bun 1.3.x
|
|
15
|
-
* via the N-API compat layer — both spawn() and the onData/onExit/kill
|
|
16
|
-
* surface work as documented. There's no native Bun PTY primitive yet;
|
|
17
|
-
* if/when Bun ships one, this file is the natural place to swap
|
|
18
|
-
* implementations behind the shared RunOptions API.
|
|
19
|
-
*
|
|
20
|
-
* Lifecycle: each setup phase that needs an undo step returns a small
|
|
21
|
-
* disposable wrapper (`using` / `await using`). The orchestration function
|
|
22
|
-
* stays linear and the teardown happens implicitly when the block exits —
|
|
23
|
-
* including every early-return error path. The disposables are LIFO at
|
|
24
|
-
* dispose time, so order them top-to-bottom from "last to clean" to "first
|
|
25
|
-
* to clean".
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import { spawn as ptySpawn, type IPty } from 'node-pty';
|
|
29
|
-
import { envFromConfig } from '../config.js';
|
|
30
|
-
import { errorMessage } from '../errors.js';
|
|
31
|
-
import { handoffEnv } from '../handoff.js';
|
|
32
|
-
import { SocketListener } from '../mcp/socketListener.js';
|
|
33
|
-
import {
|
|
34
|
-
ensureCWD,
|
|
35
|
-
type EnsureCWDHandle,
|
|
36
|
-
RING_BUFFER_SIZE,
|
|
37
|
-
RingBuffer,
|
|
38
|
-
type RunOptions,
|
|
39
|
-
type RunResult,
|
|
40
|
-
} from '../pty.js';
|
|
41
|
-
|
|
42
|
-
// ── helpers ────────────────────────────────────────────────────────────────
|
|
43
|
-
|
|
44
|
-
/** Convert a `KEY=VALUE` string array into the object shape node-pty wants. */
|
|
45
|
-
function envArrayToObject(
|
|
46
|
-
base: NodeJS.ProcessEnv,
|
|
47
|
-
extras: readonly string[],
|
|
48
|
-
): { [k: string]: string | undefined } {
|
|
49
|
-
// Start from the inherited env. Last-wins, so extras override on
|
|
50
|
-
// duplicate keys (matches Go's append semantics in exec.Command).
|
|
51
|
-
const out: { [k: string]: string | undefined } = { ...base };
|
|
52
|
-
for (const kv of extras) {
|
|
53
|
-
const eq = kv.indexOf('=');
|
|
54
|
-
if (eq < 0) continue;
|
|
55
|
-
out[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
56
|
-
}
|
|
57
|
-
return out;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function getTerminalSize(): { cols: number; rows: number } {
|
|
61
|
-
// node:tty `WriteStream` exposes columns/rows when stdout is a TTY.
|
|
62
|
-
const cols = process.stdout.columns ?? 80;
|
|
63
|
-
const rows = process.stdout.rows ?? 24;
|
|
64
|
-
return { cols, rows };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function isTTY(stream: { isTTY?: boolean }): boolean {
|
|
68
|
-
return stream.isTTY === true;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ── disposable wrappers ────────────────────────────────────────────────────
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Wraps a SocketListener so it's auto-closed on scope exit. Held as
|
|
75
|
-
* `await using` because close() is async. Disposed LAST (declared first)
|
|
76
|
-
* so callers can extract the handoff argv before the socket goes away.
|
|
77
|
-
*/
|
|
78
|
-
class ListenerHandle {
|
|
79
|
-
private constructor(readonly inner: SocketListener) {}
|
|
80
|
-
|
|
81
|
-
static async start(
|
|
82
|
-
opts: Parameters<typeof SocketListener.start>[0],
|
|
83
|
-
): Promise<ListenerHandle> {
|
|
84
|
-
return new ListenerHandle(await SocketListener.start(opts));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async [Symbol.asyncDispose](): Promise<void> {
|
|
88
|
-
await this.inner.close();
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Wraps `ensureCWD`'s fabricated-tree cleanup. The normal lifecycle is
|
|
94
|
-
* `unwindNow()` right after spawn (claude has chdir'd, the path on disk
|
|
95
|
-
* is no longer load-bearing). The asyncDispose is the safety net for any
|
|
96
|
-
* early-return path where spawn never happened.
|
|
97
|
-
*/
|
|
98
|
-
class CwdHandle {
|
|
99
|
-
private done = false;
|
|
100
|
-
|
|
101
|
-
private constructor(private readonly h: EnsureCWDHandle) {}
|
|
102
|
-
|
|
103
|
-
static async ensure(dir: string): Promise<CwdHandle> {
|
|
104
|
-
return new CwdHandle(await ensureCWD(dir));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Eager cleanup — call once spawn has succeeded. Marks the disposer as
|
|
109
|
-
* a no-op so dispose-on-exit doesn't double-fire.
|
|
110
|
-
*/
|
|
111
|
-
async unwindNow(): Promise<void> {
|
|
112
|
-
if (this.done) return;
|
|
113
|
-
this.done = true;
|
|
114
|
-
try {
|
|
115
|
-
await this.h.cleanup();
|
|
116
|
-
} catch (err) {
|
|
117
|
-
process.stderr.write(`fnclaude: ${errorMessage(err)}\n`);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async [Symbol.asyncDispose](): Promise<void> {
|
|
122
|
-
if (this.done) return;
|
|
123
|
-
this.done = true;
|
|
124
|
-
// Safety-net path (early error before spawn) — swallow & ignore. We
|
|
125
|
-
// already surfaced the original error to the caller; a secondary
|
|
126
|
-
// cleanup failure here would just add noise.
|
|
127
|
-
await this.h.cleanup().catch(() => undefined);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Wraps an IPty so it's defensively killed on scope exit. In the happy
|
|
133
|
-
* path the child has already exited (we awaited its exit before falling
|
|
134
|
-
* out of the block); kill() on a dead pty is a no-op. In the error path
|
|
135
|
-
* (something between spawn and exit-await threw) this guarantees we don't
|
|
136
|
-
* leak a child process.
|
|
137
|
-
*/
|
|
138
|
-
class PtyHandle {
|
|
139
|
-
constructor(readonly inner: IPty) {}
|
|
140
|
-
|
|
141
|
-
[Symbol.dispose](): void {
|
|
142
|
-
try {
|
|
143
|
-
this.inner.kill();
|
|
144
|
-
} catch {
|
|
145
|
-
// already dead — fine
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Raw mode on the controlling TTY. Restores to whatever the original
|
|
152
|
-
* `isRaw` was. Returns undefined when stdin isn't a real TTY (test harness,
|
|
153
|
-
* piped invocation) — disposable then becomes a no-op via `?.`.
|
|
154
|
-
*/
|
|
155
|
-
class RawModeHandle {
|
|
156
|
-
private constructor(
|
|
157
|
-
private readonly stdin: NodeJS.ReadStream,
|
|
158
|
-
private readonly wasRaw: boolean,
|
|
159
|
-
) {}
|
|
160
|
-
|
|
161
|
-
static enter(): RawModeHandle | undefined {
|
|
162
|
-
if (!isTTY(process.stdin)) return undefined;
|
|
163
|
-
const stdin = process.stdin;
|
|
164
|
-
const wasRaw = stdin.isRaw;
|
|
165
|
-
try {
|
|
166
|
-
stdin.setRawMode(true);
|
|
167
|
-
} catch {
|
|
168
|
-
// not a real TTY in some test harnesses — skip raw mode silently
|
|
169
|
-
return undefined;
|
|
170
|
-
}
|
|
171
|
-
return new RawModeHandle(stdin, wasRaw);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
[Symbol.dispose](): void {
|
|
175
|
-
try {
|
|
176
|
-
this.stdin.setRawMode(this.wasRaw);
|
|
177
|
-
} catch {
|
|
178
|
-
// best-effort — terminal may already be torn down
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* SIGWINCH forwarder — resize the PTY when the controlling terminal
|
|
185
|
-
* changes size. Dispose removes the listener.
|
|
186
|
-
*/
|
|
187
|
-
class WinchForwarder {
|
|
188
|
-
private constructor(private readonly handler: () => void) {}
|
|
189
|
-
|
|
190
|
-
static start(pty: IPty): WinchForwarder {
|
|
191
|
-
const handler = (): void => {
|
|
192
|
-
const sz = getTerminalSize();
|
|
193
|
-
try {
|
|
194
|
-
pty.resize(sz.cols, sz.rows);
|
|
195
|
-
} catch {
|
|
196
|
-
// ignore — child may have already exited
|
|
197
|
-
}
|
|
198
|
-
};
|
|
199
|
-
process.on('SIGWINCH', handler);
|
|
200
|
-
return new WinchForwarder(handler);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
[Symbol.dispose](): void {
|
|
204
|
-
process.off('SIGWINCH', this.handler);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* stdin → PTY-master pump. Only attaches when stdin is a real TTY —
|
|
210
|
-
* draining a non-TTY pipe could close the PTY prematurely. Dispose detaches
|
|
211
|
-
* the listener and pauses (so the parent doesn't keep consuming) without
|
|
212
|
-
* destroying stdin.
|
|
213
|
-
*/
|
|
214
|
-
class StdinPump {
|
|
215
|
-
private constructor(private readonly handler: (chunk: Buffer) => void) {}
|
|
216
|
-
|
|
217
|
-
static start(pty: IPty): StdinPump | undefined {
|
|
218
|
-
if (!isTTY(process.stdin)) return undefined;
|
|
219
|
-
const handler = (chunk: Buffer): void => {
|
|
220
|
-
try {
|
|
221
|
-
pty.write(chunk);
|
|
222
|
-
} catch {
|
|
223
|
-
// child gone — ignore
|
|
224
|
-
}
|
|
225
|
-
};
|
|
226
|
-
process.stdin.on('data', handler);
|
|
227
|
-
if (typeof process.stdin.resume === 'function') process.stdin.resume();
|
|
228
|
-
return new StdinPump(handler);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
[Symbol.dispose](): void {
|
|
232
|
-
process.stdin.off('data', this.handler);
|
|
233
|
-
if (typeof process.stdin.pause === 'function') process.stdin.pause();
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Arms a kill chain that fires when the SocketListener's `triggered`
|
|
239
|
-
* promise resolves (i.e. a handoff action was dispatched): SIGTERM, then
|
|
240
|
-
* SIGKILL after a 200 ms grace. Dispose clears the pending SIGKILL timer
|
|
241
|
-
* if it hasn't fired yet.
|
|
242
|
-
*/
|
|
243
|
-
class HandoffKill {
|
|
244
|
-
private timer: NodeJS.Timeout | undefined;
|
|
245
|
-
|
|
246
|
-
private constructor() {}
|
|
247
|
-
|
|
248
|
-
static arm(listener: SocketListener, pty: IPty): HandoffKill {
|
|
249
|
-
const h = new HandoffKill();
|
|
250
|
-
void listener.triggered().then(() => {
|
|
251
|
-
try {
|
|
252
|
-
pty.kill('SIGTERM');
|
|
253
|
-
} catch {
|
|
254
|
-
// ignore
|
|
255
|
-
}
|
|
256
|
-
h.timer = setTimeout(() => {
|
|
257
|
-
try {
|
|
258
|
-
pty.kill('SIGKILL');
|
|
259
|
-
} catch {
|
|
260
|
-
// ignore
|
|
261
|
-
}
|
|
262
|
-
}, 200);
|
|
263
|
-
});
|
|
264
|
-
return h;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
[Symbol.dispose](): void {
|
|
268
|
-
if (this.timer !== undefined) clearTimeout(this.timer);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// ── runWithPTY ─────────────────────────────────────────────────────────────
|
|
273
|
-
|
|
274
|
-
export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
|
|
275
|
-
const { claudeArgv, launchCWD, cfg, handoff } = opts;
|
|
276
|
-
|
|
277
|
-
// claudeArgv[0] is the conventional program name and is ignored — node-pty
|
|
278
|
-
// takes file + args separately (mirrors exec.Command).
|
|
279
|
-
if (claudeArgv.length === 0) {
|
|
280
|
-
process.stderr.write('fnclaude: empty argv passed to runWithPTY\n');
|
|
281
|
-
return { exitCode: 1, tail: undefined, handoffArgv: undefined };
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Build the env. Order matches Go: os env → exec.env → handoff env.
|
|
285
|
-
// Last-wins on dupes, so handoff env beats user-supplied dupes.
|
|
286
|
-
const envExtras: string[] = [...envFromConfig(cfg)];
|
|
287
|
-
if (handoff !== undefined) {
|
|
288
|
-
envExtras.push(...handoffEnv(handoff.mode, handoff.socketPath));
|
|
289
|
-
}
|
|
290
|
-
const childEnv = envArrayToObject(process.env, envExtras);
|
|
291
|
-
|
|
292
|
-
// Start the AF_UNIX listener BEFORE the child so the socket is ready the
|
|
293
|
-
// moment claude (and thus the `fnclaude mcp` subprocess) starts. On
|
|
294
|
-
// listener-startup failure we abort the run — handoff is core behavior,
|
|
295
|
-
// not optional.
|
|
296
|
-
let listener: ListenerHandle | undefined;
|
|
297
|
-
try {
|
|
298
|
-
listener =
|
|
299
|
-
handoff !== undefined
|
|
300
|
-
? await ListenerHandle.start({
|
|
301
|
-
spec: handoff,
|
|
302
|
-
cfg,
|
|
303
|
-
launchCWD,
|
|
304
|
-
origArgs: handoff.originalArgs,
|
|
305
|
-
})
|
|
306
|
-
: undefined;
|
|
307
|
-
} catch (err) {
|
|
308
|
-
process.stderr.write(
|
|
309
|
-
`fnclaude: socket listener failed to start: ${errorMessage(err)}\n`,
|
|
310
|
-
);
|
|
311
|
-
return { exitCode: 1, tail: undefined, handoffArgv: undefined };
|
|
312
|
-
}
|
|
313
|
-
// Bind into `await using` scope. Declared first → disposed last, after
|
|
314
|
-
// we've extracted the handoff argv below.
|
|
315
|
-
await using _listener = listener;
|
|
316
|
-
|
|
317
|
-
// Resuming a session whose stored cwd no longer exists used to surface as
|
|
318
|
-
// a misleading ENOENT-against-claude-binary. Fabricate the tree before
|
|
319
|
-
// spawn, then immediately unwind it once claude has chdir'd in.
|
|
320
|
-
let cwd: CwdHandle;
|
|
321
|
-
try {
|
|
322
|
-
cwd = await CwdHandle.ensure(launchCWD);
|
|
323
|
-
} catch (err) {
|
|
324
|
-
process.stderr.write(`fnclaude: ${errorMessage(err)}\n`);
|
|
325
|
-
return { exitCode: 1, tail: undefined, handoffArgv: undefined };
|
|
326
|
-
}
|
|
327
|
-
await using _cwd = cwd;
|
|
328
|
-
|
|
329
|
-
// Spawn under the PTY.
|
|
330
|
-
const { cols, rows } = getTerminalSize();
|
|
331
|
-
let ptyRaw: IPty;
|
|
332
|
-
try {
|
|
333
|
-
ptyRaw = ptySpawn(claudeArgv[0] as string, claudeArgv.slice(1), {
|
|
334
|
-
name: process.env.TERM ?? 'xterm-256color',
|
|
335
|
-
cols,
|
|
336
|
-
rows,
|
|
337
|
-
cwd: launchCWD,
|
|
338
|
-
env: childEnv,
|
|
339
|
-
encoding: null, // emit raw Buffers so the ring tail is byte-accurate
|
|
340
|
-
});
|
|
341
|
-
} catch (err) {
|
|
342
|
-
// cwd + listener will be cleaned up by their `using` disposers on the
|
|
343
|
-
// way out of this scope.
|
|
344
|
-
process.stderr.write(
|
|
345
|
-
`fnclaude: failed to start claude with PTY: ${errorMessage(err)}\n`,
|
|
346
|
-
);
|
|
347
|
-
return { exitCode: 1, tail: undefined, handoffArgv: undefined };
|
|
348
|
-
}
|
|
349
|
-
using pty = new PtyHandle(ptyRaw);
|
|
350
|
-
|
|
351
|
-
// Unwind any fabricated cwd tree now that the child has been spawned —
|
|
352
|
-
// claude's kernel cwd is held by inode reference, so the path on disk
|
|
353
|
-
// is no longer load-bearing. The CwdHandle's disposer becomes a no-op
|
|
354
|
-
// after this.
|
|
355
|
-
await cwd.unwindNow();
|
|
356
|
-
|
|
357
|
-
// Put the controlling terminal into raw mode so the PTY behaves
|
|
358
|
-
// transparently (key-by-key, no local echo, etc.).
|
|
359
|
-
using _rawMode = RawModeHandle.enter();
|
|
360
|
-
|
|
361
|
-
// Ring buffer for post-exit cross-cwd scanning.
|
|
362
|
-
const ring = new RingBuffer(RING_BUFFER_SIZE);
|
|
363
|
-
|
|
364
|
-
// Tee PTY output → stdout + ring buffer. node-pty with encoding:null
|
|
365
|
-
// emits Buffer chunks; the type declaration still says string, but at
|
|
366
|
-
// runtime it's Buffer when encoding is null (the lib's docstring is
|
|
367
|
-
// explicit on this).
|
|
368
|
-
pty.inner.onData((chunk: unknown) => {
|
|
369
|
-
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string);
|
|
370
|
-
ring.write(buf);
|
|
371
|
-
process.stdout.write(buf);
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
// Forward SIGWINCH (terminal resize) to the PTY.
|
|
375
|
-
using _winch = WinchForwarder.start(pty.inner);
|
|
376
|
-
|
|
377
|
-
// Pump stdin → PTY master. We only forward when stdin is a real TTY —
|
|
378
|
-
// otherwise (test harness, piped invocation, headless run) we don't want
|
|
379
|
-
// to drain a non-TTY stdin pipe which could close the PTY prematurely.
|
|
380
|
-
using _stdinPump = StdinPump.start(pty.inner);
|
|
381
|
-
|
|
382
|
-
// Handoff: when the listener fires triggered(), terminate claude.
|
|
383
|
-
// SIGTERM + brief grace + SIGKILL mirrors the legacy SIGUSR1 path
|
|
384
|
-
// — the listener marks "switch fired" and the parent gets out of
|
|
385
|
-
// the PTY loop ASAP.
|
|
386
|
-
using _handoffKill =
|
|
387
|
-
listener !== undefined ? HandoffKill.arm(listener.inner, pty.inner) : undefined;
|
|
388
|
-
|
|
389
|
-
// Wait for the child to exit. Use a one-shot guard so we capture only
|
|
390
|
-
// the FIRST exit event — node-pty under Bun has been observed to emit
|
|
391
|
-
// a follow-up `exit` for the previous pty when a new one is started in
|
|
392
|
-
// the same process, which would pollute the next call's result.
|
|
393
|
-
const exitResult = await new Promise<{ exitCode: number; signal?: number }>(
|
|
394
|
-
(resolve) => {
|
|
395
|
-
let fired = false;
|
|
396
|
-
const disposable = pty.inner.onExit((e) => {
|
|
397
|
-
if (fired) return;
|
|
398
|
-
fired = true;
|
|
399
|
-
disposable.dispose();
|
|
400
|
-
resolve(e);
|
|
401
|
-
});
|
|
402
|
-
},
|
|
403
|
-
);
|
|
404
|
-
|
|
405
|
-
// node-pty's exit shape: { exitCode, signal? }.
|
|
406
|
-
//
|
|
407
|
-
// Under Node: exitCode is set when the child exited normally; signal is
|
|
408
|
-
// set (with the signal number) when terminated by a signal. We could map
|
|
409
|
-
// signal-death to POSIX's `128 + signal`.
|
|
410
|
-
//
|
|
411
|
-
// Under Bun (1.3.x), node-pty's `signal` field is unreliable — observed
|
|
412
|
-
// value `1` on both normal exits AND deliberate SIGTERM kills, so we
|
|
413
|
-
// can't trust it to distinguish "exited normally" from "killed". The
|
|
414
|
-
// safest cross-runtime answer is to use `exitCode` as truth: it tracks
|
|
415
|
-
// the real exit code on both runtimes, and on signal-death it reports
|
|
416
|
-
// 0 (which is the correct "process didn't choose its exit code" answer).
|
|
417
|
-
//
|
|
418
|
-
// Callers that need to know "was this a handoff kill?" should inspect
|
|
419
|
-
// `handoffArgv !== null` (the listener's stash), not the exit code.
|
|
420
|
-
const exitCode = exitResult.exitCode;
|
|
421
|
-
|
|
422
|
-
// Extract handoff argv BEFORE the listener's disposer fires (which will
|
|
423
|
-
// close the socket). The listener disposer runs last because it was
|
|
424
|
-
// declared first in this scope.
|
|
425
|
-
const handoffArgv =
|
|
426
|
-
listener !== undefined ? listener.inner.getHandoffArgv() : undefined;
|
|
427
|
-
|
|
428
|
-
return { exitCode, tail: ring.bytes(), handoffArgv };
|
|
429
|
-
}
|
package/src/pty/windows.ts
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
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 { errorMessage } from '../errors.js';
|
|
19
|
-
import { handoffEnv } from '../handoff.js';
|
|
20
|
-
import { SocketListener } from '../mcp/socketListener.js';
|
|
21
|
-
import { ensureCWD, type RunOptions, type RunResult } from '../pty.js';
|
|
22
|
-
|
|
23
|
-
export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
|
|
24
|
-
const { claudeArgv, launchCWD, cfg, handoff } = opts;
|
|
25
|
-
if (claudeArgv.length === 0) {
|
|
26
|
-
process.stderr.write('fnclaude: empty argv passed to runWithPTY\n');
|
|
27
|
-
return { exitCode: 1, tail: undefined, handoffArgv: undefined };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Build env. Order matches Unix: os env → exec.env → handoff env;
|
|
31
|
-
// last-wins on dupes.
|
|
32
|
-
const envExtras: string[] = [...envFromConfig(cfg)];
|
|
33
|
-
if (handoff !== undefined) {
|
|
34
|
-
envExtras.push(...handoffEnv(handoff.mode, handoff.socketPath));
|
|
35
|
-
}
|
|
36
|
-
const childEnv: NodeJS.ProcessEnv = { ...process.env };
|
|
37
|
-
for (const kv of envExtras) {
|
|
38
|
-
const eq = kv.indexOf('=');
|
|
39
|
-
if (eq < 0) continue;
|
|
40
|
-
childEnv[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Start the AF_UNIX listener before the child.
|
|
44
|
-
let listener: SocketListener | undefined;
|
|
45
|
-
if (handoff !== undefined) {
|
|
46
|
-
try {
|
|
47
|
-
listener = await SocketListener.start({
|
|
48
|
-
spec: handoff,
|
|
49
|
-
cfg,
|
|
50
|
-
launchCWD,
|
|
51
|
-
origArgs: handoff.originalArgs,
|
|
52
|
-
});
|
|
53
|
-
} catch (err) {
|
|
54
|
-
process.stderr.write(
|
|
55
|
-
`fnclaude: socket listener failed to start: ${errorMessage(err)}\n`,
|
|
56
|
-
);
|
|
57
|
-
return { exitCode: 1, tail: undefined, handoffArgv: undefined };
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Fabricate the cwd tree if missing. Windows can't safely tear the cwd
|
|
62
|
-
// out from under a running child the way Unix can; defer the cleanup
|
|
63
|
-
// until the child exits.
|
|
64
|
-
let cleanupCWD: (() => Promise<void>) | undefined;
|
|
65
|
-
try {
|
|
66
|
-
const h = await ensureCWD(launchCWD);
|
|
67
|
-
cleanupCWD = h.cleanup;
|
|
68
|
-
} catch (err) {
|
|
69
|
-
process.stderr.write(`fnclaude: ${errorMessage(err)}\n`);
|
|
70
|
-
if (listener !== undefined) await listener.close();
|
|
71
|
-
return { exitCode: 1, tail: undefined, handoffArgv: undefined };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
let exitCode = 0;
|
|
75
|
-
try {
|
|
76
|
-
const child = childSpawn(claudeArgv[0] as string, claudeArgv.slice(1), {
|
|
77
|
-
cwd: launchCWD,
|
|
78
|
-
env: childEnv,
|
|
79
|
-
stdio: 'inherit',
|
|
80
|
-
// On Windows, this matches Go's exec.Command default — no shell.
|
|
81
|
-
shell: false,
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
// Handoff: when the listener fires, kill the child. Windows doesn't
|
|
85
|
-
// honor SIGTERM/SIGKILL through Node's signal API; child.kill() maps
|
|
86
|
-
// to TerminateProcess which is the closest equivalent.
|
|
87
|
-
if (listener !== undefined) {
|
|
88
|
-
void listener.triggered().then(() => {
|
|
89
|
-
try {
|
|
90
|
-
child.kill();
|
|
91
|
-
} catch {
|
|
92
|
-
// ignore
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
exitCode = await new Promise<number>((resolve) => {
|
|
98
|
-
child.on('exit', (code, signal) => {
|
|
99
|
-
if (code !== null) resolve(code);
|
|
100
|
-
else if (signal !== null) resolve(1);
|
|
101
|
-
else resolve(0);
|
|
102
|
-
});
|
|
103
|
-
child.on('error', (err) => {
|
|
104
|
-
process.stderr.write(`fnclaude: failed to start claude: ${err.message}\n`);
|
|
105
|
-
resolve(1);
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
} finally {
|
|
109
|
-
if (cleanupCWD !== undefined) {
|
|
110
|
-
try {
|
|
111
|
-
await cleanupCWD();
|
|
112
|
-
} catch (err) {
|
|
113
|
-
process.stderr.write(`fnclaude: ${errorMessage(err)}\n`);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
let handoffArgv: string[] | undefined;
|
|
119
|
-
if (listener !== undefined) {
|
|
120
|
-
handoffArgv = listener.getHandoffArgv() ?? undefined;
|
|
121
|
-
await listener.close();
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return { exitCode, tail: undefined, handoffArgv };
|
|
125
|
-
}
|