@fnclaude/cli 1.1.0 → 2.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/bin/fnc.js +34 -79
- package/package.json +6 -9
- package/share/fnclaude/templates/handoff.template.md +11 -0
- package/src/argv/classify.ts +48 -0
- package/src/argv/expand.ts +51 -0
- package/src/argv/intake.ts +52 -0
- package/src/argv/magic.ts +103 -0
- package/src/argv/parse.ts +213 -0
- package/src/argv/preserve-args.ts +333 -0
- package/src/argv/sentinel.ts +41 -0
- package/src/argv/short-flags.ts +152 -0
- package/src/config/load.ts +116 -0
- package/src/handoff/awaiter.ts +140 -0
- package/src/handoff/clean-env.ts +45 -0
- package/src/handoff/kill-and-exec.ts +110 -0
- package/src/handoff/spawn-launcher.ts +185 -0
- package/src/handoff/summary-file.ts +86 -0
- package/src/handoff/trigger.ts +90 -0
- package/src/help-version.ts +151 -0
- package/src/launch/compose-env.ts +34 -0
- package/src/launch/cross-cwd-parse.ts +69 -0
- package/src/launch/cross-cwd-relaunch.ts +95 -0
- package/src/launch/find-claude.ts +52 -0
- package/src/launch/live-permission-reader.ts +133 -0
- package/src/launch/ring-buffer.ts +92 -0
- package/src/main.ts +580 -437
- package/src/mcp/dispatch.ts +240 -0
- package/src/mcp/handlers/clipboard-backends.ts +176 -0
- package/src/mcp/handlers/clipboard.ts +62 -0
- package/src/mcp/handlers/restart.ts +156 -0
- package/src/mcp/handlers/spawn.ts +219 -0
- package/src/mcp/handlers/switch.ts +272 -0
- package/src/mcp/inject-config.ts +59 -0
- package/src/mcp/jsonrpc-server.ts +154 -0
- package/src/mcp/listener.ts +141 -0
- package/src/mcp/parent-dispatch.ts +154 -0
- package/src/mcp/socket-path.ts +48 -0
- package/src/mcp/wire.ts +181 -0
- package/src/name/auto-name.ts +162 -0
- package/src/name/llm-prompt.ts +14 -0
- package/src/name/sanitize.ts +57 -0
- package/src/name/sdk-llm.ts +42 -0
- package/src/noop/seed.ts +63 -0
- package/src/noop/template-source.ts +62 -0
- package/src/path/ensure-cwd.ts +95 -0
- package/src/path/resolve.ts +58 -0
- package/src/prompts/dir.ts +61 -0
- package/src/prompts/load.ts +100 -0
- package/src/prompts/select.ts +43 -0
- package/src/repo/clone-exec.ts +37 -0
- package/src/repo/clone.ts +45 -0
- package/src/repo/gh-runner.ts +68 -0
- package/src/repo/host-aliases.ts +58 -0
- package/src/repo/owner-lookup.ts +71 -0
- package/src/repo/ref.ts +146 -0
- package/src/repo/repo-settings.ts +99 -0
- package/src/repo/resolve-input.ts +179 -0
- package/src/repo/template.ts +92 -0
- package/src/warnings/buffer.ts +39 -0
- package/src/worktree/auto-tmux.ts +45 -0
- package/src/worktree/git-list.ts +73 -0
- package/src/worktree/intercept.ts +150 -0
- package/bin/preflight.js +0 -66
- package/prompts/agent-pitfall.md +0 -1
- package/prompts/noop-router.md +0 -186
- package/prompts/project-switch.md +0 -64
- package/prompts/restart.md +0 -50
- package/prompts/spawn.md +0 -62
- package/src/argParser.ts +0 -367
- package/src/args/preserve.ts +0 -338
- package/src/args.ts +0 -239
- package/src/argv.ts +0 -203
- package/src/autoname.ts +0 -273
- package/src/clipboard.ts +0 -149
- package/src/config.ts +0 -369
- package/src/errors.ts +0 -13
- package/src/handoff.ts +0 -108
- package/src/help.ts +0 -139
- package/src/hostAliases.ts +0 -139
- package/src/index.ts +0 -120
- package/src/mcp/client.ts +0 -645
- package/src/mcp/protocol.ts +0 -445
- package/src/mcp/socketListener.ts +0 -540
- package/src/noop.ts +0 -106
- package/src/passthrough.ts +0 -36
- package/src/paths.ts +0 -55
- package/src/prompts.ts +0 -279
- package/src/pty/unix.ts +0 -429
- package/src/pty/windows.ts +0 -125
- package/src/pty.ts +0 -380
- package/src/repoRef.ts +0 -158
- package/src/repoSettings.ts +0 -144
- package/src/resolver.ts +0 -519
- package/src/sanitize.ts +0 -120
- package/src/sessionState.ts +0 -220
- package/src/silentRelaunch.ts +0 -178
- package/src/spawn.ts +0 -163
- package/src/template.ts +0 -44
- package/src/warnings.ts +0 -34
- package/src/worktree.ts +0 -201
|
@@ -1,540 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Parent-side AF_UNIX socket listener — accepts one MCP Request per
|
|
3
|
-
* connection, dispatches by Op, returns one Response, then closes. Ported
|
|
4
|
-
* from src/socket_listener.go in the Go reference (fnclaude@fnrhombus).
|
|
5
|
-
*
|
|
6
|
-
* The triggered/handoff-argv channel pattern is preserved: when a handoff
|
|
7
|
-
* action fires (OpRestart, OpSwitch), the listener stashes the new argv
|
|
8
|
-
* and resolves a `triggered` promise so the PTY-runner can unblock and
|
|
9
|
-
* kill claude. First-wins on the argv; subsequent calls don't replace it.
|
|
10
|
-
*
|
|
11
|
-
* Cross-cutting helpers (clipboard write, sibling spawn) are dependency-
|
|
12
|
-
* injected so tests can stub them without exec'ing real binaries. This
|
|
13
|
-
* mirrors Go's `clipboardExec` / `spawnSibling` indirection vars.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { createServer, type Server, type Socket } from 'node:net';
|
|
17
|
-
import { writeFile, unlink, chmod } from 'node:fs/promises';
|
|
18
|
-
import type { Config } from '../config.js';
|
|
19
|
-
import { errorMessage } from '../errors.js';
|
|
20
|
-
import { handoffContentPath, type HandoffSpec } from '../handoff.js';
|
|
21
|
-
import {
|
|
22
|
-
appendRestartReminder,
|
|
23
|
-
readLivePermissionMode,
|
|
24
|
-
} from '../sessionState.js';
|
|
25
|
-
import {
|
|
26
|
-
encodeResponse,
|
|
27
|
-
type CopyRequest,
|
|
28
|
-
type Request,
|
|
29
|
-
type Response,
|
|
30
|
-
type RestartRequest,
|
|
31
|
-
type SpawnRequest,
|
|
32
|
-
type SwitchRequest,
|
|
33
|
-
readRequest,
|
|
34
|
-
} from './protocol.js';
|
|
35
|
-
import {
|
|
36
|
-
applyOverrides,
|
|
37
|
-
flagPresent,
|
|
38
|
-
preserveArgs,
|
|
39
|
-
splitLeadingMagic,
|
|
40
|
-
transferDenyBareOK,
|
|
41
|
-
transferDenyFlags,
|
|
42
|
-
} from '../args/preserve.js';
|
|
43
|
-
|
|
44
|
-
// ── Session ID validation (matches Go sessionIDPattern) ───────────────────
|
|
45
|
-
|
|
46
|
-
const SESSION_ID_PATTERN =
|
|
47
|
-
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
48
|
-
|
|
49
|
-
// ── Injected dependencies ─────────────────────────────────────────────────
|
|
50
|
-
|
|
51
|
-
/** Result of a clipboard write — mirrors Go's (bool, error). */
|
|
52
|
-
export interface ClipboardResult {
|
|
53
|
-
ok: boolean;
|
|
54
|
-
error?: Error;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Result of a sibling spawn — mirrors Go's (bool, error). */
|
|
58
|
-
export interface SpawnResult {
|
|
59
|
-
spawned: boolean;
|
|
60
|
-
error?: Error;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export interface SocketListenerDeps {
|
|
64
|
-
/**
|
|
65
|
-
* Write text to the user's clipboard. Production default is a stub that
|
|
66
|
-
* always reports failure — wire the real implementation in via the
|
|
67
|
-
* constructor (or accept the no-op behavior in tests that don't care).
|
|
68
|
-
*/
|
|
69
|
-
copyToClipboard: (text: string) => Promise<ClipboardResult>;
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Spawn a sibling fnclaude session at `dest`, passing `summaryPath` as
|
|
73
|
-
* the @-arg and `extraArgs` as the tail flag list. Return `spawned:true`
|
|
74
|
-
* when a launcher was found and started; `spawned:false` (with no
|
|
75
|
-
* error) when no launcher resolved — caller falls back to paste-flow.
|
|
76
|
-
*/
|
|
77
|
-
spawnSibling: (
|
|
78
|
-
cfg: Config,
|
|
79
|
-
dest: string,
|
|
80
|
-
name: string,
|
|
81
|
-
summaryPath: string,
|
|
82
|
-
extraArgs: readonly string[],
|
|
83
|
-
) => Promise<SpawnResult>;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Default deps used when the caller doesn't supply their own. Both are
|
|
88
|
-
* intentionally no-op fallbacks: real implementations live in higher
|
|
89
|
-
* layers (clipboard.ts / spawn.ts in future phases). The listener
|
|
90
|
-
* remains usable end-to-end with the defaults — clipboard writes report
|
|
91
|
-
* failure, spawn always reports "no launcher".
|
|
92
|
-
*/
|
|
93
|
-
export function defaultDeps(): SocketListenerDeps {
|
|
94
|
-
return {
|
|
95
|
-
copyToClipboard: async () => ({
|
|
96
|
-
ok: false,
|
|
97
|
-
error: new Error('no clipboard backend wired'),
|
|
98
|
-
}),
|
|
99
|
-
spawnSibling: async () => ({ spawned: false }),
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// ── SocketListener ────────────────────────────────────────────────────────
|
|
104
|
-
|
|
105
|
-
export interface StartOptions {
|
|
106
|
-
spec: HandoffSpec;
|
|
107
|
-
cfg: Config;
|
|
108
|
-
launchCWD: string;
|
|
109
|
-
/** os.Args[1:] equivalent captured at startup; used to preserve flags. */
|
|
110
|
-
origArgs?: readonly string[];
|
|
111
|
-
/** Override for clipboard + spawn — tests pass stubs here. */
|
|
112
|
-
deps?: Partial<SocketListenerDeps>;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export class SocketListener {
|
|
116
|
-
private readonly socketPath: string;
|
|
117
|
-
private readonly server: Server;
|
|
118
|
-
private readonly cfg: Config;
|
|
119
|
-
private readonly launchCWD: string;
|
|
120
|
-
private readonly origArgs: readonly string[];
|
|
121
|
-
private readonly deps: SocketListenerDeps;
|
|
122
|
-
|
|
123
|
-
private handoffArgv: string[] | undefined;
|
|
124
|
-
private triggeredResolve!: () => void;
|
|
125
|
-
private readonly triggeredPromise: Promise<void>;
|
|
126
|
-
private triggeredFired = false;
|
|
127
|
-
|
|
128
|
-
private constructor(opts: StartOptions, server: Server) {
|
|
129
|
-
this.socketPath = opts.spec.socketPath;
|
|
130
|
-
this.server = server;
|
|
131
|
-
this.cfg = opts.cfg;
|
|
132
|
-
this.launchCWD = opts.launchCWD;
|
|
133
|
-
this.origArgs = (opts.origArgs ?? []).slice();
|
|
134
|
-
const fallback = defaultDeps();
|
|
135
|
-
this.deps = {
|
|
136
|
-
copyToClipboard: opts.deps?.copyToClipboard ?? fallback.copyToClipboard,
|
|
137
|
-
spawnSibling: opts.deps?.spawnSibling ?? fallback.spawnSibling,
|
|
138
|
-
};
|
|
139
|
-
this.triggeredPromise = new Promise<void>((resolve) => {
|
|
140
|
-
this.triggeredResolve = resolve;
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Open the AF_UNIX listener at spec.socketPath and start the accept
|
|
146
|
-
* loop. Best-effort removes any stale socket file from a prior crashed
|
|
147
|
-
* invocation at this path (net listen errors with EADDRINUSE otherwise).
|
|
148
|
-
*
|
|
149
|
-
* The socket file is chmod'd to 0600 immediately after bind so other
|
|
150
|
-
* UIDs on the host cannot dial it. Node's createServer() does NOT honor
|
|
151
|
-
* a mode option for AF_UNIX paths — it inherits the process umask, which
|
|
152
|
-
* defaults to 022 (world-readable) or worse depending on caller. We
|
|
153
|
-
* tighten unconditionally rather than rely on umask discipline at every
|
|
154
|
-
* launch site. The race window between bind and chmod is small (single
|
|
155
|
-
* tick) but real; we accept it as the trade vs. a per-process umask
|
|
156
|
-
* dance that would still leak any *other* file created in the same tick.
|
|
157
|
-
*/
|
|
158
|
-
static async start(opts: StartOptions): Promise<SocketListener> {
|
|
159
|
-
try {
|
|
160
|
-
await unlink(opts.spec.socketPath);
|
|
161
|
-
} catch {
|
|
162
|
-
// best-effort — fine if it didn't exist
|
|
163
|
-
}
|
|
164
|
-
const server = createServer();
|
|
165
|
-
const listener = new SocketListener(opts, server);
|
|
166
|
-
server.on('connection', (sock) => {
|
|
167
|
-
void listener.handleConn(sock);
|
|
168
|
-
});
|
|
169
|
-
await new Promise<void>((resolve, reject) => {
|
|
170
|
-
const onErr = (e: Error) => {
|
|
171
|
-
server.off('listening', onOk);
|
|
172
|
-
reject(e);
|
|
173
|
-
};
|
|
174
|
-
const onOk = () => {
|
|
175
|
-
server.off('error', onErr);
|
|
176
|
-
resolve();
|
|
177
|
-
};
|
|
178
|
-
server.once('error', onErr);
|
|
179
|
-
server.once('listening', onOk);
|
|
180
|
-
server.listen(opts.spec.socketPath);
|
|
181
|
-
});
|
|
182
|
-
// Tighten the socket to owner-only rw — see method-level note above.
|
|
183
|
-
// Windows AF_UNIX implementations don't honor POSIX modes; the chmod
|
|
184
|
-
// call is a no-op there but harmless.
|
|
185
|
-
try {
|
|
186
|
-
await chmod(opts.spec.socketPath, 0o600);
|
|
187
|
-
} catch (err) {
|
|
188
|
-
// Don't leave a world-readable socket up if we can't tighten it.
|
|
189
|
-
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
190
|
-
try {
|
|
191
|
-
await unlink(opts.spec.socketPath);
|
|
192
|
-
} catch {
|
|
193
|
-
// already gone
|
|
194
|
-
}
|
|
195
|
-
throw new Error(
|
|
196
|
-
`failed to chmod socket to 0600 at ${opts.spec.socketPath}: ${errorMessage(err)}`,
|
|
197
|
-
);
|
|
198
|
-
}
|
|
199
|
-
return listener;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** Shut down and remove the socket file. */
|
|
203
|
-
async close(): Promise<void> {
|
|
204
|
-
await new Promise<void>((resolve) => {
|
|
205
|
-
this.server.close(() => resolve());
|
|
206
|
-
});
|
|
207
|
-
try {
|
|
208
|
-
await unlink(this.socketPath);
|
|
209
|
-
} catch {
|
|
210
|
-
// already removed (some platforms do this on close)
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Promise that resolves when a handoff action (restart or switch) has
|
|
216
|
-
* stashed an argv to relaunch with. The PTY runner awaits this to know
|
|
217
|
-
* when to kill claude.
|
|
218
|
-
*/
|
|
219
|
-
triggered(): Promise<void> {
|
|
220
|
-
return this.triggeredPromise;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** Parsed argv (leading "fnclaude" token already dropped) to re-exec. */
|
|
224
|
-
getHandoffArgv(): string[] | undefined {
|
|
225
|
-
return this.handoffArgv === undefined ? undefined : this.handoffArgv.slice();
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** First-wins. Subsequent calls don't overwrite. */
|
|
229
|
-
private stashArgv(argv: string[]): void {
|
|
230
|
-
if (this.handoffArgv === undefined) {
|
|
231
|
-
this.handoffArgv = argv;
|
|
232
|
-
}
|
|
233
|
-
if (!this.triggeredFired) {
|
|
234
|
-
this.triggeredFired = true;
|
|
235
|
-
this.triggeredResolve();
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// ── per-connection handler ──────────────────────────────────────────────
|
|
240
|
-
|
|
241
|
-
private async handleConn(sock: Socket): Promise<void> {
|
|
242
|
-
try {
|
|
243
|
-
// Read one Request from the socket's data stream.
|
|
244
|
-
let req: Request | undefined;
|
|
245
|
-
try {
|
|
246
|
-
req = await readRequest(sock);
|
|
247
|
-
} catch (err) {
|
|
248
|
-
sock.write(
|
|
249
|
-
encodeResponse({
|
|
250
|
-
action: 'error',
|
|
251
|
-
error: `malformed request: ${errorMessage(err)}`,
|
|
252
|
-
}),
|
|
253
|
-
);
|
|
254
|
-
sock.end();
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
if (req === undefined) {
|
|
258
|
-
// EOF without a line — client disconnected silently. Nothing to respond.
|
|
259
|
-
sock.end();
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
const resp = await this.dispatch(req);
|
|
263
|
-
sock.write(encodeResponse(resp));
|
|
264
|
-
sock.end();
|
|
265
|
-
} catch (err) {
|
|
266
|
-
// Best-effort error response then close.
|
|
267
|
-
try {
|
|
268
|
-
sock.write(
|
|
269
|
-
encodeResponse({
|
|
270
|
-
action: 'error',
|
|
271
|
-
error: `handler failure: ${errorMessage(err)}`,
|
|
272
|
-
}),
|
|
273
|
-
);
|
|
274
|
-
} catch {
|
|
275
|
-
// ignore — socket may already be torn down
|
|
276
|
-
}
|
|
277
|
-
sock.end();
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
private async dispatch(req: Request): Promise<Response> {
|
|
282
|
-
switch (req.op) {
|
|
283
|
-
case 'restart':
|
|
284
|
-
return this.handleRestart(req);
|
|
285
|
-
case 'switch':
|
|
286
|
-
return this.handleSwitch(req);
|
|
287
|
-
case 'spawn':
|
|
288
|
-
return this.handleSpawn(req);
|
|
289
|
-
case 'copy_to_clipboard':
|
|
290
|
-
return this.handleCopy(req);
|
|
291
|
-
default: {
|
|
292
|
-
// Exhaustiveness: adding a new Op variant to Request without
|
|
293
|
-
// handling it here becomes a compile error on this line. The
|
|
294
|
-
// runtime branch defends against malformed wire input that
|
|
295
|
-
// squeezes through with an unknown op.
|
|
296
|
-
const _exhaustive: never = req;
|
|
297
|
-
void _exhaustive;
|
|
298
|
-
return {
|
|
299
|
-
action: 'error',
|
|
300
|
-
error: `unsupported op ${JSON.stringify((req as { op: unknown }).op)}`,
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// ── handleRestart ───────────────────────────────────────────────────────
|
|
307
|
-
|
|
308
|
-
private async handleRestart(req: RestartRequest): Promise<Response> {
|
|
309
|
-
const sid = req.session_id;
|
|
310
|
-
if (!sid) {
|
|
311
|
-
return {
|
|
312
|
-
action: 'error',
|
|
313
|
-
error:
|
|
314
|
-
'restart requires a session id; pass it as the fnc_restart session_id argument (read $CLAUDE_CODE_SESSION_ID via Bash).',
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
if (!SESSION_ID_PATTERN.test(sid)) {
|
|
318
|
-
return {
|
|
319
|
-
action: 'error',
|
|
320
|
-
error: `session_id ${JSON.stringify(sid)} is not a valid UUID; expected the 8-4-4-4-12 hex form.`,
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// Preserve user flags (no denylist for restart — everything carries).
|
|
325
|
-
const preserved = preserveArgs(this.origArgs, null, null);
|
|
326
|
-
// Apply MCP-supplied overrides.
|
|
327
|
-
let withOverrides = applyOverrides(preserved, req);
|
|
328
|
-
// Auto-capture live permission-mode from the session JSONL when the
|
|
329
|
-
// caller didn't override AND none was preserved.
|
|
330
|
-
if (
|
|
331
|
-
!req.permission_mode &&
|
|
332
|
-
!flagPresent(withOverrides, '--permission-mode')
|
|
333
|
-
) {
|
|
334
|
-
const live = readLivePermissionMode(this.launchCWD, sid);
|
|
335
|
-
if (live !== undefined) {
|
|
336
|
-
withOverrides = [...withOverrides, '--permission-mode', live];
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
const { magic, rest } = splitLeadingMagic(withOverrides);
|
|
340
|
-
|
|
341
|
-
// Append a system-reminder to the session JSONL so the resumed model
|
|
342
|
-
// sees a fresh directive to continue the in-flight work rather than
|
|
343
|
-
// treat the restart as a hard reset and idle (issue #77).
|
|
344
|
-
appendRestartReminder(this.launchCWD, sid, {
|
|
345
|
-
model: req.model,
|
|
346
|
-
effort: req.effort,
|
|
347
|
-
permissionMode: req.permission_mode,
|
|
348
|
-
agent: req.agent,
|
|
349
|
-
ide: req.ide === true,
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
const argv = [...magic, this.launchCWD, '--resume', sid, ...rest];
|
|
353
|
-
this.stashArgv(argv);
|
|
354
|
-
return { action: 'done' };
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// ── handleSwitch ────────────────────────────────────────────────────────
|
|
358
|
-
|
|
359
|
-
private async handleSwitch(req: SwitchRequest): Promise<Response> {
|
|
360
|
-
if (this.cfg.auto.handoff === 'never') {
|
|
361
|
-
return this.handleSwitchNeverMode(req);
|
|
362
|
-
}
|
|
363
|
-
const summaryPath = handoffContentPath();
|
|
364
|
-
try {
|
|
365
|
-
await writeFile(summaryPath, req.summary ?? '', { mode: 0o600 });
|
|
366
|
-
} catch (err) {
|
|
367
|
-
return { action: 'error', error: `write summary: ${errorMessage(err)}` };
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Preserve user flags minus the transfer denylist, then apply overrides.
|
|
371
|
-
const preserved = preserveArgs(this.origArgs, transferDenyFlags, transferDenyBareOK);
|
|
372
|
-
let withOverrides = applyOverrides(preserved, req);
|
|
373
|
-
// Auto-capture live permission-mode (same pattern as restart).
|
|
374
|
-
if (
|
|
375
|
-
!req.permission_mode &&
|
|
376
|
-
!flagPresent(withOverrides, '--permission-mode') &&
|
|
377
|
-
req.session_id
|
|
378
|
-
) {
|
|
379
|
-
const live = readLivePermissionMode(this.launchCWD, req.session_id);
|
|
380
|
-
if (live !== undefined) {
|
|
381
|
-
withOverrides = [...withOverrides, '--permission-mode', live];
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
const { magic, rest } = splitLeadingMagic(withOverrides);
|
|
385
|
-
|
|
386
|
-
const argv = [
|
|
387
|
-
...magic,
|
|
388
|
-
req.destination ?? '',
|
|
389
|
-
...rest,
|
|
390
|
-
'--name',
|
|
391
|
-
req.name ?? '',
|
|
392
|
-
`@${summaryPath}`,
|
|
393
|
-
];
|
|
394
|
-
this.stashArgv(argv);
|
|
395
|
-
return { action: 'done' };
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// ── handleSpawn ─────────────────────────────────────────────────────────
|
|
399
|
-
|
|
400
|
-
private async handleSpawn(req: SpawnRequest): Promise<Response> {
|
|
401
|
-
if (this.cfg.auto.handoff === 'never') {
|
|
402
|
-
return this.handleSpawnNeverMode(req);
|
|
403
|
-
}
|
|
404
|
-
const summaryPath = handoffContentPath();
|
|
405
|
-
try {
|
|
406
|
-
await writeFile(summaryPath, req.summary ?? '', { mode: 0o600 });
|
|
407
|
-
} catch (err) {
|
|
408
|
-
return { action: 'error', error: `write summary: ${errorMessage(err)}` };
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Spawn doesn't preserve startup flags — fresh-start sibling.
|
|
412
|
-
const extraArgs = applyOverrides([], req);
|
|
413
|
-
|
|
414
|
-
const dest = req.destination ?? '';
|
|
415
|
-
const name = req.name ?? '';
|
|
416
|
-
const { spawned, error } = await this.deps.spawnSibling(
|
|
417
|
-
this.cfg,
|
|
418
|
-
dest,
|
|
419
|
-
name,
|
|
420
|
-
summaryPath,
|
|
421
|
-
extraArgs,
|
|
422
|
-
);
|
|
423
|
-
if (error) {
|
|
424
|
-
return { action: 'error', error: `spawn: ${error.message}` };
|
|
425
|
-
}
|
|
426
|
-
if (!spawned) {
|
|
427
|
-
// No launcher resolved — fall back to paste-flow.
|
|
428
|
-
const cmdStr = renderSpawnCommand(dest, name, summaryPath, extraArgs);
|
|
429
|
-
const { ok } = await this.deps.copyToClipboard(cmdStr);
|
|
430
|
-
const msg = ok
|
|
431
|
-
? 'No spawn launcher configured for this terminal — the relaunch command is on your clipboard; paste it into a new terminal window. Set `auto.spawnCommand` in ~/.config/fnclaude/config.toml to enable auto-spawn (use {bin}, {dest}, {name}, {summary} placeholders).'
|
|
432
|
-
: 'No spawn launcher configured for this terminal — copy this command and run it in a new terminal window. Set `auto.spawnCommand` in ~/.config/fnclaude/config.toml to enable auto-spawn (use {bin}, {dest}, {name}, {summary} placeholders):';
|
|
433
|
-
return {
|
|
434
|
-
action: 'paste_flow',
|
|
435
|
-
message: msg,
|
|
436
|
-
command: cmdStr,
|
|
437
|
-
clipboard_ok: ok,
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
|
-
return {
|
|
441
|
-
action: 'done',
|
|
442
|
-
message: `Spawned sibling fnclaude for ${dest} in a new window.`,
|
|
443
|
-
};
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// ── never-mode handlers ─────────────────────────────────────────────────
|
|
447
|
-
|
|
448
|
-
private async handleSwitchNeverMode(req: SwitchRequest): Promise<Response> {
|
|
449
|
-
const summaryPath = handoffContentPath();
|
|
450
|
-
try {
|
|
451
|
-
await writeFile(summaryPath, req.summary ?? '', { mode: 0o600 });
|
|
452
|
-
} catch (err) {
|
|
453
|
-
return { action: 'error', error: `write summary: ${errorMessage(err)}` };
|
|
454
|
-
}
|
|
455
|
-
const preserved = preserveArgs(this.origArgs, transferDenyFlags, transferDenyBareOK);
|
|
456
|
-
const withOverrides = applyOverrides(preserved, req);
|
|
457
|
-
const { magic, rest } = splitLeadingMagic(withOverrides);
|
|
458
|
-
const cmdStr = renderSwitchCommand(
|
|
459
|
-
magic,
|
|
460
|
-
req.destination ?? '',
|
|
461
|
-
rest,
|
|
462
|
-
req.name ?? '',
|
|
463
|
-
summaryPath,
|
|
464
|
-
);
|
|
465
|
-
const { ok } = await this.deps.copyToClipboard(cmdStr);
|
|
466
|
-
const msg = ok
|
|
467
|
-
? "I've prepared the handoff command (already on your clipboard)."
|
|
468
|
-
: 'Copy this command and run it:';
|
|
469
|
-
return {
|
|
470
|
-
action: 'paste_flow',
|
|
471
|
-
message: msg,
|
|
472
|
-
command: cmdStr,
|
|
473
|
-
clipboard_ok: ok,
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
private async handleSpawnNeverMode(req: SpawnRequest): Promise<Response> {
|
|
478
|
-
const summaryPath = handoffContentPath();
|
|
479
|
-
try {
|
|
480
|
-
await writeFile(summaryPath, req.summary ?? '', { mode: 0o600 });
|
|
481
|
-
} catch (err) {
|
|
482
|
-
return { action: 'error', error: `write summary: ${errorMessage(err)}` };
|
|
483
|
-
}
|
|
484
|
-
const extraArgs = applyOverrides([], req);
|
|
485
|
-
const dest = req.destination ?? '';
|
|
486
|
-
const name = req.name ?? '';
|
|
487
|
-
const cmdStr = renderSpawnCommand(dest, name, summaryPath, extraArgs);
|
|
488
|
-
const { ok } = await this.deps.copyToClipboard(cmdStr);
|
|
489
|
-
const msg = ok
|
|
490
|
-
? 'Auto-handoff is disabled — the relaunch command is on your clipboard; paste it into a new terminal window.'
|
|
491
|
-
: 'Auto-handoff is disabled — copy this command and run it in a new terminal window:';
|
|
492
|
-
return {
|
|
493
|
-
action: 'paste_flow',
|
|
494
|
-
message: msg,
|
|
495
|
-
command: cmdStr,
|
|
496
|
-
clipboard_ok: ok,
|
|
497
|
-
};
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// ── handleCopy ──────────────────────────────────────────────────────────
|
|
501
|
-
|
|
502
|
-
private async handleCopy(req: CopyRequest): Promise<Response> {
|
|
503
|
-
const { ok } = await this.deps.copyToClipboard(req.text ?? '');
|
|
504
|
-
return { action: 'done', clipboard_ok: ok };
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// ── command-string rendering ──────────────────────────────────────────────
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* Build the user-visible relaunch command for paste-flow Responses (spawn).
|
|
512
|
-
*/
|
|
513
|
-
export function renderSpawnCommand(
|
|
514
|
-
destination: string,
|
|
515
|
-
name: string,
|
|
516
|
-
summaryPath: string,
|
|
517
|
-
extraArgs: readonly string[],
|
|
518
|
-
): string {
|
|
519
|
-
let cmd = `fnclaude ${destination} --name ${name} @${summaryPath}`;
|
|
520
|
-
if (extraArgs.length > 0) {
|
|
521
|
-
cmd += ` ${extraArgs.join(' ')}`;
|
|
522
|
-
}
|
|
523
|
-
return cmd;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/**
|
|
527
|
-
* Build the user-visible relaunch command for paste-flow Responses
|
|
528
|
-
* (never-mode switch). Magic words come first, then destination, then
|
|
529
|
-
* preserved/override flags, then --name name @summary at the end.
|
|
530
|
-
*/
|
|
531
|
-
export function renderSwitchCommand(
|
|
532
|
-
magic: readonly string[],
|
|
533
|
-
destination: string,
|
|
534
|
-
rest: readonly string[],
|
|
535
|
-
name: string,
|
|
536
|
-
summaryPath: string,
|
|
537
|
-
): string {
|
|
538
|
-
const parts: string[] = ['fnclaude', ...magic, destination, ...rest, '--name', name, `@${summaryPath}`];
|
|
539
|
-
return parts.join(' ');
|
|
540
|
-
}
|
package/src/noop.ts
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
// Port of src/noop.go (fnclaude/fnclaude Go reference).
|
|
2
|
-
//
|
|
3
|
-
// Ensures the user's noop dir exists and contains the up-to-date handoff
|
|
4
|
-
// template (a single-use bridge file written by the noop router when it
|
|
5
|
-
// hands off to a project session). Never touches CLAUDE.md or any other
|
|
6
|
-
// file in the dir — those are user territory.
|
|
7
|
-
//
|
|
8
|
-
// `defaultNoopDir` is re-exported from argParser.ts so callers can reach it
|
|
9
|
-
// from one canonical location; it's defined there because the parser uses
|
|
10
|
-
// it as the fallback when no positional path was provided.
|
|
11
|
-
|
|
12
|
-
import { createHash } from 'node:crypto';
|
|
13
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
14
|
-
import { join } from 'node:path';
|
|
15
|
-
|
|
16
|
-
export { defaultNoopDir } from './argParser.js';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* The embedded handoff.template.md content. Sourced verbatim from
|
|
20
|
-
* src/noop_templates/handoff.template.md in the Go reference. Inlined here
|
|
21
|
-
* (rather than read from disk) so the npm package ships a single binary
|
|
22
|
-
* with no companion data files to lose.
|
|
23
|
-
*
|
|
24
|
-
* Update process: when the upstream template changes, copy the new content
|
|
25
|
-
* here and the next seedNoop will detect the SHA-256 mismatch and rewrite
|
|
26
|
-
* the on-disk copy.
|
|
27
|
-
*/
|
|
28
|
-
export const NOOP_HANDOFF_TEMPLATE = `<!--
|
|
29
|
-
============================================================================
|
|
30
|
-
HANDOFF — BURN AFTER READING
|
|
31
|
-
============================================================================
|
|
32
|
-
This file is a single-use bridge between Claude sessions.
|
|
33
|
-
|
|
34
|
-
RECEIVING CLAUDE: your VERY FIRST action after reading this file is:
|
|
35
|
-
rm <HANDOFF_PATH>
|
|
36
|
-
Do this BEFORE responding to the user. The file is in tmpfs and
|
|
37
|
-
would eventually clear on logout, but explicit deletion keeps the
|
|
38
|
-
handoff queue accurate — leftover handoff files look like
|
|
39
|
-
unhandled handoffs. Confirm deletion in your first message, then
|
|
40
|
-
proceed with the work below.
|
|
41
|
-
============================================================================
|
|
42
|
-
-->
|
|
43
|
-
|
|
44
|
-
# Handoff from noop session — <ISO 8601 datetime>
|
|
45
|
-
|
|
46
|
-
## What the user asked for
|
|
47
|
-
<verbatim or near-verbatim version of the user's request — preserve their wording where you can>
|
|
48
|
-
|
|
49
|
-
## Context I gathered in noop
|
|
50
|
-
<anything relevant the receiving session needs: tool versions, decisions, links. Tight; don't pad.>
|
|
51
|
-
|
|
52
|
-
## What I did NOT do
|
|
53
|
-
<short list of what was correctly avoided in noop, so the receiving session knows where work starts>
|
|
54
|
-
|
|
55
|
-
## Suggested first steps for receiving session
|
|
56
|
-
1. <next concrete action>
|
|
57
|
-
2. <…>
|
|
58
|
-
|
|
59
|
-
## Open questions for the user
|
|
60
|
-
<only if any — otherwise omit the section>
|
|
61
|
-
`;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Hex SHA-256 of the embedded template. Comparing hex strings is simpler
|
|
65
|
-
* than comparing Buffers and reads cleanly in the on-disk check.
|
|
66
|
-
*/
|
|
67
|
-
const TEMPLATE_SHA = createHash('sha256').update(NOOP_HANDOFF_TEMPLATE).digest('hex');
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Ensure `noopDir` exists and contains the latest handoff template. Creates
|
|
71
|
-
* the dir if missing; rewrites the template iff the on-disk SHA-256 differs
|
|
72
|
-
* from the embedded SHA-256. Never touches any other file in the directory.
|
|
73
|
-
*
|
|
74
|
-
* Returns void on success; throws a wrapped Error on filesystem failure that
|
|
75
|
-
* the caller should surface as a warning (best-effort — a noop session
|
|
76
|
-
* without a freshly-seeded template can still receive the system prompt and
|
|
77
|
-
* route requests, just won't have the template to follow).
|
|
78
|
-
*/
|
|
79
|
-
export async function seedNoop(noopDir: string): Promise<void> {
|
|
80
|
-
try {
|
|
81
|
-
await mkdir(noopDir, { recursive: true, mode: 0o755 });
|
|
82
|
-
} catch (err) {
|
|
83
|
-
throw new Error(`create noop dir ${noopDir}: ${(err as Error).message}`);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const path = join(noopDir, 'handoff.template.md');
|
|
87
|
-
|
|
88
|
-
// Compare existing on-disk hash with the embedded hash; skip rewrite when
|
|
89
|
-
// they match.
|
|
90
|
-
try {
|
|
91
|
-
const existing = await readFile(path);
|
|
92
|
-
const existingSha = createHash('sha256').update(existing).digest('hex');
|
|
93
|
-
if (existingSha === TEMPLATE_SHA) return;
|
|
94
|
-
} catch (err) {
|
|
95
|
-
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
96
|
-
throw new Error(`read ${path}: ${(err as Error).message}`);
|
|
97
|
-
}
|
|
98
|
-
// ENOENT — fall through to write.
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
await writeFile(path, NOOP_HANDOFF_TEMPLATE, { mode: 0o644 });
|
|
103
|
-
} catch (err) {
|
|
104
|
-
throw new Error(`write ${path}: ${(err as Error).message}`);
|
|
105
|
-
}
|
|
106
|
-
}
|
package/src/passthrough.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// Passthrough-slice inspection helpers — the small predicates that ask
|
|
2
|
-
// "does this argv list already contain <flag>?". Shared between the
|
|
3
|
-
// argParser (which builds the slice) and the downstream pipeline stages
|
|
4
|
-
// (argv.ts, worktree.ts) that decide whether to inject more.
|
|
5
|
-
//
|
|
6
|
-
// Lives in its own module to break the import cycle that would otherwise
|
|
7
|
-
// arise: argParser.ts imports the predicates → argv.ts imports them too
|
|
8
|
-
// → worktree.ts needs nameInPassthrough → if it imported from argParser.ts
|
|
9
|
-
// that would close the loop (argParser → argv → worktree → argParser).
|
|
10
|
-
// With every consumer importing from here, the cycle disappears.
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* True when any token is `--setting-sources` or starts with `--setting-sources=`.
|
|
14
|
-
*/
|
|
15
|
-
export function settingSourcesInPassthrough(passthrough: readonly string[]): boolean {
|
|
16
|
-
return passthrough.some(
|
|
17
|
-
(t) => t === '--setting-sources' || t.startsWith('--setting-sources='),
|
|
18
|
-
);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* True when the exact token appears, or any `token=<anything>` form.
|
|
23
|
-
*/
|
|
24
|
-
export function tokenInPassthrough(passthrough: readonly string[], long: string): boolean {
|
|
25
|
-
const prefix = `${long}=`;
|
|
26
|
-
return passthrough.some((t) => t === long || t.startsWith(prefix));
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* True when --name or -n (bare or =value) appears anywhere in passthrough.
|
|
31
|
-
*/
|
|
32
|
-
export function nameInPassthrough(passthrough: readonly string[]): boolean {
|
|
33
|
-
return passthrough.some(
|
|
34
|
-
(t) => t === '--name' || t === '-n' || t.startsWith('--name=') || t.startsWith('-n='),
|
|
35
|
-
);
|
|
36
|
-
}
|