@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
|
@@ -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[] |
|
|
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
|
|
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[] |
|
|
224
|
-
return this.handoffArgv ===
|
|
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 ===
|
|
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 |
|
|
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
|
|
251
|
+
error: `malformed request: ${errorMessage(err)}`,
|
|
251
252
|
}),
|
|
252
253
|
);
|
|
253
254
|
sock.end();
|
|
254
255
|
return;
|
|
255
256
|
}
|
|
256
|
-
if (req ===
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
375
|
+
!req.permission_mode &&
|
|
376
376
|
!flagPresent(withOverrides, '--permission-mode') &&
|
|
377
|
-
|
|
377
|
+
req.session_id
|
|
378
378
|
) {
|
|
379
|
-
const live = readLivePermissionMode(this.launchCWD,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 |
|
|
161
|
-
if (!isTTY(process.stdin)) return
|
|
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
|
|
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 |
|
|
217
|
-
if (!isTTY(process.stdin)) return
|
|
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 |
|
|
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 !==
|
|
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:
|
|
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 !==
|
|
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 |
|
|
296
|
+
let listener: ListenerHandle | undefined;
|
|
296
297
|
try {
|
|
297
298
|
listener =
|
|
298
|
-
handoff !==
|
|
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
|
-
:
|
|
306
|
+
: undefined;
|
|
306
307
|
} catch (err) {
|
|
307
308
|
process.stderr.write(
|
|
308
|
-
`fnclaude: socket listener failed to start: ${(err
|
|
309
|
+
`fnclaude: socket listener failed to start: ${errorMessage(err)}\n`,
|
|
309
310
|
);
|
|
310
|
-
return { exitCode: 1, tail:
|
|
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
|
|
324
|
-
return { exitCode: 1, tail:
|
|
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
|
|
345
|
+
`fnclaude: failed to start claude with PTY: ${errorMessage(err)}\n`,
|
|
345
346
|
);
|
|
346
|
-
return { exitCode: 1, tail:
|
|
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 =
|
|
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 =
|
|
425
|
+
const handoffArgv =
|
|
426
|
+
listener !== undefined ? listener.inner.getHandoffArgv() : undefined;
|
|
424
427
|
|
|
425
428
|
return { exitCode, tail: ring.bytes(), handoffArgv };
|
|
426
429
|
}
|
package/src/pty/windows.ts
CHANGED
|
@@ -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:
|
|
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 !==
|
|
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 |
|
|
44
|
-
if (handoff !==
|
|
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
|
|
55
|
+
`fnclaude: socket listener failed to start: ${errorMessage(err)}\n`,
|
|
55
56
|
);
|
|
56
|
-
return { exitCode: 1, tail:
|
|
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>) |
|
|
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
|
|
69
|
-
if (listener !==
|
|
70
|
-
return { exitCode: 1, tail:
|
|
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 !==
|
|
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 !==
|
|
109
|
+
if (cleanupCWD !== undefined) {
|
|
109
110
|
try {
|
|
110
111
|
await cleanupCWD();
|
|
111
112
|
} catch (err) {
|
|
112
|
-
process.stderr.write(`fnclaude: ${(err
|
|
113
|
+
process.stderr.write(`fnclaude: ${errorMessage(err)}\n`);
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
let handoffArgv: string[] |
|
|
118
|
-
if (listener !==
|
|
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:
|
|
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
|
|
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 |
|
|
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 |
|
|
146
|
+
let last: RegExpMatchArray | undefined;
|
|
146
147
|
for (const m of s.matchAll(crossCwdRe)) {
|
|
147
148
|
last = m;
|
|
148
149
|
}
|
|
149
|
-
if (last ===
|
|
150
|
+
if (last === undefined) return undefined;
|
|
150
151
|
const dest = last[1]!;
|
|
151
|
-
if (!isSafeDest(dest)) return
|
|
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 |
|
|
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 !==
|
|
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 |
|
|
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 !==
|
|
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
|
|
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
|
|
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
|
|
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 |
|
|
348
|
-
handoffArgv: string[] |
|
|
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
|
-
/**
|
|
361
|
-
handoff: HandoffSpec |
|
|
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
|
|
18
|
-
// version returns (RepoRef, error); the TS port branches on
|
|
19
|
-
// which matches the rest of the CLI's "no exceptions for
|
|
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 |
|
|
60
|
-
if (input === '') return
|
|
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
|
|
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
|
|
103
|
+
if (containsAny(owner, '/@:') || containsAny(name, '/@:')) return undefined;
|
|
102
104
|
return finalise({ host: 'github.com', owner, name, workspace, original: input });
|
|
103
105
|
}
|
|
104
|
-
return
|
|
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
|
|
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
|
|
115
|
-
if (owner === '' || name === '') return
|
|
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
|
|
125
|
-
if (owner === '' || name === '') return
|
|
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
|
|
133
|
+
if (containsAny(body, '/@:')) return undefined;
|
|
132
134
|
return finalise({ host: '', owner: '', name: body, workspace, original: input });
|
|
133
135
|
}
|
|
134
136
|
|