@fnclaude/cli 0.7.2 → 0.7.3

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.
@@ -20,9 +20,12 @@ import { handoffContentPath, type HandoffSpec } from '../handoff.js';
20
20
  import { readLivePermissionMode } from '../sessionState.js';
21
21
  import {
22
22
  encodeResponse,
23
- type Op,
23
+ type CopyRequest,
24
24
  type Request,
25
25
  type Response,
26
+ type RestartRequest,
27
+ type SpawnRequest,
28
+ type SwitchRequest,
26
29
  readRequest,
27
30
  } from './protocol.js';
28
31
  import {
@@ -246,7 +249,7 @@ export class SocketListener {
246
249
  }
247
250
 
248
251
  private async dispatch(req: Request): Promise<Response> {
249
- switch (req.op as Op) {
252
+ switch (req.op) {
250
253
  case 'restart':
251
254
  return this.handleRestart(req);
252
255
  case 'switch':
@@ -255,17 +258,24 @@ export class SocketListener {
255
258
  return this.handleSpawn(req);
256
259
  case 'copy_to_clipboard':
257
260
  return this.handleCopy(req);
258
- default:
261
+ default: {
262
+ // Exhaustiveness: adding a new Op variant to Request without
263
+ // handling it here becomes a compile error on this line. The
264
+ // runtime branch defends against malformed wire input that
265
+ // squeezes through with an unknown op.
266
+ const _exhaustive: never = req;
267
+ void _exhaustive;
259
268
  return {
260
269
  action: 'error',
261
- error: `unsupported op ${JSON.stringify(req.op)}`,
270
+ error: `unsupported op ${JSON.stringify((req as { op: unknown }).op)}`,
262
271
  };
272
+ }
263
273
  }
264
274
  }
265
275
 
266
276
  // ── handleRestart ───────────────────────────────────────────────────────
267
277
 
268
- private async handleRestart(req: Request): Promise<Response> {
278
+ private async handleRestart(req: RestartRequest): Promise<Response> {
269
279
  const sid = req.session_id ?? '';
270
280
  if (sid === '') {
271
281
  return {
@@ -305,7 +315,7 @@ export class SocketListener {
305
315
 
306
316
  // ── handleSwitch ────────────────────────────────────────────────────────
307
317
 
308
- private async handleSwitch(req: Request): Promise<Response> {
318
+ private async handleSwitch(req: SwitchRequest): Promise<Response> {
309
319
  if (this.cfg.auto.handoff === 'never') {
310
320
  return this.handleSwitchNeverMode(req);
311
321
  }
@@ -347,7 +357,7 @@ export class SocketListener {
347
357
 
348
358
  // ── handleSpawn ─────────────────────────────────────────────────────────
349
359
 
350
- private async handleSpawn(req: Request): Promise<Response> {
360
+ private async handleSpawn(req: SpawnRequest): Promise<Response> {
351
361
  if (this.cfg.auto.handoff === 'never') {
352
362
  return this.handleSpawnNeverMode(req);
353
363
  }
@@ -395,7 +405,7 @@ export class SocketListener {
395
405
 
396
406
  // ── never-mode handlers ─────────────────────────────────────────────────
397
407
 
398
- private async handleSwitchNeverMode(req: Request): Promise<Response> {
408
+ private async handleSwitchNeverMode(req: SwitchRequest): Promise<Response> {
399
409
  const summaryPath = handoffContentPath();
400
410
  try {
401
411
  await writeFile(summaryPath, req.summary ?? '', { mode: 0o600 });
@@ -424,7 +434,7 @@ export class SocketListener {
424
434
  };
425
435
  }
426
436
 
427
- private async handleSpawnNeverMode(req: Request): Promise<Response> {
437
+ private async handleSpawnNeverMode(req: SpawnRequest): Promise<Response> {
428
438
  const summaryPath = handoffContentPath();
429
439
  try {
430
440
  await writeFile(summaryPath, req.summary ?? '', { mode: 0o600 });
@@ -449,7 +459,7 @@ export class SocketListener {
449
459
 
450
460
  // ── handleCopy ──────────────────────────────────────────────────────────
451
461
 
452
- private async handleCopy(req: Request): Promise<Response> {
462
+ private async handleCopy(req: CopyRequest): Promise<Response> {
453
463
  const { ok } = await this.deps.copyToClipboard(req.text ?? '');
454
464
  return { action: 'done', clipboard_ok: ok };
455
465
  }
@@ -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 { realpathSync, statSync } from 'node:fs';
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
- // Prefer argv[1] (the CLI script) over execPath (the interpreter) so the
106
- // search anchors to the script's neighbours, not bun's bin dir.
107
- const argv1 = process.argv.length > 1 ? process.argv[1] : undefined;
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: 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
- }
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 cleanupCWD: (() => Promise<void>) | null = null;
314
+ let cwd: CwdHandle;
102
315
  try {
103
- const h = await ensureCWD(launchCWD);
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 pty: IPty;
325
+ let ptyRaw: IPty;
114
326
  try {
115
- pty = ptySpawn(claudeArgv[0] as string, claudeArgv.slice(1), {
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
- if (cleanupCWD !== null) await cleanupCWD().catch(() => undefined);
125
- if (listener !== null) await listener.close();
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
- if (cleanupCWD !== null) {
136
- try {
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
- 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
- }
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. 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);
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
- // 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
- }
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
- 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
- }
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
- let handoffArgv: string[] | null = null;
272
- if (listener !== null) {
273
- handoffArgv = listener.getHandoffArgv();
274
- await listener.close();
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
  }