@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Butler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fnclaude/cli",
3
- "version": "0.7.7",
3
+ "version": "1.0.0",
4
4
  "description": "fnclaude CLI implementation (TypeScript rewrite, in progress)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/config.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import { existsSync, readFileSync } from 'node:fs';
10
10
  import { homedir } from 'node:os';
11
11
  import { join } from 'node:path';
12
+ import { errorMessage } from './errors.js';
12
13
 
13
14
  /**
14
15
  * Resolve the user's home directory. Honors `$HOME` first (matches Go's
@@ -136,7 +137,7 @@ export function parseBoolEnv(v: string): boolean {
136
137
  */
137
138
  export interface NormalizeResult<T> {
138
139
  value: T;
139
- warning: string | null;
140
+ warning: string | undefined;
140
141
  }
141
142
 
142
143
  /**
@@ -146,8 +147,8 @@ export interface NormalizeResult<T> {
146
147
  * absent-value default path and produces no warning).
147
148
  */
148
149
  export function normalizeTmuxMode(v: string): NormalizeResult<TmuxMode> {
149
- if (v === 'never' || v === 'worktree') return { value: v, warning: null };
150
- if (v === '') return { value: 'never', warning: null };
150
+ if (v === 'never' || v === 'worktree') return { value: v, warning: undefined };
151
+ if (v === '') return { value: 'never', warning: undefined };
151
152
  return {
152
153
  value: 'never',
153
154
  warning: `fnclaude: auto.tmux=${JSON.stringify(v)} is not a valid mode (use "never" or "worktree"), falling back to "never"`,
@@ -160,12 +161,12 @@ export function normalizeTmuxMode(v: string): NormalizeResult<TmuxMode> {
160
161
  * string). Valid: "never", "ask", or a non-negative integer (as a string).
161
162
  */
162
163
  export function normalizeHandoffMode(v: string): NormalizeResult<HandoffMode> {
163
- if (v === 'never' || v === 'ask') return { value: v, warning: null };
164
- if (v === '') return { value: 'ask', warning: null };
164
+ if (v === 'never' || v === 'ask') return { value: v, warning: undefined };
165
+ if (v === '') return { value: 'ask', warning: undefined };
165
166
  // Non-negative integer (no decimal, no unit). The regex guarantees the
166
167
  // template-literal shape, which TS's type narrowing can't infer from a
167
168
  // .test() call alone — so assert it explicitly once.
168
- if (/^\d+$/.test(v)) return { value: v as `${number}`, warning: null };
169
+ if (/^\d+$/.test(v)) return { value: v as `${number}`, warning: undefined };
169
170
  return {
170
171
  value: 'ask',
171
172
  warning: `fnclaude: auto.handoff=${JSON.stringify(v)} is not a valid mode (use "never", "ask", or a non-negative integer), falling back to "ask"`,
@@ -174,12 +175,12 @@ export function normalizeHandoffMode(v: string): NormalizeResult<HandoffMode> {
174
175
 
175
176
  /**
176
177
  * parseDuration accepts a Go-style duration string (e.g., "3s", "150ms",
177
- * "1m30s") and returns the equivalent in milliseconds. Returns null on
178
- * parse failure. This is the same surface as Go's time.ParseDuration for
179
- * the config use-case (we don't need ns/us precision).
178
+ * "1m30s") and returns the equivalent in milliseconds. Returns undefined
179
+ * on parse failure. This is the same surface as Go's time.ParseDuration
180
+ * for the config use-case (we don't need ns/us precision).
180
181
  */
181
- export function parseDuration(s: string): number | null {
182
- if (!s) return null;
182
+ export function parseDuration(s: string): number | undefined {
183
+ if (!s) return undefined;
183
184
  // Whole number with unit suffix(es).
184
185
  // Units: ns, us, µs, ms, s, m, h. (We support all common units.)
185
186
  const unitToMs: Record<string, number> = {
@@ -195,17 +196,19 @@ export function parseDuration(s: string): number | null {
195
196
  let total = 0;
196
197
  let matched = 0;
197
198
  let consumed = 0;
199
+ // RegExp.exec returns null for "no match" — third-party API shape, kept
200
+ // as null rather than coerced.
198
201
  let m: RegExpExecArray | null;
199
202
  while ((m = re.exec(s)) !== null) {
200
- if (m.index !== consumed) return null; // gap between matches
203
+ if (m.index !== consumed) return undefined; // gap between matches
201
204
  const num = parseFloat(m[1] as string);
202
205
  const unit = m[2] as string;
203
- if (!Number.isFinite(num) || num < 0) return null;
206
+ if (!Number.isFinite(num) || num < 0) return undefined;
204
207
  total += num * (unitToMs[unit] as number);
205
208
  consumed = m.index + m[0].length;
206
209
  matched++;
207
210
  }
208
- if (matched === 0 || consumed !== s.length) return null;
211
+ if (matched === 0 || consumed !== s.length) return undefined;
209
212
  return total;
210
213
  }
211
214
 
@@ -262,25 +265,25 @@ export function loadConfig(): LoadConfigResult {
262
265
  set: (v: T) => void,
263
266
  ): void => {
264
267
  set(r.value);
265
- if (r.warning !== null) warnings.push(r.warning);
268
+ if (r.warning !== undefined) warnings.push(r.warning);
266
269
  };
267
270
 
268
271
  if (existsSync(path)) {
269
- let raw: RawConfig | null = null;
272
+ let raw: RawConfig | undefined;
270
273
  try {
271
274
  const body = readFileSync(path, 'utf8');
272
275
  raw = Bun.TOML.parse(body) as RawConfig;
273
276
  } catch (err) {
274
277
  warnings.push(
275
- `fnclaude: config file ${path} is malformed, using defaults: ${(err as Error).message}`,
278
+ `fnclaude: config file ${path} is malformed, using defaults: ${errorMessage(err)}`,
276
279
  );
277
- raw = null;
280
+ raw = undefined;
278
281
  }
279
282
  if (raw) {
280
283
  if (raw.name?.model) cfg.name.model = raw.name.model;
281
284
  if (raw.name?.timeout) {
282
285
  const d = parseDuration(raw.name.timeout);
283
- if (d !== null) {
286
+ if (d !== undefined) {
284
287
  cfg.name.timeout = d;
285
288
  } else {
286
289
  warnings.push(
@@ -318,7 +321,7 @@ export function loadConfig(): LoadConfigResult {
318
321
  if (e.FNCLAUDE_NAME_MODEL) cfg.name.model = e.FNCLAUDE_NAME_MODEL;
319
322
  if (e.FNCLAUDE_NAME_TIMEOUT) {
320
323
  const d = parseDuration(e.FNCLAUDE_NAME_TIMEOUT);
321
- if (d !== null) {
324
+ if (d !== undefined) {
322
325
  cfg.name.timeout = d;
323
326
  } else {
324
327
  warnings.push(
package/src/errors.ts ADDED
@@ -0,0 +1,13 @@
1
+ // Error-handling helpers shared across the CLI.
2
+ //
3
+ // `errorMessage` collapses the "throw value can be anything" branch into a
4
+ // single safe path: real Errors yield their `.message`, anything else gets
5
+ // stringified. Used at every catch-and-format site that previously did
6
+ // `(err as Error).message` — a cast that silently produces `undefined` (and
7
+ // then crashes the error-handling path itself) whenever the thrown value
8
+ // isn't actually an Error instance.
9
+
10
+ export function errorMessage(err: unknown): string {
11
+ if (err instanceof Error) return err.message;
12
+ return String(err);
13
+ }
package/src/main.ts CHANGED
@@ -50,6 +50,7 @@ import { seedNoop } from './noop.js';
50
50
  import { silentRelaunch, silentRelaunchHandoff } from './silentRelaunch.js';
51
51
  import { applyWorktreeIntercept, type GitRunner } from './worktree.js';
52
52
  import { flushWarnings } from './warnings.js';
53
+ import { errorMessage } from './errors.js';
53
54
 
54
55
  /**
55
56
  * `RunIO` — process-shaped seams. Streams, paths, the launch environment,
@@ -77,8 +78,8 @@ export interface RunIO {
77
78
  /** Shell cwd at startup. */
78
79
  cwd?: string;
79
80
 
80
- /** PATH lookup for the claude binary; returns null when not found. */
81
- lookupClaude?: (name: string) => string | null;
81
+ /** PATH lookup for the claude binary; returns undefined when not found. */
82
+ lookupClaude?: (name: string) => string | undefined;
82
83
  /** Override the run-with-pty step. */
83
84
  runWithPTY?: typeof runWithPTY;
84
85
  /** Override the silent-relaunch step (cross-cwd resume). */
@@ -144,18 +145,19 @@ export interface RunDeps {
144
145
  data?: RunConfig;
145
146
  }
146
147
 
147
- function lookupClaudeFromPath(name: string): string | null {
148
- // Bun's PATH lookup: Bun.which() returns null when not found.
149
- // Falls back to a synchronous probe via process.env.PATH if needed.
148
+ function lookupClaudeFromPath(name: string): string | undefined {
149
+ // Bun's PATH lookup: Bun.which() returns null when not found. Coerce to
150
+ // undefined to keep the absent-value sentinel consistent with the rest of
151
+ // the codebase.
150
152
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
151
153
  const bunWhich = (globalThis as any).Bun?.which;
152
154
  if (typeof bunWhich === 'function') {
153
- return bunWhich(name);
155
+ return bunWhich(name) ?? undefined;
154
156
  }
155
157
  // Fallback: walk PATH ourselves. Avoid `which` shell-out — synchronous and
156
158
  // brittle. Use spawnSync('which', [name]) only if Bun.which is unavailable
157
159
  // and we're not on Windows.
158
- return null;
160
+ return undefined;
159
161
  }
160
162
 
161
163
  /**
@@ -232,7 +234,7 @@ export async function run(deps: RunDeps = {}): Promise<number> {
232
234
  try {
233
235
  parsed = parseArgs(argv, home);
234
236
  } catch (err) {
235
- stderr.write(`${(err as Error).message}\n`);
237
+ stderr.write(`${errorMessage(err)}\n`);
236
238
  return 1;
237
239
  }
238
240
 
@@ -241,7 +243,7 @@ export async function run(deps: RunDeps = {}): Promise<number> {
241
243
  try {
242
244
  await seedNoopFn(parsed.cwd);
243
245
  } catch (err) {
244
- warnings.push(`fnclaude: noop seed failed: ${(err as Error).message}`);
246
+ warnings.push(`fnclaude: noop seed failed: ${errorMessage(err)}`);
245
247
  }
246
248
  }
247
249
 
@@ -309,14 +311,13 @@ export async function run(deps: RunDeps = {}): Promise<number> {
309
311
  hostAliases: aliases,
310
312
  }));
311
313
  } catch (err) {
312
- stderr.write(`${(err as Error).message}\n`);
314
+ stderr.write(`${errorMessage(err)}\n`);
313
315
  return 1;
314
316
  }
315
317
  // If the user's reference had a +workspace suffix AND they didn't
316
318
  // pass -w explicitly, propagate the workspace to the intercept
317
319
  // layer.
318
- const promoteWorkspace =
319
- result.workspace !== undefined && result.workspace !== '' && !parsed.worktreeSet;
320
+ const promoteWorkspace = !!result.workspace && !parsed.worktreeSet;
320
321
  resolved = withResolved(parsed, {
321
322
  cwd: result.path,
322
323
  ...(promoteWorkspace
@@ -351,9 +352,9 @@ export async function run(deps: RunDeps = {}): Promise<number> {
351
352
  if (shouldAutoName(named.passthrough)) {
352
353
  const prompt = extractPrompt(named.passthrough);
353
354
  const apiKey = process.env.ANTHROPIC_API_KEY ?? '';
354
- let llmFn: LlmClientFn | undefined;
355
- if (apiKey !== '') llmFn = defaultLlmClient(apiKey);
356
- else llmFn = claudeCliFn(cfg.name.model);
355
+ const llmFn: LlmClientFn = apiKey
356
+ ? defaultLlmClient(apiKey)
357
+ : claudeCliFn(cfg.name.model);
357
358
  const name = await generateNameFn(prompt, cfg.name, apiKey, llmFn);
358
359
  named = withPassthroughUpdate(named, {
359
360
  passthrough: ['--name', name, ...named.passthrough],
@@ -380,7 +381,7 @@ export async function run(deps: RunDeps = {}): Promise<number> {
380
381
  const claudeArgv = buildArgv(sanitized, shellCWD, cfg, prompts);
381
382
 
382
383
  // ── Verify claude is on PATH before starting the PTY. ────────────────
383
- if (lookupClaude('claude') === null) {
384
+ if (lookupClaude('claude') === undefined) {
384
385
  stderr.write(`fnclaude: claude not found in PATH\n`);
385
386
  return 1;
386
387
  }
@@ -400,7 +401,7 @@ export async function run(deps: RunDeps = {}): Promise<number> {
400
401
  });
401
402
 
402
403
  // ── Auto-handoff fires first. ────────────────────────────────────────
403
- if (handoffArgv !== null && handoffArgv.length > 0) {
404
+ if (handoffArgv !== undefined && handoffArgv.length > 0) {
404
405
  // Flush deferred warnings before relaunch since execve replaces the
405
406
  // process image (the deferred flush below would be skipped).
406
407
  flushOnce();
@@ -410,9 +411,9 @@ export async function run(deps: RunDeps = {}): Promise<number> {
410
411
  }
411
412
 
412
413
  // ── Cross-cwd redirect detection. ────────────────────────────────────
413
- if (tail !== null) {
414
+ if (tail !== undefined) {
414
415
  const hit = detectCrossCwd(tail);
415
- if (hit !== null) {
416
+ if (hit !== undefined) {
416
417
  flushOnce();
417
418
  relaunch(argv, hit.dest, hit.uuid);
418
419
  // Same fallthrough as above.
@@ -434,7 +435,7 @@ export async function main(): Promise<void> {
434
435
  try {
435
436
  code = await run();
436
437
  } catch (err) {
437
- process.stderr.write(`fnclaude: fatal: ${(err as Error).message}\n`);
438
+ process.stderr.write(`fnclaude: fatal: ${errorMessage(err)}\n`);
438
439
  code = 1;
439
440
  }
440
441
  process.exit(code);
package/src/mcp/client.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import { Buffer } from 'node:buffer';
13
13
  import { connect, type Socket } from 'node:net';
14
14
  import type { Readable, Writable } from 'node:stream';
15
+ import { errorMessage } from '../errors.js';
15
16
  import {
16
17
  encodeRequest,
17
18
  readResponse,
@@ -114,7 +115,7 @@ export const defaultDial: DialFn = async (socketPath, req) => {
114
115
 
115
116
  // Attach an error catcher up front so an early ECONNREFUSED doesn't
116
117
  // crash the process via 'error' before we await.
117
- let earlyErr: Error | null = null;
118
+ let earlyErr: Error | undefined;
118
119
  sock.on('error', (e) => {
119
120
  if (!earlyErr) earlyErr = e;
120
121
  });
@@ -146,7 +147,7 @@ export const defaultDial: DialFn = async (socketPath, req) => {
146
147
 
147
148
  sock.write(encodeRequest(req));
148
149
  const resp = await readResponse(sock);
149
- if (resp === null) {
150
+ if (resp === undefined) {
150
151
  throw new Error('read response: EOF before any line');
151
152
  }
152
153
  return resp;
@@ -189,7 +190,7 @@ const toolRestart: MCPTool = {
189
190
  const toolSwitchProject: MCPTool = {
190
191
  name: 'fnc_switch_project',
191
192
  description:
192
- 'Switch this fnclaude session to a different project, carrying a continuity summary. ONE-SHOT: call once and the session is killed and re-launched at the destination. Because the call ends this session, print a brief cancellation-window line to the user (e.g. "Transferring in 3 seconds. Ctrl-C to cancel.") and run a Bash sleep BEFORE calling this tool; if the sleep completes uninterrupted, call once. fnclaude preserves the user\'s startup flags (minus a denylist of destination-bound ones like --add-dir, --mcp-config, --from-pr, --name, etc.); the optional override args below replace individual flags. Args: destination (verbatim user reference: a short repo name like \'arch-setup\', a name@owner like \'arch-setup@fnrhombus\', an owner/name like \'fnrhombus/arch-setup\', a URL, or an absolute path; a +workspace suffix is supported for worktrees), name (a 3-6 word kebab-case session topic, e.g. \'fix-auth-bug\'), summary (a /compact-style continuity summary that lets the receiving session pick up where this one left off — what the user asked for, decisions made, files touched, work in flight, open questions, user-specific observations), session_id (the current session UUID, read from $CLAUDE_CODE_SESSION_ID; used by fnclaude to auto-capture the live permission-mode from this session\'s JSONL log). Optional overrides: model, effort, permission_mode, allowed_tools, agent, brief, chrome, ide, verbose. Response.action will be done (transfer in flight), paste_flow (auto-handoff disabled — copy/paste the rendered command), or error.',
193
+ 'Switch this fnclaude session to a different project, carrying a continuity summary. Call as early in the turn as you recognize the user\'s intent belongs in another project — don\'t read/grep/test in this session as a pre-step, the destination session will do that with fresh context. Pre-investigating here wastes parent tokens and degrades the destination\'s research independence. ONE-SHOT: call once and the session is killed and re-launched at the destination. Because the call ends this session, print a brief cancellation-window line to the user (e.g. "Transferring in 3 seconds. Ctrl-C to cancel.") and run a Bash sleep BEFORE calling this tool; if the sleep completes uninterrupted, call once. fnclaude preserves the user\'s startup flags (minus a denylist of destination-bound ones like --add-dir, --mcp-config, --from-pr, --name, etc.); the optional override args below replace individual flags. Args: destination (verbatim user reference: a short repo name like \'arch-setup\', a name@owner like \'arch-setup@fnrhombus\', an owner/name like \'fnrhombus/arch-setup\', a URL, or an absolute path; a +workspace suffix is supported for worktrees), name (a 3-6 word kebab-case session topic, e.g. \'fix-auth-bug\'), summary (a /compact-style continuity summary that lets the receiving session pick up where this one left off — what the user asked for, decisions made, files touched, work in flight, open questions, user-specific observations), session_id (the current session UUID, read from $CLAUDE_CODE_SESSION_ID; used by fnclaude to auto-capture the live permission-mode from this session\'s JSONL log). Optional overrides: model, effort, permission_mode, allowed_tools, agent, brief, chrome, ide, verbose. Response.action will be done (transfer in flight), paste_flow (auto-handoff disabled — copy/paste the rendered command), or error.',
193
194
  inputSchema: {
194
195
  type: 'object',
195
196
  properties: {
@@ -214,7 +215,7 @@ const toolSwitchProject: MCPTool = {
214
215
  const toolSpawnSession: MCPTool = {
215
216
  name: 'fnc_spawn_session',
216
217
  description:
217
- "Spawn a sibling fnclaude session for a different project in a new terminal window, while leaving the CURRENT session running. Use when, in the middle of a task here, the user discovers an unrelated task in another project but doesn't want to abandon what's happening in this session. (Use fnc_switch_project instead when the current session should be replaced.) ONE-SHOT: call once; no countdown or cancellation window is needed — the current session keeps running regardless. Spawn is a fresh start — it does NOT preserve this session's startup flags; pass the optional override args when the user wants the sibling to start with explicit tooling choices. Args: destination (verbatim user reference: short repo name, name@owner, owner/name, URL, or absolute path; +workspace suffix supported), name (3-6 word kebab-case session topic for the new session, e.g. 'fix-css-bug'), summary (a /compact-style continuity summary for the new session — what the user wants done in that other project, with enough context to start cold). Optional overrides (applied to the sibling, not this session): model, effort, permission_mode, allowed_tools, agent, brief, chrome, ide, verbose. Response.action will be done (sibling launched), paste_flow (no launcher available — copy/paste the rendered command into a new terminal), or error.",
218
+ "Spawn a sibling fnclaude session for a different project in a new terminal window, while leaving the CURRENT session running. Use when, in the middle of a task here, the user discovers an unrelated task in another project but doesn't want to abandon what's happening in this session. (Use fnc_switch_project instead when the current session should be replaced.) Call as early in the turn as you recognize the work belongs in another project — don't read/grep/test in this session as a pre-step, the sibling will do that with fresh context. Pre-investigating here wastes parent tokens and degrades the sibling's research independence. ONE-SHOT: call once; no countdown or cancellation window is needed — the current session keeps running regardless. Spawn is a fresh start — it does NOT preserve this session's startup flags; pass the optional override args when the user wants the sibling to start with explicit tooling choices. Args: destination (verbatim user reference: short repo name, name@owner, owner/name, URL, or absolute path; +workspace suffix supported), name (3-6 word kebab-case session topic for the new session, e.g. 'fix-css-bug'), summary (a /compact-style continuity summary for the new session — what the user wants done in that other project, with enough context to start cold). Optional overrides (applied to the sibling, not this session): model, effort, permission_mode, allowed_tools, agent, brief, chrome, ide, verbose. Response.action will be done (sibling launched), paste_flow (no launcher available — copy/paste the rendered command into a new terminal), or error.",
218
219
  inputSchema: {
219
220
  type: 'object',
220
221
  properties: {
@@ -274,15 +275,14 @@ export async function runMCPServer(opts: MCPServerOptions): Promise<number> {
274
275
  // eslint-disable-next-line no-constant-condition
275
276
  while (true) {
276
277
  const line = await reader.readLine();
277
- if (line === null) return 0; // clean EOF
278
+ if (line === undefined) return 0; // clean EOF
278
279
  try {
279
280
  await handleLine(opts, dial, line);
280
281
  } catch (err) {
281
282
  // Sending an error response is the right behavior; abort the loop
282
283
  // only if the write itself fails.
283
284
  try {
284
- const msg = (err as Error).message;
285
- sendError(opts.stdout, null, CODE_PARSE_ERROR, `parse error: ${msg}`);
285
+ sendError(opts.stdout, null, CODE_PARSE_ERROR, `parse error: ${errorMessage(err)}`);
286
286
  } catch {
287
287
  return 1;
288
288
  }
@@ -299,7 +299,7 @@ async function handleLine(
299
299
  try {
300
300
  req = JSON.parse(line) as JSONRPCRequest;
301
301
  } catch (err) {
302
- sendError(opts.stdout, null, CODE_PARSE_ERROR, `parse error: ${(err as Error).message}`);
302
+ sendError(opts.stdout, null, CODE_PARSE_ERROR, `parse error: ${errorMessage(err)}`);
303
303
  return;
304
304
  }
305
305
 
@@ -348,7 +348,7 @@ async function handleToolsCall(
348
348
  try {
349
349
  params = (req.params ?? {}) as MCPCallToolParams;
350
350
  } catch (err) {
351
- sendError(opts.stdout, req.id, CODE_INVALID_PARAMS, `invalid params: ${(err as Error).message}`);
351
+ sendError(opts.stdout, req.id, CODE_INVALID_PARAMS, `invalid params: ${errorMessage(err)}`);
352
352
  return;
353
353
  }
354
354
 
@@ -402,12 +402,12 @@ async function callRestart(
402
402
  id: unknown,
403
403
  args: Record<string, unknown>,
404
404
  ): Promise<void> {
405
- if (opts.socketPath === '') {
405
+ if (!opts.socketPath) {
406
406
  sendToolError(opts.stdout, id, SOCKET_UNAVAILABLE_MSG);
407
407
  return;
408
408
  }
409
409
  const sid = readStringArg(args, 'session_id');
410
- if (sid === '') {
410
+ if (!sid) {
411
411
  sendToolError(
412
412
  opts.stdout,
413
413
  id,
@@ -445,7 +445,7 @@ async function callSwitch(
445
445
  id: unknown,
446
446
  args: Record<string, unknown>,
447
447
  ): Promise<void> {
448
- if (opts.socketPath === '') {
448
+ if (!opts.socketPath) {
449
449
  sendToolError(opts.stdout, id, SOCKET_UNAVAILABLE_MSG);
450
450
  return;
451
451
  }
@@ -475,7 +475,7 @@ async function callSpawn(
475
475
  id: unknown,
476
476
  args: Record<string, unknown>,
477
477
  ): Promise<void> {
478
- if (opts.socketPath === '') {
478
+ if (!opts.socketPath) {
479
479
  sendToolError(opts.stdout, id, SOCKET_UNAVAILABLE_MSG);
480
480
  return;
481
481
  }
@@ -504,7 +504,7 @@ async function callCopy(
504
504
  id: unknown,
505
505
  args: Record<string, unknown>,
506
506
  ): Promise<void> {
507
- if (opts.socketPath === '') {
507
+ if (!opts.socketPath) {
508
508
  sendToolError(opts.stdout, id, SOCKET_UNAVAILABLE_MSG);
509
509
  return;
510
510
  }
@@ -525,7 +525,7 @@ async function dialAndRelay(
525
525
  try {
526
526
  resp = await dial(opts.socketPath, req);
527
527
  } catch (err) {
528
- sendToolError(opts.stdout, id, (err as Error).message);
528
+ sendToolError(opts.stdout, id, errorMessage(err));
529
529
  return;
530
530
  }
531
531
  sendToolResult(opts.stdout, id, resp);
@@ -573,7 +573,7 @@ function sendToolResult(stdout: Writable, id: unknown, resp: Response): void {
573
573
  try {
574
574
  text = JSON.stringify(resp);
575
575
  } catch (err) {
576
- sendToolError(stdout, id, `internal marshal error: ${(err as Error).message}`);
576
+ sendToolError(stdout, id, `internal marshal error: ${errorMessage(err)}`);
577
577
  return;
578
578
  }
579
579
  sendResult(stdout, id, {
@@ -595,7 +595,7 @@ function writeResponse(stdout: Writable, resp: JSONRPCResponse): void {
595
595
  class LineReader {
596
596
  private buf: Buffer = Buffer.alloc(0);
597
597
  private ended = false;
598
- private pending: ((line: string | null) => void) | null = null;
598
+ private pending: ((line: string | undefined) => void) | undefined;
599
599
 
600
600
  constructor(private readonly stream: Readable) {
601
601
  stream.on('data', (chunk: Buffer) => {
@@ -612,8 +612,8 @@ class LineReader {
612
612
  });
613
613
  }
614
614
 
615
- readLine(): Promise<string | null> {
616
- return new Promise<string | null>((resolve) => {
615
+ readLine(): Promise<string | undefined> {
616
+ return new Promise<string | undefined>((resolve) => {
617
617
  this.pending = resolve;
618
618
  this.tryDeliver();
619
619
  });
@@ -626,15 +626,15 @@ class LineReader {
626
626
  const line = this.buf.subarray(0, nl + 1).toString('utf8');
627
627
  this.buf = this.buf.subarray(nl + 1);
628
628
  const cb = this.pending;
629
- this.pending = null;
629
+ this.pending = undefined;
630
630
  cb(line);
631
631
  return;
632
632
  }
633
633
  if (this.ended) {
634
634
  const cb = this.pending;
635
- this.pending = null;
635
+ this.pending = undefined;
636
636
  if (this.buf.length === 0) {
637
- cb(null);
637
+ cb(undefined);
638
638
  } else {
639
639
  const tail = this.buf.toString('utf8');
640
640
  this.buf = Buffer.alloc(0);
@@ -370,9 +370,9 @@ export interface DataStream {
370
370
 
371
371
  /**
372
372
  * Read one newline-terminated JSON line from a data-emitting stream (a
373
- * `net.Socket` is the common case). Returns the decoded Request or null
374
- * if the stream ended cleanly before any line was seen (analogous to
375
- * Go's `io.EOF` return).
373
+ * `net.Socket` is the common case). Returns the decoded Request or
374
+ * undefined if the stream ended cleanly before any line was seen
375
+ * (analogous to Go's `io.EOF` return).
376
376
  *
377
377
  * Buffers across chunk boundaries. Stops at the first '\n'; bytes past
378
378
  * it are silently dropped (the wire protocol is one-line-per-connection).
@@ -382,22 +382,22 @@ export interface DataStream {
382
382
  * which would prevent the caller from writing the response back. The
383
383
  * event-listener form leaves the socket fully writable.
384
384
  */
385
- export async function readRequest(stream: DataStream): Promise<Request | null> {
385
+ export async function readRequest(stream: DataStream): Promise<Request | undefined> {
386
386
  const line = await readLine(stream);
387
- if (line === null) return null;
387
+ if (line === undefined) return undefined;
388
388
  return decodeRequest(line);
389
389
  }
390
390
 
391
391
  /** Read one newline-terminated JSON line and decode it as a Response. */
392
- export async function readResponse(stream: DataStream): Promise<Response | null> {
392
+ export async function readResponse(stream: DataStream): Promise<Response | undefined> {
393
393
  const line = await readLine(stream);
394
- if (line === null) return null;
394
+ if (line === undefined) return undefined;
395
395
  return decodeResponse(line);
396
396
  }
397
397
 
398
398
  /** Internal — read up to and including the first '\n' via stream events. */
399
- async function readLine(stream: DataStream): Promise<string | null> {
400
- return new Promise<string | null>((resolve, reject) => {
399
+ async function readLine(stream: DataStream): Promise<string | undefined> {
400
+ return new Promise<string | undefined>((resolve, reject) => {
401
401
  const chunks: Buffer[] = [];
402
402
  let total = 0;
403
403
  let settled = false;
@@ -408,7 +408,7 @@ async function readLine(stream: DataStream): Promise<string | null> {
408
408
  stream.off('close', onEnd);
409
409
  stream.off('error', onError);
410
410
  };
411
- const settle = (value: string | null, err?: Error): void => {
411
+ const settle = (value: string | undefined, err?: Error): void => {
412
412
  if (settled) return;
413
413
  settled = true;
414
414
  cleanup();
@@ -430,12 +430,12 @@ async function readLine(stream: DataStream): Promise<string | null> {
430
430
  };
431
431
  const onEnd = (): void => {
432
432
  if (total === 0) {
433
- settle(null);
433
+ settle(undefined);
434
434
  return;
435
435
  }
436
436
  settle(Buffer.concat(chunks).toString('utf8'));
437
437
  };
438
- const onError = (err: Error): void => settle(null, err);
438
+ const onError = (err: Error): void => settle(undefined, err);
439
439
 
440
440
  stream.on('data', onData);
441
441
  stream.on('end', onEnd);