@fnclaude/cli 0.7.8 → 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/package.json +1 -1
- package/src/config.ts +23 -20
- package/src/errors.ts +13 -0
- package/src/main.ts +21 -20
- package/src/mcp/client.ts +22 -22
- package/src/mcp/protocol.ts +12 -12
- package/src/mcp/socketListener.ts +20 -20
- package/src/pty/unix.ts +25 -22
- package/src/pty/windows.ts +18 -17
- package/src/pty.ts +18 -17
- package/src/repoRef.ts +17 -15
- package/src/repoSettings.ts +12 -11
- package/src/sanitize.ts +12 -12
- package/src/sessionState.ts +12 -10
- package/src/worktree.ts +6 -6
package/package.json
CHANGED
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 |
|
|
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:
|
|
150
|
-
if (v === '') return { value: 'never', warning:
|
|
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:
|
|
164
|
-
if (v === '') return { value: 'ask', warning:
|
|
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:
|
|
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
|
|
178
|
-
* parse failure. This is the same surface as Go's time.ParseDuration
|
|
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 |
|
|
182
|
-
if (!s) return
|
|
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
|
|
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
|
|
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
|
|
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 !==
|
|
268
|
+
if (r.warning !== undefined) warnings.push(r.warning);
|
|
266
269
|
};
|
|
267
270
|
|
|
268
271
|
if (existsSync(path)) {
|
|
269
|
-
let raw: RawConfig |
|
|
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
|
|
278
|
+
`fnclaude: config file ${path} is malformed, using defaults: ${errorMessage(err)}`,
|
|
276
279
|
);
|
|
277
|
-
raw =
|
|
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 !==
|
|
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 !==
|
|
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
|
|
81
|
-
lookupClaude?: (name: string) => string |
|
|
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 |
|
|
148
|
-
// Bun's PATH lookup: Bun.which() returns null when not found.
|
|
149
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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') ===
|
|
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 !==
|
|
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 !==
|
|
414
|
+
if (tail !== undefined) {
|
|
414
415
|
const hit = detectCrossCwd(tail);
|
|
415
|
-
if (hit !==
|
|
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
|
|
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 |
|
|
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 ===
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 |
|
|
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 |
|
|
616
|
-
return new Promise<string |
|
|
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 =
|
|
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 =
|
|
635
|
+
this.pending = undefined;
|
|
636
636
|
if (this.buf.length === 0) {
|
|
637
|
-
cb(
|
|
637
|
+
cb(undefined);
|
|
638
638
|
} else {
|
|
639
639
|
const tail = this.buf.toString('utf8');
|
|
640
640
|
this.buf = Buffer.alloc(0);
|
package/src/mcp/protocol.ts
CHANGED
|
@@ -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
|
|
374
|
-
* if the stream ended cleanly before any line was seen
|
|
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 |
|
|
385
|
+
export async function readRequest(stream: DataStream): Promise<Request | undefined> {
|
|
386
386
|
const line = await readLine(stream);
|
|
387
|
-
if (line ===
|
|
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 |
|
|
392
|
+
export async function readResponse(stream: DataStream): Promise<Response | undefined> {
|
|
393
393
|
const line = await readLine(stream);
|
|
394
|
-
if (line ===
|
|
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 |
|
|
400
|
-
return new Promise<string |
|
|
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 |
|
|
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(
|
|
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(
|
|
438
|
+
const onError = (err: Error): void => settle(undefined, err);
|
|
439
439
|
|
|
440
440
|
stream.on('data', onData);
|
|
441
441
|
stream.on('end', onEnd);
|