@fnclaude/cli 0.7.7 → 1.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.
@@ -16,6 +16,7 @@
16
16
  import { createServer, type Server, type Socket } from 'node:net';
17
17
  import { writeFile, unlink, chmod } from 'node:fs/promises';
18
18
  import type { Config } from '../config.js';
19
+ import { errorMessage } from '../errors.js';
19
20
  import { handoffContentPath, type HandoffSpec } from '../handoff.js';
20
21
  import {
21
22
  appendRestartReminder,
@@ -119,7 +120,7 @@ export class SocketListener {
119
120
  private readonly origArgs: readonly string[];
120
121
  private readonly deps: SocketListenerDeps;
121
122
 
122
- private handoffArgv: string[] | null = null;
123
+ private handoffArgv: string[] | undefined;
123
124
  private triggeredResolve!: () => void;
124
125
  private readonly triggeredPromise: Promise<void>;
125
126
  private triggeredFired = false;
@@ -192,7 +193,7 @@ export class SocketListener {
192
193
  // already gone
193
194
  }
194
195
  throw new Error(
195
- `failed to chmod socket to 0600 at ${opts.spec.socketPath}: ${(err as Error).message}`,
196
+ `failed to chmod socket to 0600 at ${opts.spec.socketPath}: ${errorMessage(err)}`,
196
197
  );
197
198
  }
198
199
  return listener;
@@ -220,13 +221,13 @@ export class SocketListener {
220
221
  }
221
222
 
222
223
  /** Parsed argv (leading "fnclaude" token already dropped) to re-exec. */
223
- getHandoffArgv(): string[] | null {
224
- return this.handoffArgv === null ? null : this.handoffArgv.slice();
224
+ getHandoffArgv(): string[] | undefined {
225
+ return this.handoffArgv === undefined ? undefined : this.handoffArgv.slice();
225
226
  }
226
227
 
227
228
  /** First-wins. Subsequent calls don't overwrite. */
228
229
  private stashArgv(argv: string[]): void {
229
- if (this.handoffArgv === null) {
230
+ if (this.handoffArgv === undefined) {
230
231
  this.handoffArgv = argv;
231
232
  }
232
233
  if (!this.triggeredFired) {
@@ -240,20 +241,20 @@ export class SocketListener {
240
241
  private async handleConn(sock: Socket): Promise<void> {
241
242
  try {
242
243
  // Read one Request from the socket's data stream.
243
- let req: Request | null;
244
+ let req: Request | undefined;
244
245
  try {
245
246
  req = await readRequest(sock);
246
247
  } catch (err) {
247
248
  sock.write(
248
249
  encodeResponse({
249
250
  action: 'error',
250
- error: `malformed request: ${(err as Error).message}`,
251
+ error: `malformed request: ${errorMessage(err)}`,
251
252
  }),
252
253
  );
253
254
  sock.end();
254
255
  return;
255
256
  }
256
- if (req === null) {
257
+ if (req === undefined) {
257
258
  // EOF without a line — client disconnected silently. Nothing to respond.
258
259
  sock.end();
259
260
  return;
@@ -267,7 +268,7 @@ export class SocketListener {
267
268
  sock.write(
268
269
  encodeResponse({
269
270
  action: 'error',
270
- error: `handler failure: ${(err as Error).message}`,
271
+ error: `handler failure: ${errorMessage(err)}`,
271
272
  }),
272
273
  );
273
274
  } catch {
@@ -305,8 +306,8 @@ export class SocketListener {
305
306
  // ── handleRestart ───────────────────────────────────────────────────────
306
307
 
307
308
  private async handleRestart(req: RestartRequest): Promise<Response> {
308
- const sid = req.session_id ?? '';
309
- if (sid === '') {
309
+ const sid = req.session_id;
310
+ if (!sid) {
310
311
  return {
311
312
  action: 'error',
312
313
  error:
@@ -327,7 +328,7 @@ export class SocketListener {
327
328
  // Auto-capture live permission-mode from the session JSONL when the
328
329
  // caller didn't override AND none was preserved.
329
330
  if (
330
- (req.permission_mode === undefined || req.permission_mode === '') &&
331
+ !req.permission_mode &&
331
332
  !flagPresent(withOverrides, '--permission-mode')
332
333
  ) {
333
334
  const live = readLivePermissionMode(this.launchCWD, sid);
@@ -363,20 +364,19 @@ export class SocketListener {
363
364
  try {
364
365
  await writeFile(summaryPath, req.summary ?? '', { mode: 0o600 });
365
366
  } catch (err) {
366
- return { action: 'error', error: `write summary: ${(err as Error).message}` };
367
+ return { action: 'error', error: `write summary: ${errorMessage(err)}` };
367
368
  }
368
369
 
369
370
  // Preserve user flags minus the transfer denylist, then apply overrides.
370
371
  const preserved = preserveArgs(this.origArgs, transferDenyFlags, transferDenyBareOK);
371
372
  let withOverrides = applyOverrides(preserved, req);
372
373
  // Auto-capture live permission-mode (same pattern as restart).
373
- const sid = req.session_id ?? '';
374
374
  if (
375
- (req.permission_mode === undefined || req.permission_mode === '') &&
375
+ !req.permission_mode &&
376
376
  !flagPresent(withOverrides, '--permission-mode') &&
377
- sid !== ''
377
+ req.session_id
378
378
  ) {
379
- const live = readLivePermissionMode(this.launchCWD, sid);
379
+ const live = readLivePermissionMode(this.launchCWD, req.session_id);
380
380
  if (live !== undefined) {
381
381
  withOverrides = [...withOverrides, '--permission-mode', live];
382
382
  }
@@ -405,7 +405,7 @@ export class SocketListener {
405
405
  try {
406
406
  await writeFile(summaryPath, req.summary ?? '', { mode: 0o600 });
407
407
  } catch (err) {
408
- return { action: 'error', error: `write summary: ${(err as Error).message}` };
408
+ return { action: 'error', error: `write summary: ${errorMessage(err)}` };
409
409
  }
410
410
 
411
411
  // Spawn doesn't preserve startup flags — fresh-start sibling.
@@ -450,7 +450,7 @@ export class SocketListener {
450
450
  try {
451
451
  await writeFile(summaryPath, req.summary ?? '', { mode: 0o600 });
452
452
  } catch (err) {
453
- return { action: 'error', error: `write summary: ${(err as Error).message}` };
453
+ return { action: 'error', error: `write summary: ${errorMessage(err)}` };
454
454
  }
455
455
  const preserved = preserveArgs(this.origArgs, transferDenyFlags, transferDenyBareOK);
456
456
  const withOverrides = applyOverrides(preserved, req);
@@ -479,7 +479,7 @@ export class SocketListener {
479
479
  try {
480
480
  await writeFile(summaryPath, req.summary ?? '', { mode: 0o600 });
481
481
  } catch (err) {
482
- return { action: 'error', error: `write summary: ${(err as Error).message}` };
482
+ return { action: 'error', error: `write summary: ${errorMessage(err)}` };
483
483
  }
484
484
  const extraArgs = applyOverrides([], req);
485
485
  const dest = req.destination ?? '';
package/src/pty/unix.ts CHANGED
@@ -27,6 +27,7 @@
27
27
 
28
28
  import { spawn as ptySpawn, type IPty } from 'node-pty';
29
29
  import { envFromConfig } from '../config.js';
30
+ import { errorMessage } from '../errors.js';
30
31
  import { handoffEnv } from '../handoff.js';
31
32
  import { SocketListener } from '../mcp/socketListener.js';
32
33
  import {
@@ -113,7 +114,7 @@ class CwdHandle {
113
114
  try {
114
115
  await this.h.cleanup();
115
116
  } catch (err) {
116
- process.stderr.write(`fnclaude: ${(err as Error).message}\n`);
117
+ process.stderr.write(`fnclaude: ${errorMessage(err)}\n`);
117
118
  }
118
119
  }
119
120
 
@@ -148,7 +149,7 @@ class PtyHandle {
148
149
 
149
150
  /**
150
151
  * Raw mode on the controlling TTY. Restores to whatever the original
151
- * `isRaw` was. Returns null when stdin isn't a real TTY (test harness,
152
+ * `isRaw` was. Returns undefined when stdin isn't a real TTY (test harness,
152
153
  * piped invocation) — disposable then becomes a no-op via `?.`.
153
154
  */
154
155
  class RawModeHandle {
@@ -157,15 +158,15 @@ class RawModeHandle {
157
158
  private readonly wasRaw: boolean,
158
159
  ) {}
159
160
 
160
- static enter(): RawModeHandle | null {
161
- if (!isTTY(process.stdin)) return null;
161
+ static enter(): RawModeHandle | undefined {
162
+ if (!isTTY(process.stdin)) return undefined;
162
163
  const stdin = process.stdin;
163
164
  const wasRaw = stdin.isRaw;
164
165
  try {
165
166
  stdin.setRawMode(true);
166
167
  } catch {
167
168
  // not a real TTY in some test harnesses — skip raw mode silently
168
- return null;
169
+ return undefined;
169
170
  }
170
171
  return new RawModeHandle(stdin, wasRaw);
171
172
  }
@@ -213,8 +214,8 @@ class WinchForwarder {
213
214
  class StdinPump {
214
215
  private constructor(private readonly handler: (chunk: Buffer) => void) {}
215
216
 
216
- static start(pty: IPty): StdinPump | null {
217
- if (!isTTY(process.stdin)) return null;
217
+ static start(pty: IPty): StdinPump | undefined {
218
+ if (!isTTY(process.stdin)) return undefined;
218
219
  const handler = (chunk: Buffer): void => {
219
220
  try {
220
221
  pty.write(chunk);
@@ -240,7 +241,7 @@ class StdinPump {
240
241
  * if it hasn't fired yet.
241
242
  */
242
243
  class HandoffKill {
243
- private timer: NodeJS.Timeout | null = null;
244
+ private timer: NodeJS.Timeout | undefined;
244
245
 
245
246
  private constructor() {}
246
247
 
@@ -264,7 +265,7 @@ class HandoffKill {
264
265
  }
265
266
 
266
267
  [Symbol.dispose](): void {
267
- if (this.timer !== null) clearTimeout(this.timer);
268
+ if (this.timer !== undefined) clearTimeout(this.timer);
268
269
  }
269
270
  }
270
271
 
@@ -277,13 +278,13 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
277
278
  // takes file + args separately (mirrors exec.Command).
278
279
  if (claudeArgv.length === 0) {
279
280
  process.stderr.write('fnclaude: empty argv passed to runWithPTY\n');
280
- return { exitCode: 1, tail: null, handoffArgv: null };
281
+ return { exitCode: 1, tail: undefined, handoffArgv: undefined };
281
282
  }
282
283
 
283
284
  // Build the env. Order matches Go: os env → exec.env → handoff env.
284
285
  // Last-wins on dupes, so handoff env beats user-supplied dupes.
285
286
  const envExtras: string[] = [...envFromConfig(cfg)];
286
- if (handoff !== null) {
287
+ if (handoff !== undefined) {
287
288
  envExtras.push(...handoffEnv(handoff.mode, handoff.socketPath));
288
289
  }
289
290
  const childEnv = envArrayToObject(process.env, envExtras);
@@ -292,22 +293,22 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
292
293
  // moment claude (and thus the `fnclaude mcp` subprocess) starts. On
293
294
  // listener-startup failure we abort the run — handoff is core behavior,
294
295
  // not optional.
295
- let listener: ListenerHandle | null;
296
+ let listener: ListenerHandle | undefined;
296
297
  try {
297
298
  listener =
298
- handoff !== null
299
+ handoff !== undefined
299
300
  ? await ListenerHandle.start({
300
301
  spec: handoff,
301
302
  cfg,
302
303
  launchCWD,
303
304
  origArgs: handoff.originalArgs,
304
305
  })
305
- : null;
306
+ : undefined;
306
307
  } catch (err) {
307
308
  process.stderr.write(
308
- `fnclaude: socket listener failed to start: ${(err as Error).message}\n`,
309
+ `fnclaude: socket listener failed to start: ${errorMessage(err)}\n`,
309
310
  );
310
- return { exitCode: 1, tail: null, handoffArgv: null };
311
+ return { exitCode: 1, tail: undefined, handoffArgv: undefined };
311
312
  }
312
313
  // Bind into `await using` scope. Declared first → disposed last, after
313
314
  // we've extracted the handoff argv below.
@@ -320,8 +321,8 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
320
321
  try {
321
322
  cwd = await CwdHandle.ensure(launchCWD);
322
323
  } catch (err) {
323
- process.stderr.write(`fnclaude: ${(err as Error).message}\n`);
324
- return { exitCode: 1, tail: null, handoffArgv: null };
324
+ process.stderr.write(`fnclaude: ${errorMessage(err)}\n`);
325
+ return { exitCode: 1, tail: undefined, handoffArgv: undefined };
325
326
  }
326
327
  await using _cwd = cwd;
327
328
 
@@ -341,9 +342,9 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
341
342
  // cwd + listener will be cleaned up by their `using` disposers on the
342
343
  // way out of this scope.
343
344
  process.stderr.write(
344
- `fnclaude: failed to start claude with PTY: ${(err as Error).message}\n`,
345
+ `fnclaude: failed to start claude with PTY: ${errorMessage(err)}\n`,
345
346
  );
346
- return { exitCode: 1, tail: null, handoffArgv: null };
347
+ return { exitCode: 1, tail: undefined, handoffArgv: undefined };
347
348
  }
348
349
  using pty = new PtyHandle(ptyRaw);
349
350
 
@@ -382,7 +383,8 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
382
383
  // SIGTERM + brief grace + SIGKILL mirrors the legacy SIGUSR1 path
383
384
  // — the listener marks "switch fired" and the parent gets out of
384
385
  // the PTY loop ASAP.
385
- using _handoffKill = listener !== null ? HandoffKill.arm(listener.inner, pty.inner) : null;
386
+ using _handoffKill =
387
+ listener !== undefined ? HandoffKill.arm(listener.inner, pty.inner) : undefined;
386
388
 
387
389
  // Wait for the child to exit. Use a one-shot guard so we capture only
388
390
  // the FIRST exit event — node-pty under Bun has been observed to emit
@@ -420,7 +422,8 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
420
422
  // Extract handoff argv BEFORE the listener's disposer fires (which will
421
423
  // close the socket). The listener disposer runs last because it was
422
424
  // declared first in this scope.
423
- const handoffArgv = listener !== null ? listener.inner.getHandoffArgv() : null;
425
+ const handoffArgv =
426
+ listener !== undefined ? listener.inner.getHandoffArgv() : undefined;
424
427
 
425
428
  return { exitCode, tail: ring.bytes(), handoffArgv };
426
429
  }
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { spawn as childSpawn } from 'node:child_process';
17
17
  import { envFromConfig } from '../config.js';
18
+ import { errorMessage } from '../errors.js';
18
19
  import { handoffEnv } from '../handoff.js';
19
20
  import { SocketListener } from '../mcp/socketListener.js';
20
21
  import { ensureCWD, type RunOptions, type RunResult } from '../pty.js';
@@ -23,13 +24,13 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
23
24
  const { claudeArgv, launchCWD, cfg, handoff } = opts;
24
25
  if (claudeArgv.length === 0) {
25
26
  process.stderr.write('fnclaude: empty argv passed to runWithPTY\n');
26
- return { exitCode: 1, tail: null, handoffArgv: null };
27
+ return { exitCode: 1, tail: undefined, handoffArgv: undefined };
27
28
  }
28
29
 
29
30
  // Build env. Order matches Unix: os env → exec.env → handoff env;
30
31
  // last-wins on dupes.
31
32
  const envExtras: string[] = [...envFromConfig(cfg)];
32
- if (handoff !== null) {
33
+ if (handoff !== undefined) {
33
34
  envExtras.push(...handoffEnv(handoff.mode, handoff.socketPath));
34
35
  }
35
36
  const childEnv: NodeJS.ProcessEnv = { ...process.env };
@@ -40,8 +41,8 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
40
41
  }
41
42
 
42
43
  // Start the AF_UNIX listener before the child.
43
- let listener: SocketListener | null = null;
44
- if (handoff !== null) {
44
+ let listener: SocketListener | undefined;
45
+ if (handoff !== undefined) {
45
46
  try {
46
47
  listener = await SocketListener.start({
47
48
  spec: handoff,
@@ -51,23 +52,23 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
51
52
  });
52
53
  } catch (err) {
53
54
  process.stderr.write(
54
- `fnclaude: socket listener failed to start: ${(err as Error).message}\n`,
55
+ `fnclaude: socket listener failed to start: ${errorMessage(err)}\n`,
55
56
  );
56
- return { exitCode: 1, tail: null, handoffArgv: null };
57
+ return { exitCode: 1, tail: undefined, handoffArgv: undefined };
57
58
  }
58
59
  }
59
60
 
60
61
  // Fabricate the cwd tree if missing. Windows can't safely tear the cwd
61
62
  // out from under a running child the way Unix can; defer the cleanup
62
63
  // until the child exits.
63
- let cleanupCWD: (() => Promise<void>) | null = null;
64
+ let cleanupCWD: (() => Promise<void>) | undefined;
64
65
  try {
65
66
  const h = await ensureCWD(launchCWD);
66
67
  cleanupCWD = h.cleanup;
67
68
  } 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 };
69
+ process.stderr.write(`fnclaude: ${errorMessage(err)}\n`);
70
+ if (listener !== undefined) await listener.close();
71
+ return { exitCode: 1, tail: undefined, handoffArgv: undefined };
71
72
  }
72
73
 
73
74
  let exitCode = 0;
@@ -83,7 +84,7 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
83
84
  // Handoff: when the listener fires, kill the child. Windows doesn't
84
85
  // honor SIGTERM/SIGKILL through Node's signal API; child.kill() maps
85
86
  // to TerminateProcess which is the closest equivalent.
86
- if (listener !== null) {
87
+ if (listener !== undefined) {
87
88
  void listener.triggered().then(() => {
88
89
  try {
89
90
  child.kill();
@@ -105,20 +106,20 @@ export async function runWithPTY(opts: RunOptions): Promise<RunResult> {
105
106
  });
106
107
  });
107
108
  } finally {
108
- if (cleanupCWD !== null) {
109
+ if (cleanupCWD !== undefined) {
109
110
  try {
110
111
  await cleanupCWD();
111
112
  } catch (err) {
112
- process.stderr.write(`fnclaude: ${(err as Error).message}\n`);
113
+ process.stderr.write(`fnclaude: ${errorMessage(err)}\n`);
113
114
  }
114
115
  }
115
116
  }
116
117
 
117
- let handoffArgv: string[] | null = null;
118
- if (listener !== null) {
119
- handoffArgv = listener.getHandoffArgv();
118
+ let handoffArgv: string[] | undefined;
119
+ if (listener !== undefined) {
120
+ handoffArgv = listener.getHandoffArgv() ?? undefined;
120
121
  await listener.close();
121
122
  }
122
123
 
123
- return { exitCode, tail: null, handoffArgv };
124
+ return { exitCode, tail: undefined, handoffArgv };
124
125
  }
package/src/pty.ts CHANGED
@@ -14,6 +14,7 @@ import { mkdir, rmdir, stat } from 'node:fs/promises';
14
14
  import type { Stats } from 'node:fs';
15
15
  import { dirname, isAbsolute, resolve as resolvePath } from 'node:path';
16
16
  import type { Config } from './config.js';
17
+ import { errorMessage } from './errors.js';
17
18
  import type { HandoffSpec } from './handoff.js';
18
19
  import { isFlag, isMagicWord, preserveArgs, splitLeadingMagic } from './args/preserve.js';
19
20
 
@@ -118,7 +119,7 @@ export interface CrossCwdMatch {
118
119
  }
119
120
 
120
121
  /**
121
- * Scan `tail` for the cross-cwd redirect message. Returns null when no
122
+ * Scan `tail` for the cross-cwd redirect message. Returns undefined when no
122
123
  * match is found OR when the captured `dest` fails safety validation.
123
124
  * When multiple matches appear (unlikely but defensive), the LAST match
124
125
  * wins.
@@ -132,7 +133,7 @@ export interface CrossCwdMatch {
132
133
  * it's an absolute path that survives canonicalisation unchanged and
133
134
  * contains no null bytes / `..` segments.
134
135
  */
135
- export function detectCrossCwd(tail: Buffer): CrossCwdMatch | null {
136
+ export function detectCrossCwd(tail: Buffer): CrossCwdMatch | undefined {
136
137
  // Decode as Latin-1 so every byte maps to a code unit; the regex matches
137
138
  // ASCII anchors so the multi-byte representation of any non-ASCII bytes
138
139
  // never participates in a match. This is the JS equivalent of Go's
@@ -142,13 +143,13 @@ export function detectCrossCwd(tail: Buffer): CrossCwdMatch | null {
142
143
  // module-level `lastIndex` to reset. The exported `crossCwdRe` stays
143
144
  // `g`-flagged (matchAll requires it) but is only ever consumed as an
144
145
  // anchor for tests / the source-of-truth comparison.
145
- let last: RegExpMatchArray | null = null;
146
+ let last: RegExpMatchArray | undefined;
146
147
  for (const m of s.matchAll(crossCwdRe)) {
147
148
  last = m;
148
149
  }
149
- if (last === null) return null;
150
+ if (last === undefined) return undefined;
150
151
  const dest = last[1]!;
151
- if (!isSafeDest(dest)) return null;
152
+ if (!isSafeDest(dest)) return undefined;
152
153
  return { dest, uuid: last[2]! };
153
154
  }
154
155
 
@@ -251,13 +252,13 @@ export interface EnsureCWDHandle {
251
252
  * a file, ensureCWD likewise rejects without touching the filesystem.
252
253
  */
253
254
  export async function ensureCWD(dir: string): Promise<EnsureCWDHandle> {
254
- let info: Stats | null = null;
255
+ let info: Stats | undefined;
255
256
  try {
256
257
  info = await stat(dir);
257
258
  } catch (err) {
258
259
  if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
259
260
  }
260
- if (info !== null) {
261
+ if (info !== undefined) {
261
262
  if (!info.isDirectory()) {
262
263
  throw new Error(`session cwd ${dir} exists but is not a directory`);
263
264
  }
@@ -276,13 +277,13 @@ export async function ensureCWD(dir: string): Promise<EnsureCWDHandle> {
276
277
  if (parent === p) {
277
278
  throw new Error(`session cwd ${dir} does not exist and has no existing ancestor`);
278
279
  }
279
- let parentInfo: Stats | null = null;
280
+ let parentInfo: Stats | undefined;
280
281
  try {
281
282
  parentInfo = await stat(parent);
282
283
  } catch (err) {
283
284
  if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
284
285
  }
285
- if (parentInfo !== null) {
286
+ if (parentInfo !== undefined) {
286
287
  if (!parentInfo.isDirectory()) {
287
288
  throw new Error(
288
289
  `session cwd ${dir} cannot be created: ancestor ${parent} is not a directory`,
@@ -308,7 +309,7 @@ export async function ensureCWD(dir: string): Promise<EnsureCWDHandle> {
308
309
  }
309
310
  }
310
311
  throw new Error(
311
- `session cwd ${dir} does not exist and could not be created: ${(err as Error).message}`,
312
+ `session cwd ${dir} does not exist and could not be created: ${errorMessage(err)}`,
312
313
  );
313
314
  }
314
315
  created.push(level);
@@ -325,7 +326,7 @@ export async function ensureCWD(dir: string): Promise<EnsureCWDHandle> {
325
326
  } catch (err) {
326
327
  const code = (err as NodeJS.ErrnoException).code;
327
328
  if (code === 'ENOENT') continue; // already gone — fine
328
- throw new Error(`could not clean up auto-created ${level}: ${(err as Error).message}`);
329
+ throw new Error(`could not clean up auto-created ${level}: ${errorMessage(err)}`);
329
330
  }
330
331
  }
331
332
  },
@@ -339,13 +340,13 @@ export async function ensureCWD(dir: string): Promise<EnsureCWDHandle> {
339
340
  * the moment the child exited; `handoffArgv` is populated only when the
340
341
  * socket listener fired `triggered()` and stashed a relaunch argv.
341
342
  *
342
- * On Windows the tail is null (no PTY, no ring buffer, cross-cwd-resume
343
- * is a no-op).
343
+ * On Windows the tail is undefined (no PTY, no ring buffer,
344
+ * cross-cwd-resume is a no-op).
344
345
  */
345
346
  export interface RunResult {
346
347
  exitCode: number;
347
- tail: Buffer | null;
348
- handoffArgv: string[] | null;
348
+ tail: Buffer | undefined;
349
+ handoffArgv: string[] | undefined;
349
350
  }
350
351
 
351
352
  export interface RunOptions {
@@ -357,8 +358,8 @@ export interface RunOptions {
357
358
  claudeArgv: string[];
358
359
  launchCWD: string;
359
360
  cfg: Config;
360
- /** Null disables handoff (no env injection, no listener). */
361
- handoff: HandoffSpec | null;
361
+ /** Undefined disables handoff (no env injection, no listener). */
362
+ handoff: HandoffSpec | undefined;
362
363
  }
363
364
 
364
365
  /**
package/src/repoRef.ts CHANGED
@@ -14,10 +14,10 @@
14
14
  // Inputs starting with `/` or `~/` are NOT repo refs (they're paths); the
15
15
  // caller short-circuits before this function.
16
16
  //
17
- // Returns null when the input is empty or otherwise unparseable. The Go
18
- // version returns (RepoRef, error); the TS port branches on null instead,
19
- // which matches the rest of the CLI's "no exceptions for user-input
20
- // validation" style.
17
+ // Returns undefined when the input is empty or otherwise unparseable. The
18
+ // Go version returns (RepoRef, error); the TS port branches on undefined
19
+ // instead, which matches the rest of the CLI's "no exceptions for
20
+ // user-input validation" style.
21
21
 
22
22
  export interface RepoRef {
23
23
  /**
@@ -56,8 +56,8 @@ const URL_RE =
56
56
  /^(?:(?:https?|ssh):\/\/(?:[^@/]+@)?)([^:/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/;
57
57
  const SCP_RE = /^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?\/?$/;
58
58
 
59
- export function parseRepoRef(input: string): RepoRef | null {
60
- if (input === '') return null;
59
+ export function parseRepoRef(input: string): RepoRef | undefined {
60
+ if (input === '') return undefined;
61
61
 
62
62
  // Split off workspace suffix first.
63
63
  let body = input;
@@ -66,10 +66,12 @@ export function parseRepoRef(input: string): RepoRef | null {
66
66
  if (plusIdx >= 0) {
67
67
  workspace = body.slice(plusIdx + 1);
68
68
  body = body.slice(0, plusIdx);
69
- if (workspace === '') return null; // trailing `+` with no workspace
69
+ if (workspace === '') return undefined; // trailing `+` with no workspace
70
70
  }
71
71
 
72
72
  // URL forms.
73
+ // RegExp.exec returns null for "no match" — third-party API shape, kept
74
+ // verbatim rather than coerced.
73
75
  const urlMatch = URL_RE.exec(body);
74
76
  if (urlMatch !== null) {
75
77
  return finalise({
@@ -98,21 +100,21 @@ export function parseRepoRef(input: string): RepoRef | null {
98
100
  if (slashIdx > 0 && slashIdx < rest.length - 1) {
99
101
  const owner = rest.slice(0, slashIdx);
100
102
  const name = rest.slice(slashIdx + 1);
101
- if (containsAny(owner, '/@:') || containsAny(name, '/@:')) return null;
103
+ if (containsAny(owner, '/@:') || containsAny(name, '/@:')) return undefined;
102
104
  return finalise({ host: 'github.com', owner, name, workspace, original: input });
103
105
  }
104
- return null;
106
+ return undefined;
105
107
  }
106
108
 
107
109
  // owner/name (single slash, no scheme).
108
110
  const slashIdx = body.indexOf('/');
109
111
  if (slashIdx > 0) {
110
112
  // Reject multiple slashes (ambiguous).
111
- if (body.indexOf('/', slashIdx + 1) >= 0) return null;
113
+ if (body.indexOf('/', slashIdx + 1) >= 0) return undefined;
112
114
  const owner = body.slice(0, slashIdx);
113
115
  const name = body.slice(slashIdx + 1);
114
- if (containsAny(owner, '@:') || containsAny(name, '@:')) return null;
115
- if (owner === '' || name === '') return null;
116
+ if (containsAny(owner, '@:') || containsAny(name, '@:')) return undefined;
117
+ if (owner === '' || name === '') return undefined;
116
118
  return finalise({ host: '', owner, name, workspace, original: input });
117
119
  }
118
120
 
@@ -121,14 +123,14 @@ export function parseRepoRef(input: string): RepoRef | null {
121
123
  if (atIdx > 0) {
122
124
  const name = body.slice(0, atIdx);
123
125
  const owner = body.slice(atIdx + 1);
124
- if (containsAny(owner, '@:/') || containsAny(name, '@:/')) return null;
125
- if (owner === '' || name === '') return null;
126
+ if (containsAny(owner, '@:/') || containsAny(name, '@:/')) return undefined;
127
+ if (owner === '' || name === '') return undefined;
126
128
  return finalise({ host: '', owner, name, workspace, original: input });
127
129
  }
128
130
 
129
131
  // Bare name. Defense-in-depth: reject anything that looks like a special
130
132
  // form we already had a chance to match.
131
- if (containsAny(body, '/@:')) return null;
133
+ if (containsAny(body, '/@:')) return undefined;
132
134
  return finalise({ host: '', owner: '', name: body, workspace, original: input });
133
135
  }
134
136