@fnclaude/cli 0.7.2 → 0.7.4
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/README.md +215 -27
- package/package.json +1 -1
- package/src/argParser.ts +19 -32
- package/src/args/preserve.ts +2 -2
- package/src/args.ts +200 -23
- package/src/argv.ts +18 -19
- package/src/config.ts +85 -52
- package/src/help.ts +1 -1
- package/src/hostAliases.ts +40 -15
- package/src/index.ts +11 -5
- package/src/main.ts +209 -88
- package/src/mcp/client.ts +12 -9
- package/src/mcp/protocol.ts +66 -26
- package/src/mcp/socketListener.ts +35 -11
- package/src/passthrough.ts +36 -0
- package/src/paths.ts +31 -0
- package/src/prompts.ts +5 -12
- package/src/pty/unix.ts +250 -107
- package/src/pty.ts +20 -13
- package/src/repoSettings.ts +35 -11
- package/src/sessionState.ts +125 -1
- package/src/silentRelaunch.ts +3 -3
- package/src/spawn.ts +2 -28
- package/src/warnings.ts +15 -31
- package/src/worktree.ts +57 -43
|
@@ -17,12 +17,18 @@ import { createServer, type Server, type Socket } from 'node:net';
|
|
|
17
17
|
import { writeFile, unlink } from 'node:fs/promises';
|
|
18
18
|
import type { Config } from '../config.js';
|
|
19
19
|
import { handoffContentPath, type HandoffSpec } from '../handoff.js';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
appendRestartReminder,
|
|
22
|
+
readLivePermissionMode,
|
|
23
|
+
} from '../sessionState.js';
|
|
21
24
|
import {
|
|
22
25
|
encodeResponse,
|
|
23
|
-
type
|
|
26
|
+
type CopyRequest,
|
|
24
27
|
type Request,
|
|
25
28
|
type Response,
|
|
29
|
+
type RestartRequest,
|
|
30
|
+
type SpawnRequest,
|
|
31
|
+
type SwitchRequest,
|
|
26
32
|
readRequest,
|
|
27
33
|
} from './protocol.js';
|
|
28
34
|
import {
|
|
@@ -246,7 +252,7 @@ export class SocketListener {
|
|
|
246
252
|
}
|
|
247
253
|
|
|
248
254
|
private async dispatch(req: Request): Promise<Response> {
|
|
249
|
-
switch (req.op
|
|
255
|
+
switch (req.op) {
|
|
250
256
|
case 'restart':
|
|
251
257
|
return this.handleRestart(req);
|
|
252
258
|
case 'switch':
|
|
@@ -255,17 +261,24 @@ export class SocketListener {
|
|
|
255
261
|
return this.handleSpawn(req);
|
|
256
262
|
case 'copy_to_clipboard':
|
|
257
263
|
return this.handleCopy(req);
|
|
258
|
-
default:
|
|
264
|
+
default: {
|
|
265
|
+
// Exhaustiveness: adding a new Op variant to Request without
|
|
266
|
+
// handling it here becomes a compile error on this line. The
|
|
267
|
+
// runtime branch defends against malformed wire input that
|
|
268
|
+
// squeezes through with an unknown op.
|
|
269
|
+
const _exhaustive: never = req;
|
|
270
|
+
void _exhaustive;
|
|
259
271
|
return {
|
|
260
272
|
action: 'error',
|
|
261
|
-
error: `unsupported op ${JSON.stringify(req.op)}`,
|
|
273
|
+
error: `unsupported op ${JSON.stringify((req as { op: unknown }).op)}`,
|
|
262
274
|
};
|
|
275
|
+
}
|
|
263
276
|
}
|
|
264
277
|
}
|
|
265
278
|
|
|
266
279
|
// ── handleRestart ───────────────────────────────────────────────────────
|
|
267
280
|
|
|
268
|
-
private async handleRestart(req:
|
|
281
|
+
private async handleRestart(req: RestartRequest): Promise<Response> {
|
|
269
282
|
const sid = req.session_id ?? '';
|
|
270
283
|
if (sid === '') {
|
|
271
284
|
return {
|
|
@@ -298,6 +311,17 @@ export class SocketListener {
|
|
|
298
311
|
}
|
|
299
312
|
const { magic, rest } = splitLeadingMagic(withOverrides);
|
|
300
313
|
|
|
314
|
+
// Append a system-reminder to the session JSONL so the resumed model
|
|
315
|
+
// sees a fresh directive to continue the in-flight work rather than
|
|
316
|
+
// treat the restart as a hard reset and idle (issue #77).
|
|
317
|
+
appendRestartReminder(this.launchCWD, sid, {
|
|
318
|
+
model: req.model,
|
|
319
|
+
effort: req.effort,
|
|
320
|
+
permissionMode: req.permission_mode,
|
|
321
|
+
agent: req.agent,
|
|
322
|
+
ide: req.ide === true,
|
|
323
|
+
});
|
|
324
|
+
|
|
301
325
|
const argv = [...magic, this.launchCWD, '--resume', sid, ...rest];
|
|
302
326
|
this.stashArgv(argv);
|
|
303
327
|
return { action: 'done' };
|
|
@@ -305,7 +329,7 @@ export class SocketListener {
|
|
|
305
329
|
|
|
306
330
|
// ── handleSwitch ────────────────────────────────────────────────────────
|
|
307
331
|
|
|
308
|
-
private async handleSwitch(req:
|
|
332
|
+
private async handleSwitch(req: SwitchRequest): Promise<Response> {
|
|
309
333
|
if (this.cfg.auto.handoff === 'never') {
|
|
310
334
|
return this.handleSwitchNeverMode(req);
|
|
311
335
|
}
|
|
@@ -347,7 +371,7 @@ export class SocketListener {
|
|
|
347
371
|
|
|
348
372
|
// ── handleSpawn ─────────────────────────────────────────────────────────
|
|
349
373
|
|
|
350
|
-
private async handleSpawn(req:
|
|
374
|
+
private async handleSpawn(req: SpawnRequest): Promise<Response> {
|
|
351
375
|
if (this.cfg.auto.handoff === 'never') {
|
|
352
376
|
return this.handleSpawnNeverMode(req);
|
|
353
377
|
}
|
|
@@ -395,7 +419,7 @@ export class SocketListener {
|
|
|
395
419
|
|
|
396
420
|
// ── never-mode handlers ─────────────────────────────────────────────────
|
|
397
421
|
|
|
398
|
-
private async handleSwitchNeverMode(req:
|
|
422
|
+
private async handleSwitchNeverMode(req: SwitchRequest): Promise<Response> {
|
|
399
423
|
const summaryPath = handoffContentPath();
|
|
400
424
|
try {
|
|
401
425
|
await writeFile(summaryPath, req.summary ?? '', { mode: 0o600 });
|
|
@@ -424,7 +448,7 @@ export class SocketListener {
|
|
|
424
448
|
};
|
|
425
449
|
}
|
|
426
450
|
|
|
427
|
-
private async handleSpawnNeverMode(req:
|
|
451
|
+
private async handleSpawnNeverMode(req: SpawnRequest): Promise<Response> {
|
|
428
452
|
const summaryPath = handoffContentPath();
|
|
429
453
|
try {
|
|
430
454
|
await writeFile(summaryPath, req.summary ?? '', { mode: 0o600 });
|
|
@@ -449,7 +473,7 @@ export class SocketListener {
|
|
|
449
473
|
|
|
450
474
|
// ── handleCopy ──────────────────────────────────────────────────────────
|
|
451
475
|
|
|
452
|
-
private async handleCopy(req:
|
|
476
|
+
private async handleCopy(req: CopyRequest): Promise<Response> {
|
|
453
477
|
const { ok } = await this.deps.copyToClipboard(req.text ?? '');
|
|
454
478
|
return { action: 'done', clipboard_ok: ok };
|
|
455
479
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Passthrough-slice inspection helpers — the small predicates that ask
|
|
2
|
+
// "does this argv list already contain <flag>?". Shared between the
|
|
3
|
+
// argParser (which builds the slice) and the downstream pipeline stages
|
|
4
|
+
// (argv.ts, worktree.ts) that decide whether to inject more.
|
|
5
|
+
//
|
|
6
|
+
// Lives in its own module to break the import cycle that would otherwise
|
|
7
|
+
// arise: argParser.ts imports the predicates → argv.ts imports them too
|
|
8
|
+
// → worktree.ts needs nameInPassthrough → if it imported from argParser.ts
|
|
9
|
+
// that would close the loop (argParser → argv → worktree → argParser).
|
|
10
|
+
// With every consumer importing from here, the cycle disappears.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* True when any token is `--setting-sources` or starts with `--setting-sources=`.
|
|
14
|
+
*/
|
|
15
|
+
export function settingSourcesInPassthrough(passthrough: readonly string[]): boolean {
|
|
16
|
+
return passthrough.some(
|
|
17
|
+
(t) => t === '--setting-sources' || t.startsWith('--setting-sources='),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* True when the exact token appears, or any `token=<anything>` form.
|
|
23
|
+
*/
|
|
24
|
+
export function tokenInPassthrough(passthrough: readonly string[], long: string): boolean {
|
|
25
|
+
const prefix = `${long}=`;
|
|
26
|
+
return passthrough.some((t) => t === long || t.startsWith(prefix));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* True when --name or -n (bare or =value) appears anywhere in passthrough.
|
|
31
|
+
*/
|
|
32
|
+
export function nameInPassthrough(passthrough: readonly string[]): boolean {
|
|
33
|
+
return passthrough.some(
|
|
34
|
+
(t) => t === '--name' || t === '-n' || t.startsWith('--name=') || t.startsWith('-n='),
|
|
35
|
+
);
|
|
36
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Path helpers. Ported from expandTildePath in src/resolver.go.
|
|
2
2
|
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { join } from 'node:path';
|
|
6
|
+
import process from 'node:process';
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Expand a leading "~" or "~/" to the user's home directory.
|
|
@@ -22,3 +24,32 @@ export function expandTildePath(p: string): string {
|
|
|
22
24
|
if (p.startsWith('~/')) return join(homedir(), p.slice(2));
|
|
23
25
|
return p;
|
|
24
26
|
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Return the absolute, symlink-resolved path to this fnclaude script.
|
|
30
|
+
*
|
|
31
|
+
* Preference order:
|
|
32
|
+
* 1. `process.argv[1]` — the CLI script path. Anchors to the script's
|
|
33
|
+
* neighbours (so `prompts/`, `mcp` subcommand spawn, spawn-launcher
|
|
34
|
+
* `{bin}` substitution all resolve relative to the script), not the
|
|
35
|
+
* Bun interpreter under `process.execPath`.
|
|
36
|
+
* 2. `process.execPath` — fallback when `argv[1]` is empty.
|
|
37
|
+
*
|
|
38
|
+
* Symlinks are resolved when possible so callers receive the real
|
|
39
|
+
* destination path; failure to resolve is non-fatal (returns the
|
|
40
|
+
* unresolved path).
|
|
41
|
+
*
|
|
42
|
+
* This used to be duplicated in `spawn.ts:selfPath`, `argv.ts`'s
|
|
43
|
+
* `buildFnclaudeMCPConfigJSON`, and `prompts.ts:findPromptsDir` — three
|
|
44
|
+
* verbatim copies of the same six lines. Single source of truth here.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveSelfPath(): string {
|
|
47
|
+
const argv1 = process.argv.length > 1 ? process.argv[1] : undefined;
|
|
48
|
+
let exe = argv1 !== undefined && argv1 !== '' ? argv1 : process.execPath;
|
|
49
|
+
try {
|
|
50
|
+
exe = realpathSync(exe);
|
|
51
|
+
} catch {
|
|
52
|
+
// symlink resolution failure is non-fatal — use the unresolved path
|
|
53
|
+
}
|
|
54
|
+
return exe;
|
|
55
|
+
}
|
package/src/prompts.ts
CHANGED
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
// async to fit Bun/Node idioms; CLI startup is already async-friendly so
|
|
13
13
|
// awaiting `loadPrompts()` adds no observable delay.
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { statSync } from 'node:fs';
|
|
16
16
|
import { readFile, stat } from 'node:fs/promises';
|
|
17
17
|
import { dirname, join } from 'node:path';
|
|
18
18
|
import process from 'node:process';
|
|
19
|
+
import { resolveSelfPath } from './paths.js';
|
|
19
20
|
|
|
20
21
|
export interface PromptSet {
|
|
21
22
|
readonly agentPitfall: string;
|
|
@@ -102,17 +103,9 @@ export function findPromptsDir(): FindPromptsDirResult {
|
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
const
|
|
108
|
-
let exe = argv1 !== undefined && argv1 !== '' ? argv1 : process.execPath;
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
exe = realpathSync(exe);
|
|
112
|
-
} catch {
|
|
113
|
-
// Fall back to unresolved path; symlink resolution failure isn't fatal.
|
|
114
|
-
}
|
|
115
|
-
const exeDir = dirname(exe);
|
|
106
|
+
// Anchor the search at the script's neighbours, not bun's bin dir;
|
|
107
|
+
// resolveSelfPath handles the argv[1] / execPath / realpathSync logic.
|
|
108
|
+
const exeDir = dirname(resolveSelfPath());
|
|
116
109
|
|
|
117
110
|
const candidates = [
|
|
118
111
|
join(exeDir, 'prompts'),
|
package/src/pty/unix.ts
CHANGED
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
* onData/onExit/kill surface work as documented. There's no native Bun PTY
|
|
12
12
|
* primitive yet; if/when Bun ships one, this file is the natural place to
|
|
13
13
|
* swap implementations behind the shared RunOptions API.
|
|
14
|
+
*
|
|
15
|
+
* Lifecycle: each setup phase that needs an undo step returns a small
|
|
16
|
+
* disposable wrapper (`using` / `await using`). The orchestration function
|
|
17
|
+
* stays linear and the teardown happens implicitly when the block exits —
|
|
18
|
+
* including every early-return error path. The disposables are LIFO at
|
|
19
|
+
* dispose time, so order them top-to-bottom from "last to clean" to "first
|
|
20
|
+
* to clean".
|
|
14
21
|
*/
|
|
15
22
|
|
|
16
23
|
import { spawn as ptySpawn, type IPty } from 'node-pty';
|
|
@@ -19,6 +26,7 @@ import { handoffEnv } from '../handoff.js';
|
|
|
19
26
|
import { SocketListener } from '../mcp/socketListener.js';
|
|
20
27
|
import {
|
|
21
28
|
ensureCWD,
|
|
29
|
+
type EnsureCWDHandle,
|
|
22
30
|
RING_BUFFER_SIZE,
|
|
23
31
|
RingBuffer,
|
|
24
32
|
type RunOptions,
|
|
@@ -54,6 +62,207 @@ function isTTY(stream: { isTTY?: boolean }): boolean {
|
|
|
54
62
|
return stream.isTTY === true;
|
|
55
63
|
}
|
|
56
64
|
|
|
65
|
+
// ── disposable wrappers ────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Wraps a SocketListener so it's auto-closed on scope exit. Held as
|
|
69
|
+
* `await using` because close() is async. Disposed LAST (declared first)
|
|
70
|
+
* so callers can extract the handoff argv before the socket goes away.
|
|
71
|
+
*/
|
|
72
|
+
class ListenerHandle {
|
|
73
|
+
private constructor(readonly inner: SocketListener) {}
|
|
74
|
+
|
|
75
|
+
static async start(
|
|
76
|
+
opts: Parameters<typeof SocketListener.start>[0],
|
|
77
|
+
): Promise<ListenerHandle> {
|
|
78
|
+
return new ListenerHandle(await SocketListener.start(opts));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
82
|
+
await this.inner.close();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Wraps `ensureCWD`'s fabricated-tree cleanup. The normal lifecycle is
|
|
88
|
+
* `unwindNow()` right after spawn (claude has chdir'd, the path on disk
|
|
89
|
+
* is no longer load-bearing). The asyncDispose is the safety net for any
|
|
90
|
+
* early-return path where spawn never happened.
|
|
91
|
+
*/
|
|
92
|
+
class CwdHandle {
|
|
93
|
+
private done = false;
|
|
94
|
+
|
|
95
|
+
private constructor(private readonly h: EnsureCWDHandle) {}
|
|
96
|
+
|
|
97
|
+
static async ensure(dir: string): Promise<CwdHandle> {
|
|
98
|
+
return new CwdHandle(await ensureCWD(dir));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Eager cleanup — call once spawn has succeeded. Marks the disposer as
|
|
103
|
+
* a no-op so dispose-on-exit doesn't double-fire.
|
|
104
|
+
*/
|
|
105
|
+
async unwindNow(): Promise<void> {
|
|
106
|
+
if (this.done) return;
|
|
107
|
+
this.done = true;
|
|
108
|
+
try {
|
|
109
|
+
await this.h.cleanup();
|
|
110
|
+
} catch (err) {
|
|
111
|
+
process.stderr.write(`fnclaude: ${(err as Error).message}\n`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
116
|
+
if (this.done) return;
|
|
117
|
+
this.done = true;
|
|
118
|
+
// Safety-net path (early error before spawn) — swallow & ignore. We
|
|
119
|
+
// already surfaced the original error to the caller; a secondary
|
|
120
|
+
// cleanup failure here would just add noise.
|
|
121
|
+
await this.h.cleanup().catch(() => undefined);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Wraps an IPty so it's defensively killed on scope exit. In the happy
|
|
127
|
+
* path the child has already exited (we awaited its exit before falling
|
|
128
|
+
* out of the block); kill() on a dead pty is a no-op. In the error path
|
|
129
|
+
* (something between spawn and exit-await threw) this guarantees we don't
|
|
130
|
+
* leak a child process.
|
|
131
|
+
*/
|
|
132
|
+
class PtyHandle {
|
|
133
|
+
constructor(readonly inner: IPty) {}
|
|
134
|
+
|
|
135
|
+
[Symbol.dispose](): void {
|
|
136
|
+
try {
|
|
137
|
+
this.inner.kill();
|
|
138
|
+
} catch {
|
|
139
|
+
// already dead — fine
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Raw mode on the controlling TTY. Restores to whatever the original
|
|
146
|
+
* `isRaw` was. Returns null when stdin isn't a real TTY (test harness,
|
|
147
|
+
* piped invocation) — disposable then becomes a no-op via `?.`.
|
|
148
|
+
*/
|
|
149
|
+
class RawModeHandle {
|
|
150
|
+
private constructor(
|
|
151
|
+
private readonly stdin: NodeJS.ReadStream,
|
|
152
|
+
private readonly wasRaw: boolean,
|
|
153
|
+
) {}
|
|
154
|
+
|
|
155
|
+
static enter(): RawModeHandle | null {
|
|
156
|
+
if (!isTTY(process.stdin)) return null;
|
|
157
|
+
const stdin = process.stdin;
|
|
158
|
+
const wasRaw = stdin.isRaw;
|
|
159
|
+
try {
|
|
160
|
+
stdin.setRawMode(true);
|
|
161
|
+
} catch {
|
|
162
|
+
// not a real TTY in some test harnesses — skip raw mode silently
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
return new RawModeHandle(stdin, wasRaw);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
[Symbol.dispose](): void {
|
|
169
|
+
try {
|
|
170
|
+
this.stdin.setRawMode(this.wasRaw);
|
|
171
|
+
} catch {
|
|
172
|
+
// best-effort — terminal may already be torn down
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* SIGWINCH forwarder — resize the PTY when the controlling terminal
|
|
179
|
+
* changes size. Dispose removes the listener.
|
|
180
|
+
*/
|
|
181
|
+
class WinchForwarder {
|
|
182
|
+
private constructor(private readonly handler: () => void) {}
|
|
183
|
+
|
|
184
|
+
static start(pty: IPty): WinchForwarder {
|
|
185
|
+
const handler = (): void => {
|
|
186
|
+
const sz = getTerminalSize();
|
|
187
|
+
try {
|
|
188
|
+
pty.resize(sz.cols, sz.rows);
|
|
189
|
+
} catch {
|
|
190
|
+
// ignore — child may have already exited
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
process.on('SIGWINCH', handler);
|
|
194
|
+
return new WinchForwarder(handler);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
[Symbol.dispose](): void {
|
|
198
|
+
process.off('SIGWINCH', this.handler);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* stdin → PTY-master pump. Only attaches when stdin is a real TTY —
|
|
204
|
+
* draining a non-TTY pipe could close the PTY prematurely. Dispose detaches
|
|
205
|
+
* the listener and pauses (so the parent doesn't keep consuming) without
|
|
206
|
+
* destroying stdin.
|
|
207
|
+
*/
|
|
208
|
+
class StdinPump {
|
|
209
|
+
private constructor(private readonly handler: (chunk: Buffer) => void) {}
|
|
210
|
+
|
|
211
|
+
static start(pty: IPty): StdinPump | null {
|
|
212
|
+
if (!isTTY(process.stdin)) return null;
|
|
213
|
+
const handler = (chunk: Buffer): void => {
|
|
214
|
+
try {
|
|
215
|
+
pty.write(chunk);
|
|
216
|
+
} catch {
|
|
217
|
+
// child gone — ignore
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
process.stdin.on('data', handler);
|
|
221
|
+
if (typeof process.stdin.resume === 'function') process.stdin.resume();
|
|
222
|
+
return new StdinPump(handler);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
[Symbol.dispose](): void {
|
|
226
|
+
process.stdin.off('data', this.handler);
|
|
227
|
+
if (typeof process.stdin.pause === 'function') process.stdin.pause();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Arms a kill chain that fires when the SocketListener's `triggered`
|
|
233
|
+
* promise resolves (i.e. a handoff action was dispatched): SIGTERM, then
|
|
234
|
+
* SIGKILL after a 200 ms grace. Dispose clears the pending SIGKILL timer
|
|
235
|
+
* if it hasn't fired yet.
|
|
236
|
+
*/
|
|
237
|
+
class HandoffKill {
|
|
238
|
+
private timer: NodeJS.Timeout | null = null;
|
|
239
|
+
|
|
240
|
+
private constructor() {}
|
|
241
|
+
|
|
242
|
+
static arm(listener: SocketListener, pty: IPty): HandoffKill {
|
|
243
|
+
const h = new HandoffKill();
|
|
244
|
+
void listener.triggered().then(() => {
|
|
245
|
+
try {
|
|
246
|
+
pty.kill('SIGTERM');
|
|
247
|
+
} catch {
|
|
248
|
+
// ignore
|
|
249
|
+
}
|
|
250
|
+
h.timer = setTimeout(() => {
|
|
251
|
+
try {
|
|
252
|
+
pty.kill('SIGKILL');
|
|
253
|
+
} catch {
|
|
254
|
+
// ignore
|
|
255
|
+
}
|
|
256
|
+
}, 200);
|
|
257
|
+
});
|
|
258
|
+
return h;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
[Symbol.dispose](): void {
|
|
262
|
+
if (this.timer !== null) clearTimeout(this.timer);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
57
266
|
// ── runWithPTY ─────────────────────────────────────────────────────────────
|
|
58
267
|
|
|
59
268
|
export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
|
|
@@ -78,41 +287,44 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
|
|
|
78
287
|
// moment claude (and thus the `fnclaude mcp` subprocess) starts. On
|
|
79
288
|
// listener-startup failure we abort the run — handoff is core behavior,
|
|
80
289
|
// not optional.
|
|
81
|
-
let listener:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
290
|
+
let listener: ListenerHandle | null;
|
|
291
|
+
try {
|
|
292
|
+
listener =
|
|
293
|
+
handoff !== null
|
|
294
|
+
? await ListenerHandle.start({
|
|
295
|
+
spec: handoff,
|
|
296
|
+
cfg,
|
|
297
|
+
launchCWD,
|
|
298
|
+
origArgs: handoff.originalArgs,
|
|
299
|
+
})
|
|
300
|
+
: null;
|
|
301
|
+
} catch (err) {
|
|
302
|
+
process.stderr.write(
|
|
303
|
+
`fnclaude: socket listener failed to start: ${(err as Error).message}\n`,
|
|
304
|
+
);
|
|
305
|
+
return { exitCode: 1, tail: null, handoffArgv: null };
|
|
96
306
|
}
|
|
307
|
+
// Bind into `await using` scope. Declared first → disposed last, after
|
|
308
|
+
// we've extracted the handoff argv below.
|
|
309
|
+
await using _listener = listener;
|
|
97
310
|
|
|
98
311
|
// Resuming a session whose stored cwd no longer exists used to surface as
|
|
99
312
|
// a misleading ENOENT-against-claude-binary. Fabricate the tree before
|
|
100
313
|
// spawn, then immediately unwind it once claude has chdir'd in.
|
|
101
|
-
let
|
|
314
|
+
let cwd: CwdHandle;
|
|
102
315
|
try {
|
|
103
|
-
|
|
104
|
-
cleanupCWD = h.cleanup;
|
|
316
|
+
cwd = await CwdHandle.ensure(launchCWD);
|
|
105
317
|
} catch (err) {
|
|
106
318
|
process.stderr.write(`fnclaude: ${(err as Error).message}\n`);
|
|
107
|
-
if (listener !== null) await listener.close();
|
|
108
319
|
return { exitCode: 1, tail: null, handoffArgv: null };
|
|
109
320
|
}
|
|
321
|
+
await using _cwd = cwd;
|
|
110
322
|
|
|
111
323
|
// Spawn under the PTY.
|
|
112
324
|
const { cols, rows } = getTerminalSize();
|
|
113
|
-
let
|
|
325
|
+
let ptyRaw: IPty;
|
|
114
326
|
try {
|
|
115
|
-
|
|
327
|
+
ptyRaw = ptySpawn(claudeArgv[0] as string, claudeArgv.slice(1), {
|
|
116
328
|
name: process.env.TERM ?? 'xterm-256color',
|
|
117
329
|
cols,
|
|
118
330
|
rows,
|
|
@@ -121,44 +333,24 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
|
|
|
121
333
|
encoding: null, // emit raw Buffers so the ring tail is byte-accurate
|
|
122
334
|
});
|
|
123
335
|
} catch (err) {
|
|
124
|
-
|
|
125
|
-
|
|
336
|
+
// cwd + listener will be cleaned up by their `using` disposers on the
|
|
337
|
+
// way out of this scope.
|
|
126
338
|
process.stderr.write(
|
|
127
339
|
`fnclaude: failed to start claude with PTY: ${(err as Error).message}\n`,
|
|
128
340
|
);
|
|
129
341
|
return { exitCode: 1, tail: null, handoffArgv: null };
|
|
130
342
|
}
|
|
343
|
+
using pty = new PtyHandle(ptyRaw);
|
|
131
344
|
|
|
132
345
|
// Unwind any fabricated cwd tree now that the child has been spawned —
|
|
133
346
|
// claude's kernel cwd is held by inode reference, so the path on disk
|
|
134
|
-
// is no longer load-bearing.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
await cleanupCWD();
|
|
138
|
-
} catch (err) {
|
|
139
|
-
process.stderr.write(`fnclaude: ${(err as Error).message}\n`);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
347
|
+
// is no longer load-bearing. The CwdHandle's disposer becomes a no-op
|
|
348
|
+
// after this.
|
|
349
|
+
await cwd.unwindNow();
|
|
142
350
|
|
|
143
351
|
// Put the controlling terminal into raw mode so the PTY behaves
|
|
144
352
|
// transparently (key-by-key, no local echo, etc.).
|
|
145
|
-
|
|
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
|
-
}
|
|
353
|
+
using _rawMode = RawModeHandle.enter();
|
|
162
354
|
|
|
163
355
|
// Ring buffer for post-exit cross-cwd scanning.
|
|
164
356
|
const ring = new RingBuffer(RING_BUFFER_SIZE);
|
|
@@ -167,64 +359,25 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
|
|
|
167
359
|
// emits Buffer chunks; the type declaration still says string, but at
|
|
168
360
|
// runtime it's Buffer when encoding is null (the lib's docstring is
|
|
169
361
|
// explicit on this).
|
|
170
|
-
pty.onData((chunk: unknown) => {
|
|
362
|
+
pty.inner.onData((chunk: unknown) => {
|
|
171
363
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string);
|
|
172
364
|
ring.write(buf);
|
|
173
365
|
process.stdout.write(buf);
|
|
174
366
|
});
|
|
175
367
|
|
|
176
|
-
// Forward SIGWINCH (terminal resize) to the PTY.
|
|
177
|
-
|
|
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);
|
|
368
|
+
// Forward SIGWINCH (terminal resize) to the PTY.
|
|
369
|
+
using _winch = WinchForwarder.start(pty.inner);
|
|
187
370
|
|
|
188
371
|
// Pump stdin → PTY master. We only forward when stdin is a real TTY —
|
|
189
372
|
// otherwise (test harness, piped invocation, headless run) we don't want
|
|
190
373
|
// to drain a non-TTY stdin pipe which could close the PTY prematurely.
|
|
191
|
-
|
|
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
|
-
}
|
|
374
|
+
using _stdinPump = StdinPump.start(pty.inner);
|
|
206
375
|
|
|
207
376
|
// Handoff: when the listener fires triggered(), terminate claude.
|
|
208
377
|
// SIGTERM + brief grace + SIGKILL mirrors the legacy SIGUSR1 path
|
|
209
378
|
// — the listener marks "switch fired" and the parent gets out of
|
|
210
379
|
// the PTY loop ASAP.
|
|
211
|
-
|
|
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
|
-
}
|
|
380
|
+
using _handoffKill = listener !== null ? HandoffKill.arm(listener.inner, pty.inner) : null;
|
|
228
381
|
|
|
229
382
|
// Wait for the child to exit. Use a one-shot guard so we capture only
|
|
230
383
|
// the FIRST exit event — node-pty under Bun has been observed to emit
|
|
@@ -233,7 +386,7 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
|
|
|
233
386
|
const exitResult = await new Promise<{ exitCode: number; signal?: number }>(
|
|
234
387
|
(resolve) => {
|
|
235
388
|
let fired = false;
|
|
236
|
-
const disposable = pty.onExit((e) => {
|
|
389
|
+
const disposable = pty.inner.onExit((e) => {
|
|
237
390
|
if (fired) return;
|
|
238
391
|
fired = true;
|
|
239
392
|
disposable.dispose();
|
|
@@ -242,15 +395,6 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
|
|
|
242
395
|
},
|
|
243
396
|
);
|
|
244
397
|
|
|
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
398
|
// node-pty's exit shape: { exitCode, signal? }.
|
|
255
399
|
//
|
|
256
400
|
// Under Node: exitCode is set when the child exited normally; signal is
|
|
@@ -268,11 +412,10 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
|
|
|
268
412
|
// `handoffArgv !== null` (the listener's stash), not the exit code.
|
|
269
413
|
const exitCode = exitResult.exitCode;
|
|
270
414
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
415
|
+
// Extract handoff argv BEFORE the listener's disposer fires (which will
|
|
416
|
+
// close the socket). The listener disposer runs last because it was
|
|
417
|
+
// declared first in this scope.
|
|
418
|
+
const handoffArgv = listener !== null ? listener.inner.getHandoffArgv() : null;
|
|
276
419
|
|
|
277
420
|
return { exitCode, tail: ring.bytes(), handoffArgv };
|
|
278
421
|
}
|