@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.
Files changed (100) hide show
  1. package/bin/fnc.js +34 -79
  2. package/package.json +6 -9
  3. package/share/fnclaude/templates/handoff.template.md +11 -0
  4. package/src/argv/classify.ts +48 -0
  5. package/src/argv/expand.ts +51 -0
  6. package/src/argv/intake.ts +52 -0
  7. package/src/argv/magic.ts +103 -0
  8. package/src/argv/parse.ts +213 -0
  9. package/src/argv/preserve-args.ts +333 -0
  10. package/src/argv/sentinel.ts +41 -0
  11. package/src/argv/short-flags.ts +152 -0
  12. package/src/config/load.ts +116 -0
  13. package/src/handoff/awaiter.ts +140 -0
  14. package/src/handoff/clean-env.ts +45 -0
  15. package/src/handoff/kill-and-exec.ts +110 -0
  16. package/src/handoff/spawn-launcher.ts +185 -0
  17. package/src/handoff/summary-file.ts +86 -0
  18. package/src/handoff/trigger.ts +90 -0
  19. package/src/help-version.ts +151 -0
  20. package/src/launch/compose-env.ts +34 -0
  21. package/src/launch/cross-cwd-parse.ts +69 -0
  22. package/src/launch/cross-cwd-relaunch.ts +95 -0
  23. package/src/launch/find-claude.ts +52 -0
  24. package/src/launch/live-permission-reader.ts +133 -0
  25. package/src/launch/ring-buffer.ts +92 -0
  26. package/src/main.ts +580 -437
  27. package/src/mcp/dispatch.ts +240 -0
  28. package/src/mcp/handlers/clipboard-backends.ts +176 -0
  29. package/src/mcp/handlers/clipboard.ts +62 -0
  30. package/src/mcp/handlers/restart.ts +156 -0
  31. package/src/mcp/handlers/spawn.ts +219 -0
  32. package/src/mcp/handlers/switch.ts +272 -0
  33. package/src/mcp/inject-config.ts +59 -0
  34. package/src/mcp/jsonrpc-server.ts +154 -0
  35. package/src/mcp/listener.ts +141 -0
  36. package/src/mcp/parent-dispatch.ts +154 -0
  37. package/src/mcp/socket-path.ts +48 -0
  38. package/src/mcp/wire.ts +181 -0
  39. package/src/name/auto-name.ts +162 -0
  40. package/src/name/llm-prompt.ts +14 -0
  41. package/src/name/sanitize.ts +57 -0
  42. package/src/name/sdk-llm.ts +42 -0
  43. package/src/noop/seed.ts +63 -0
  44. package/src/noop/template-source.ts +62 -0
  45. package/src/path/ensure-cwd.ts +95 -0
  46. package/src/path/resolve.ts +58 -0
  47. package/src/prompts/dir.ts +61 -0
  48. package/src/prompts/load.ts +100 -0
  49. package/src/prompts/select.ts +43 -0
  50. package/src/repo/clone-exec.ts +37 -0
  51. package/src/repo/clone.ts +45 -0
  52. package/src/repo/gh-runner.ts +68 -0
  53. package/src/repo/host-aliases.ts +58 -0
  54. package/src/repo/owner-lookup.ts +71 -0
  55. package/src/repo/ref.ts +146 -0
  56. package/src/repo/repo-settings.ts +99 -0
  57. package/src/repo/resolve-input.ts +179 -0
  58. package/src/repo/template.ts +92 -0
  59. package/src/warnings/buffer.ts +39 -0
  60. package/src/worktree/auto-tmux.ts +45 -0
  61. package/src/worktree/git-list.ts +73 -0
  62. package/src/worktree/intercept.ts +150 -0
  63. package/bin/preflight.js +0 -66
  64. package/prompts/agent-pitfall.md +0 -1
  65. package/prompts/noop-router.md +0 -186
  66. package/prompts/project-switch.md +0 -64
  67. package/prompts/restart.md +0 -50
  68. package/prompts/spawn.md +0 -62
  69. package/src/argParser.ts +0 -367
  70. package/src/args/preserve.ts +0 -338
  71. package/src/args.ts +0 -239
  72. package/src/argv.ts +0 -203
  73. package/src/autoname.ts +0 -273
  74. package/src/clipboard.ts +0 -149
  75. package/src/config.ts +0 -369
  76. package/src/errors.ts +0 -13
  77. package/src/handoff.ts +0 -108
  78. package/src/help.ts +0 -139
  79. package/src/hostAliases.ts +0 -139
  80. package/src/index.ts +0 -120
  81. package/src/mcp/client.ts +0 -645
  82. package/src/mcp/protocol.ts +0 -445
  83. package/src/mcp/socketListener.ts +0 -540
  84. package/src/noop.ts +0 -106
  85. package/src/passthrough.ts +0 -36
  86. package/src/paths.ts +0 -55
  87. package/src/prompts.ts +0 -279
  88. package/src/pty/unix.ts +0 -429
  89. package/src/pty/windows.ts +0 -125
  90. package/src/pty.ts +0 -380
  91. package/src/repoRef.ts +0 -158
  92. package/src/repoSettings.ts +0 -144
  93. package/src/resolver.ts +0 -519
  94. package/src/sanitize.ts +0 -120
  95. package/src/sessionState.ts +0 -220
  96. package/src/silentRelaunch.ts +0 -178
  97. package/src/spawn.ts +0 -163
  98. package/src/template.ts +0 -44
  99. package/src/warnings.ts +0 -34
  100. 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
- }
@@ -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
- }