handmux 0.27.2 → 0.29.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 (78) hide show
  1. package/bin/handmux.js +2 -2
  2. package/dist/bin/handmux.js +51 -21
  3. package/dist/connectors/bridgeClient.js +30 -14
  4. package/dist/connectors/claude/index.js +78 -23
  5. package/dist/hooks/handmux-write.cjs +11 -0
  6. package/dist/package.json +13 -13
  7. package/dist/public/assets/index-BjnnFTLC.css +32 -0
  8. package/dist/public/assets/index-D0tDAqO-.js +356 -0
  9. package/dist/public/index.html +2 -2
  10. package/dist/src/agent-runtime/bridgeTransport.js +31 -4
  11. package/dist/src/agent-runtime/builtinRuntime.js +10 -2
  12. package/dist/src/agent-runtime/conversation.js +3 -3
  13. package/dist/src/agent-runtime/conversationStore.js +2 -2
  14. package/dist/src/agent-runtime/inbox.js +20 -2
  15. package/dist/src/agent-runtime/run.js +5 -1
  16. package/dist/src/agents/claudeConversation.js +69 -6
  17. package/dist/src/agents/claudeLocalCommand.js +54 -0
  18. package/dist/src/agents/claudeNativeTail.js +271 -0
  19. package/dist/src/agents/claudePaneInput.js +47 -0
  20. package/dist/src/agents/codexConversation.js +46 -0
  21. package/dist/src/agents/piInboxBridge.js +64 -5
  22. package/dist/src/agents/processIdentity.js +13 -6
  23. package/dist/src/apiAccountProviders.js +5 -4
  24. package/dist/src/browser/bootstrap.js +5 -0
  25. package/dist/src/browser/credentials.js +68 -0
  26. package/dist/src/browser/manager.js +40 -5
  27. package/dist/src/browser/publicProxy.js +15 -5
  28. package/dist/src/browser/routes.js +7 -0
  29. package/dist/src/browser/workerClient.js +174 -9
  30. package/dist/src/browser/workerServer.js +13 -1
  31. package/dist/src/claudeEvents.js +94 -38
  32. package/dist/src/cli/authCmd.js +202 -0
  33. package/dist/src/cli/authDefaults.js +18 -0
  34. package/dist/src/cli/claudeHooks.js +3 -3
  35. package/dist/src/cli/hookScaffold.js +5 -1
  36. package/dist/src/cli/i18n/en.js +66 -11
  37. package/dist/src/cli/i18n/zh.js +66 -11
  38. package/dist/src/cli/options.js +7 -13
  39. package/dist/src/cli/pushCmd.js +24 -11
  40. package/dist/src/cli/setupModel.js +28 -6
  41. package/dist/src/cli/setupWizard.js +47 -25
  42. package/dist/src/cli/shortcutEditor.js +20 -2
  43. package/dist/src/cli/supervisor.js +5 -7
  44. package/dist/src/codexAppServer.js +25 -15
  45. package/dist/src/codexTranscriptParse.js +40 -1
  46. package/dist/src/codexUsageSnapshot.js +1 -1
  47. package/dist/src/deviceAccess.js +134 -0
  48. package/dist/src/deviceAuth/control.js +154 -0
  49. package/dist/src/deviceAuth/http.js +321 -0
  50. package/dist/src/deviceAuth/service.js +626 -0
  51. package/dist/src/docs.js +3 -0
  52. package/dist/src/httpApi.js +8 -3
  53. package/dist/src/paneInput.js +16 -2
  54. package/dist/src/previewServer.js +26 -2
  55. package/dist/src/previews.js +37 -13
  56. package/dist/src/projectTask/backup.js +28 -0
  57. package/dist/src/projectTask/lock.js +1 -1
  58. package/dist/src/projectTask/migrations.js +30 -1
  59. package/dist/src/projectTask/runtime.js +7 -1
  60. package/dist/src/projectTask/schema.js +2 -2
  61. package/dist/src/push.js +44 -13
  62. package/dist/src/requestAuthority.js +8 -0
  63. package/dist/src/requestOrigin.js +62 -0
  64. package/dist/src/routes/agents.js +3 -1
  65. package/dist/src/routes/files.js +2 -0
  66. package/dist/src/routes/previews.js +9 -3
  67. package/dist/src/routes/push.js +55 -58
  68. package/dist/src/routes/system.js +4 -4
  69. package/dist/src/routes/terminal.js +9 -1
  70. package/dist/src/server.js +88 -12
  71. package/dist/src/terminalStream.js +90 -5
  72. package/dist/src/tmux/commands.js +2 -0
  73. package/dist/src/transcriptParse.js +4 -1
  74. package/dist/src/workspace/environment.js +14 -5
  75. package/dist/src/workspace/runtime.js +16 -1
  76. package/package.json +4 -4
  77. package/dist/public/assets/index-B4pKf3IW.js +0 -356
  78. package/dist/public/assets/index-Cdrh2CUp.css +0 -32
@@ -6,6 +6,7 @@ import { resolveVersionedComms } from './agents/claude.js';
6
6
  import { resolveCodexComms } from './agents/codex.js';
7
7
  import { defaultRun } from './agents/scanUtils.js';
8
8
  import { claude } from './agents/claude.js';
9
+ import { ClaudeNativeTailReader } from './agents/claudeNativeTail.js';
9
10
  const here = path.dirname(fileURLToPath(import.meta.url));
10
11
  // The hook-maintained state file: ONE JSON object keyed by tmux pane id, each value the pane's latest
11
12
  // event { ts, src, host, payload }. The hook writes it (handmux-write.js); the server only reads it.
@@ -60,28 +61,6 @@ function defaultStatMtime(file) { try {
60
61
  catch {
61
62
  return null;
62
63
  } }
63
- // Read the LAST complete JSON line of a (possibly multi-MB) transcript, or null if unreadable. Reads only a
64
- // bounded tail so cost is one small read regardless of transcript size; a last line longer than the window
65
- // won't parse cleanly, but the only line we care to recognise (the interrupt marker) is tiny.
66
- function defaultReadTail(file) {
67
- try {
68
- const fd = fs.openSync(file, 'r');
69
- try {
70
- const size = fs.fstatSync(fd).size;
71
- const len = Math.min(size, 65536);
72
- const buf = Buffer.alloc(len);
73
- fs.readSync(fd, buf, 0, len, size - len);
74
- const lines = buf.toString('utf8').split('\n').filter((line) => line.trim());
75
- return lines.at(-1) ?? null;
76
- }
77
- finally {
78
- fs.closeSync(fd);
79
- }
80
- }
81
- catch {
82
- return null;
83
- }
84
- }
85
64
  // Pure: has the user resolved the permission prompt recorded by `rec`, judged by its transcript mtime?
86
65
  // True only once the transcript has grown past the event ts by more than the guard. Exported for testing.
87
66
  export function permissionResolved(rec, mtimeMs, guard = PERM_RESOLVED_GUARD_MS) {
@@ -145,6 +124,14 @@ function readStateFile(file) {
145
124
  src: typeof row.src === 'string' ? row.src : '',
146
125
  payload,
147
126
  };
127
+ if (typeof row.sequence === 'number' && Number.isSafeInteger(row.sequence) && row.sequence > 0) {
128
+ record.sequence = row.sequence;
129
+ }
130
+ const process = recordOf(row.process);
131
+ if (process && typeof process.pid === 'number' && Number.isSafeInteger(process.pid)
132
+ && typeof process.startedAt === 'number' && Number.isFinite(process.startedAt)) {
133
+ record.process = { pid: process.pid, startedAt: process.startedAt };
134
+ }
148
135
  if (Object.hasOwn(row, 'agent')) {
149
136
  record.agent = typeof row.agent === 'string' && row.agent ? row.agent : null;
150
137
  }
@@ -215,7 +202,7 @@ function managedStates(value) {
215
202
  // file: the hook-maintained JSON state file (DEFAULT_STATE_FILE).
216
203
  // The hook is the sole writer; the server reads the file fresh on every getStates and on every file
217
204
  // change (the watcher, for push). No persisted state of our own — the file IS the persistence.
218
- export function createClaudeEvents({ commands, push, codexApp = null, file = DEFAULT_STATE_FILE, now = () => Date.now(), statMtime = defaultStatMtime, readTail = defaultReadTail, run = defaultRun, onStateChange = () => { }, watch = fs.watch, mkdir = fs.mkdirSync, setTimer = setTimeout, clearTimer = (handle) => clearTimeout(handle), } = {}) {
205
+ export function createClaudeEvents({ nativeTail = new ClaudeNativeTailReader(), commands, push, codexApp = null, file = DEFAULT_STATE_FILE, now = () => Date.now(), statMtime = defaultStatMtime, readTail, run = defaultRun, onStateChange = () => { }, watch = fs.watch, mkdir = fs.mkdirSync, setTimer = setTimeout, clearTimer = (handle) => clearTimeout(handle), } = {}) {
219
206
  // Short-lived executable-identity verdicts. Each poll still checks the foreground pid signature; the
220
207
  // cache only avoids repeating lsof while that exact process set is unchanged.
221
208
  const commVerdicts = new Map();
@@ -326,15 +313,23 @@ export function createClaudeEvents({ commands, push, codexApp = null, file = DEF
326
313
  if (!agent)
327
314
  continue;
328
315
  let c = classifyRecord(rec);
316
+ const native = rec.src === 'end' ? null : nativeTail.read(rec.payload, rec.ts, now(), rec.process, rec.src);
317
+ if (native?.status === 'busy')
318
+ c = { kind: 'working', msg: '' };
319
+ else if (native?.status === 'waiting')
320
+ c = { kind: 'permission', msg: '' };
321
+ else if (native?.settled)
322
+ c = null;
329
323
  // A 需要你 the user already resolved leaves no closing hook (see PERM_RESOLVED_GUARD_MS). statMtime
330
324
  // gates the cheap "still pending" path; only once the transcript has grown past the event do we pay the
331
325
  // bounded tail read to tell a RESUME (approve/deny → 进行中) from an INTERRUPT (ESC → neutral present).
332
- if (c && c.kind === 'permission') {
326
+ if (!native?.status && c && c.kind === 'permission') {
333
327
  const tp = typeof rec.payload.transcript_path === 'string' ? rec.payload.transcript_path : null;
334
- if (tp && permissionResolved(rec, statMtime(tp)))
335
- c = resolvedPermissionKind(readTail(tp));
328
+ if (tp && permissionResolved(rec, statMtime(tp))) {
329
+ c = readTail ? resolvedPermissionKind(readTail(tp)) : { kind: 'working', msg: '' };
330
+ }
336
331
  }
337
- else if (c && c.kind === 'working') {
332
+ else if (!native?.status && c && c.kind === 'working') {
338
333
  // ESC-interrupt during a turn leaves the last hook as the stale 'prompt' (working) — no Stop fires,
339
334
  // so working would otherwise stick until WORKING_TTL_MS (2h), pinning the composer's send→stop toggle.
340
335
  // Once the transcript has grown past the prompt event, a bounded tail read settles it: an interrupt
@@ -342,18 +337,25 @@ export function createClaudeEvents({ commands, push, codexApp = null, file = DEF
342
337
  // definitive so no guard window is needed; an unreadable stat/tail can't tell → keep working.
343
338
  const tp = typeof rec.payload.transcript_path === 'string' ? rec.payload.transcript_path : null;
344
339
  const mtime = tp ? statMtime(tp) : null;
345
- if (tp && typeof mtime === 'number' && mtime > rec.ts && isInterruptTail(readTail(tp)))
340
+ if (readTail && tp && typeof mtime === 'number' && mtime > rec.ts && isInterruptTail(readTail(tp)))
346
341
  c = null;
347
342
  }
348
- else if (c && c.kind === 'compacting') {
343
+ else if (!native?.status && c && c.kind === 'compacting') {
349
344
  // A no-op /compact fires no PostCompact; it writes its <local-command-stdout> at once. Once the
350
345
  // transcript has grown past the PreCompact event and its tail is that stdout, the /compact is done
351
346
  // (nothing to compact) → drop 压缩中. A real compaction stays silent until PostCompact, so it keeps
352
347
  // showing for its whole run. Unreadable stat/tail → can't tell → keep 压缩中 (the TTL is the backstop).
353
348
  const tp = typeof rec.payload.transcript_path === 'string' ? rec.payload.transcript_path : null;
354
349
  const mtime = tp ? statMtime(tp) : null;
355
- if (tp && typeof mtime === 'number' && mtime > rec.ts && isLocalCommandStdout(readTail(tp)))
356
- c = null;
350
+ if (tp && typeof mtime === 'number' && mtime > rec.ts) {
351
+ const last = native?.lastRecord;
352
+ const content = recordOf(last?.message)?.content;
353
+ const stdout = last?.sessionId === rec.payload.session_id && last?.isSidechain !== true
354
+ && typeof last?.timestamp === 'string' && Date.parse(last.timestamp) > rec.ts
355
+ && typeof content === 'string' && /^\s*<local-command-stdout>[\s\S]*<\/local-command-stdout>\s*$/.test(content);
356
+ if (readTail ? isLocalCommandStdout(readTail(tp)) : stdout)
357
+ c = null;
358
+ }
357
359
  }
358
360
  const lp = live ? live.get(pane) : null;
359
361
  // Dropped when tmux says the pane is gone or no longer running THIS agent (hard kill / crash /
@@ -463,6 +465,7 @@ export function createClaudeEvents({ commands, push, codexApp = null, file = DEF
463
465
  catch { /* fs.watch unsupported → push falls back to evaluation on each /states poll */ }
464
466
  }
465
467
  function stop() {
468
+ nativeTail.clear();
466
469
  if (watcher) {
467
470
  watcher.close();
468
471
  watcher = null;
@@ -503,23 +506,76 @@ export function createClaudeEvents({ commands, push, codexApp = null, file = DEF
503
506
  return 'claude';
504
507
  return rec.agent === 'claude' ? 'claude' : null;
505
508
  }
506
- function paneKind(pane) {
509
+ function paneKind(pane, process) {
507
510
  const rec = readStateFile(file)[pane];
508
- if (!rec || rec.agent === 'codex')
511
+ if (!rec || (rec.agent !== undefined && rec.agent !== 'claude'))
512
+ return null;
513
+ if (process && rec.process && (process.pid !== rec.process.pid || process.startedAt !== rec.process.startedAt))
509
514
  return null;
515
+ if (rec.src === 'end') {
516
+ if (typeof rec.payload.session_id === 'string')
517
+ nativeTail.release(rec.payload.session_id);
518
+ }
519
+ else {
520
+ const native = nativeTail.read(rec.payload, rec.ts, now(), rec.process, rec.src);
521
+ if (native.status === 'unknown')
522
+ return null;
523
+ if (native.status === 'busy')
524
+ return 'working';
525
+ if (native.status === 'waiting')
526
+ return 'permission';
527
+ if (native.settled)
528
+ return 'idle';
529
+ }
530
+ // Activity is not the Inbox roster: neutral lifecycle edges deliberately create no Inbox card,
531
+ // but a completed manual /compact must release the conversation queue. Automatic compaction
532
+ // continues the current turn, so it must not admit another prompt between compaction and generation.
533
+ if (rec.src === 'compact') {
534
+ return rec.payload.trigger === 'manual' ? 'idle'
535
+ : rec.payload.trigger === 'auto' ? 'working' : null;
536
+ }
537
+ if (rec.src === 'start') {
538
+ // Native full/partial/reactive compaction calls SessionStart(compact) before PostCompact.
539
+ // It identifies neither an idle session nor specifically an automatic compaction.
540
+ return ['startup', 'clear', 'resume'].includes(String(rec.payload.source)) ? 'idle'
541
+ : rec.payload.source === 'compact' ? 'compacting' : null;
542
+ }
510
543
  return classifyRecord(rec)?.kind ?? null;
511
544
  }
512
- function paneCompletionToken(pane) {
545
+ function paneCompletionToken(pane, process) {
513
546
  const rec = readStateFile(file)[pane];
514
- if (!rec || rec.agent === 'codex')
547
+ if (!rec || (rec.agent !== undefined && rec.agent !== 'claude'))
548
+ return null;
549
+ if (process && rec.process && (process.pid !== rec.process.pid || process.startedAt !== rec.process.startedAt))
550
+ return null;
551
+ const native = rec.src === 'end' ? undefined : nativeTail.read(rec.payload, rec.ts, now(), rec.process, rec.src);
552
+ if (native?.status && native.status !== 'idle')
515
553
  return null;
516
- const kind = classifyRecord(rec)?.kind ?? null;
554
+ if (native?.settled)
555
+ return native.settled;
556
+ // An idle startup/resume/clear is the first dispatch baseline. Give it a stable token too:
557
+ // the queue can then detect a fast completion even when no poll observed its busy phase.
558
+ const baseline = rec.src === 'start' && ['startup', 'clear', 'resume'].includes(String(rec.payload.source));
559
+ const kind = (rec.src === 'compact' && rec.payload.trigger === 'manual') || baseline
560
+ ? 'idle' : classifyRecord(rec)?.kind ?? null;
517
561
  if (kind !== 'done' && kind !== 'error' && kind !== 'end' && kind !== 'idle')
518
562
  return null;
519
- return Number.isFinite(rec.ts) && rec.ts >= 0 ? `claude-completed:${rec.ts}` : null;
563
+ const nativeCompletion = native?.localCommand;
564
+ if (nativeCompletion !== undefined)
565
+ return nativeCompletion;
566
+ return Number.isFinite(rec.ts) && rec.ts >= 0
567
+ ? `claude-${baseline ? 'baseline' : 'completed'}:${rec.ts}${rec.sequence === undefined ? '' : `:${rec.sequence}`}`
568
+ : null;
520
569
  }
521
570
  return {
522
571
  getStates, identifyPaneAgents, start, stop, paneSession, paneAgent,
523
- paneKind, paneCompletionToken,
572
+ paneKind, paneCompletionToken, nativeTail,
573
+ paneRestoredPrompt(pane) {
574
+ const rec = readStateFile(file)[pane];
575
+ if (!rec || rec.src === 'end' || (rec.agent !== undefined && rec.agent !== 'claude'))
576
+ return null;
577
+ const native = nativeTail.read(rec.payload, rec.ts, now(), rec.process, rec.src);
578
+ return native.status === 'idle' ? native.restoredPrompt ?? null : null;
579
+ },
524
580
  };
525
581
  }
@@ -0,0 +1,202 @@
1
+ import { text, select, confirm, isCancel } from '@clack/prompts';
2
+ import { AuthControlError, connectAuthControl } from '../deviceAuth/control.js';
3
+ import { DeviceAuthError, parseExpire, validateName } from '../deviceAuth/service.js';
4
+ import { tokenWarning } from './authDefaults.js';
5
+ import { t } from './i18n/index.js';
6
+ const usage = (target) => target === 'device'
7
+ ? 'Usage: handmux auth device status|on|off|add|list|edit|revoke'
8
+ : target === 'address'
9
+ ? 'Usage: handmux auth address status|on|off|list|add|remove'
10
+ : 'Usage: handmux auth device <action> | handmux auth address <action>';
11
+ export function parseAuthArgs(argv, interactive) {
12
+ const [target, action, ...rest] = argv;
13
+ if (target !== 'device' && target !== 'address') {
14
+ throw new Error(usage());
15
+ }
16
+ const deviceActions = ['status', 'on', 'off', 'add', 'list', 'edit', 'revoke'];
17
+ const addressActions = ['status', 'on', 'off', 'list', 'add', 'remove'];
18
+ const allowed = target === 'device' ? deviceActions : addressActions;
19
+ if (!action || !allowed.includes(action))
20
+ throw new Error(usage(target));
21
+ const result = { target, action: action };
22
+ if (target === 'address') {
23
+ if (action === 'add' || action === 'remove') {
24
+ if (rest.length !== 1 || !rest[0])
25
+ throw new Error(`Usage: handmux auth address ${action} <origin>`);
26
+ result.origin = rest[0];
27
+ }
28
+ else if (rest.length)
29
+ throw new Error(`Usage: handmux auth address ${action}`);
30
+ return result;
31
+ }
32
+ if (['status', 'on', 'off', 'list'].includes(action)) {
33
+ if (rest.length)
34
+ throw new Error(`Usage: handmux auth device ${action}`);
35
+ return result;
36
+ }
37
+ for (let i = 0; i < rest.length; i++) {
38
+ const arg = rest[i];
39
+ if (arg === '--code' || arg === '--name' || arg === '--expire') {
40
+ const key = arg === '--code' ? 'code' : arg === '--name' ? 'name' : 'expire';
41
+ const value = rest[++i];
42
+ if (!value || value.startsWith('--') || result[key] !== undefined)
43
+ throw new Error(`Provide ${arg} exactly once with a value`);
44
+ result[key] = value;
45
+ }
46
+ else if (arg.startsWith('-') || result.id !== undefined)
47
+ throw new Error(`Unexpected argument: ${arg}`);
48
+ else if (action === 'add')
49
+ throw new Error('Use --code for non-interactive add; interactive add reads the code after handmux auth device add');
50
+ else
51
+ result.id = arg;
52
+ }
53
+ if (action === 'list' && (result.id || result.code || result.name !== undefined || result.expire !== undefined))
54
+ throw new Error('Usage: handmux auth device list');
55
+ if (action !== 'add' && result.code !== undefined)
56
+ throw new Error('The --code flag is only valid for handmux auth device add');
57
+ if (action === 'revoke' && (result.name !== undefined || result.expire !== undefined))
58
+ throw new Error('Usage: handmux auth device revoke <device-id>');
59
+ if ((action === 'edit' || action === 'revoke') && !result.id)
60
+ throw new Error('Provide the exact device ID from handmux auth device list');
61
+ if (action === 'edit' && result.name === undefined && result.expire === undefined)
62
+ throw new Error('Provide --name or --expire');
63
+ if (result.name !== undefined)
64
+ result.name = validateName(result.name);
65
+ if (result.expire !== undefined)
66
+ parseExpire(result.expire);
67
+ if (action === 'add') {
68
+ if (result.code !== undefined && !/^\d{6}$/.test(result.code))
69
+ throw new Error('Code must be exactly 6 digits, preserving leading zeros');
70
+ if (!interactive && (result.code === undefined || result.name === undefined || result.expire === undefined))
71
+ throw new Error('Non-interactive add requires --code, --name and --expire; no code has been consumed');
72
+ }
73
+ return result;
74
+ }
75
+ const safeText = (value) => String(value ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, '');
76
+ const controlErrorCopy = {
77
+ AUTH_UNAVAILABLE: 'auth.error.unavailable', INVALID_COMMAND: 'auth.error.invalidCommand',
78
+ CODE_INVALID: 'auth.error.codeInvalid', CLAIM_RATE_LIMIT: 'auth.error.rateLimit',
79
+ PAIRING_NOT_FOUND: 'auth.error.pairingGone', PAIRING_INACTIVE: 'auth.error.pairingGone',
80
+ DEVICE_NOT_FOUND: 'auth.error.deviceNotFound', DEVICE_INACTIVE: 'auth.error.deviceInactive',
81
+ DEVICE_CONFLICT: 'auth.error.conflict', INVALID_EDIT: 'auth.error.invalidEdit',
82
+ INVALID_VERSION: 'auth.error.invalidVersion', PAIRING_CAPACITY: 'auth.error.pairingCapacity',
83
+ TOKEN_REQUIRED: 'auth.error.tokenRequired', DEVICE_REQUIRED: 'auth.error.deviceRequired',
84
+ DEVICE_EXPIRY_CLI_ONLY: 'auth.error.expiryCliOnly',
85
+ INVALID_EXPIRE: 'auth.invalidDuration', INVALID_NAME: 'auth.invalidName',
86
+ INVALID_ORIGIN: 'auth.error.invalidOrigin', ORIGIN_LIMIT: 'auth.error.originLimit',
87
+ SESSION_INVALID: 'auth.error.sessionInvalid', DEVICE_AUTH_DISABLED: 'auth.error.unavailable',
88
+ };
89
+ const iso = (value) => typeof value === 'number' ? new Date(value).toISOString() : 'never';
90
+ function outputDevice(value) {
91
+ const d = value;
92
+ return `${safeText(d.id)}\t${safeText(d.name)}\t${safeText(d.status)}\t${iso(d.expires_at)}\t${iso(d.authorized_at)}\t${iso(d.last_used_at)}\t${safeText(d.browser_summary)}${'previousExpiresAt' in d ? `\tprevious expiry: ${iso(d.previousExpiresAt)}` : ''}`;
93
+ }
94
+ const canceled = Symbol('canceled');
95
+ async function ask(promise) { const result = await promise; if (isCancel(result))
96
+ throw canceled; return result; }
97
+ export async function runAuthCommand({ argv, home, interactive = !!process.stdin.isTTY, log = console.log, err = console.error, connect = connectAuthControl }) {
98
+ let client;
99
+ try {
100
+ const args = parseAuthArgs(argv, interactive);
101
+ client = await connect(home);
102
+ if (args.target === 'address') {
103
+ if (args.action === 'status' || args.action === 'list') {
104
+ const result = await client.request({ op: 'address-status' });
105
+ log(`可信地址保护: ${result.enabled ? '已开启' : '未开启'}`);
106
+ if (!result.enabled)
107
+ log(tokenWarning(t('auth.addressWarning')));
108
+ result.origins.forEach(origin => log(origin));
109
+ return 0;
110
+ }
111
+ if (args.action === 'on' || args.action === 'off') {
112
+ if (args.action === 'off' && interactive && !await ask(confirm({ message: t('auth.addressDisableConfirm'), initialValue: false })))
113
+ throw canceled;
114
+ const result = await client.request({ op: 'address-policy', enabled: args.action === 'on' });
115
+ log(`可信地址保护: ${result.enabled ? '已开启' : '未开启'}`);
116
+ return 0;
117
+ }
118
+ const result = await client.request({ op: args.action === 'add' ? 'address-add' : 'address-remove', origin: args.origin });
119
+ log(`访问地址: ${args.action === 'add' ? '已添加' : '已删除'}`);
120
+ result.origins.forEach(origin => log(origin));
121
+ return 0;
122
+ }
123
+ if (args.action === 'status') {
124
+ const result = await client.request({ op: 'device-status' });
125
+ log(`可信设备保护: ${result.enabled ? '已开启' : '未开启'}`);
126
+ if (!result.enabled)
127
+ log(tokenWarning(t('auth.warning')));
128
+ log('ID\tNAME\tSTATUS\tEXPIRES\tADDED\tLAST ACCESS\tBROWSER');
129
+ result.devices.forEach(d => log(outputDevice(d)));
130
+ return 0;
131
+ }
132
+ if (args.action === 'on' || args.action === 'off') {
133
+ if (args.action === 'off' && interactive && !await ask(confirm({ message: t('auth.deviceDisableConfirm'), initialValue: false })))
134
+ throw canceled;
135
+ const result = await client.request({ op: 'device-policy', enabled: args.action === 'on' });
136
+ log(`可信设备保护: ${result.enabled ? '已开启' : '未开启'}`);
137
+ return 0;
138
+ }
139
+ if (args.action === 'list') {
140
+ const result = await client.request({ op: 'device-list' });
141
+ log('ID\tNAME\tSTATUS\tEXPIRES\tADDED\tLAST ACCESS\tBROWSER');
142
+ result.forEach(d => log(outputDevice(d)));
143
+ return 0;
144
+ }
145
+ if (args.action !== 'add') {
146
+ const result = await client.request({ op: `device-${args.action}`, id: args.id, name: args.name, expire: args.expire });
147
+ log('ID\tNAME\tSTATUS\tEXPIRES\tADDED\tLAST ACCESS\tBROWSER');
148
+ log(outputDevice(result));
149
+ return 0;
150
+ }
151
+ log(t('auth.safety'));
152
+ if (!args.code)
153
+ args.code = await ask(text({ message: t('auth.code'), validate: v => /^\d{6}$/.test(v ?? '') ? undefined : t('auth.codeInvalid') }));
154
+ const claimed = await client.request({ op: 'claim', code: args.code });
155
+ const needsPrompt = args.name === undefined || args.expire === undefined;
156
+ log(t('auth.claimed'));
157
+ log(safeText(claimed.browserSummary));
158
+ if (claimed.origin)
159
+ log(`${t('auth.origin')}: ${safeText(claimed.origin)}`);
160
+ if (args.name === undefined) {
161
+ const fallback = safeText(claimed.browserSummary).slice(0, 80) || 'Browser';
162
+ args.name = await ask(text({ message: t('auth.name'), defaultValue: fallback, placeholder: fallback, validate: v => { try {
163
+ validateName(v || fallback);
164
+ }
165
+ catch (e) {
166
+ return e instanceof DeviceAuthError && e.code === 'INVALID_NAME' ? t('auth.invalidName') : e.message;
167
+ } return undefined; } }));
168
+ args.name = args.name || fallback;
169
+ }
170
+ if (args.expire === undefined) {
171
+ const expire = await ask(select({ message: t('auth.expire'), initialValue: '30d', options: [
172
+ ...['1h', '1d', '7d', '30d'].map(value => ({ value, label: value })),
173
+ { value: 'custom', label: t('auth.custom') }, { value: 'never', label: t('auth.never') },
174
+ ] }));
175
+ args.expire = expire === 'custom' ? await ask(text({ message: t('auth.duration'), validate: v => { try {
176
+ parseExpire(v);
177
+ }
178
+ catch (e) {
179
+ return e instanceof DeviceAuthError && e.code === 'INVALID_EXPIRE' ? t('auth.invalidDuration') : e.message;
180
+ } return undefined; } })) : expire;
181
+ }
182
+ if (needsPrompt && !await ask(confirm({ message: t('auth.confirm'), initialValue: true })))
183
+ throw canceled;
184
+ const result = await client.request({ op: 'authorize', id: claimed.id, name: args.name, expire: args.expire });
185
+ log('ID\tNAME\tSTATUS\tEXPIRES\tADDED\tLAST ACCESS\tBROWSER');
186
+ log(outputDevice(result));
187
+ return 0;
188
+ }
189
+ catch (error) {
190
+ if (error === canceled)
191
+ err(t('auth.canceled'));
192
+ else if (error instanceof AuthControlError || error instanceof DeviceAuthError) {
193
+ err(t(controlErrorCopy[error.code] ?? 'auth.error.controlFailed'));
194
+ }
195
+ else
196
+ err(error instanceof Error ? safeText(error.message) : t('auth.error.controlFailed'));
197
+ return 1;
198
+ }
199
+ finally {
200
+ client?.close();
201
+ }
202
+ }
@@ -0,0 +1,18 @@
1
+ import fs from 'node:fs';
2
+ import { configPath, readState, readSupervisorConfig, statePath, supervisorConfigPath } from './state.js';
3
+ // Only the CLI has installation evidence. Pure config mappers must not guess whether {} is a
4
+ // newcomer or an existing setup. Authentication policy itself is persisted by the server database.
5
+ export function installationAuthDefaults(home, target = configPath(home), flags = {}, env = process.env) {
6
+ const state = readState(home);
7
+ const supervisor = readSupervisorConfig(home);
8
+ const hasConfig = fs.existsSync(target) || fs.existsSync(configPath(home));
9
+ const hasHistory = fs.existsSync(statePath(home)) || fs.existsSync(supervisorConfigPath(home));
10
+ const explicitToken = flags.token != null || env.HANDMUX_TOKEN != null;
11
+ const isNew = !hasConfig && !hasHistory && !explicitToken;
12
+ const token = [state?.token, supervisor?.token].find((value) => typeof value === 'string' && value.length > 0);
13
+ return { isNew, ...(typeof token === 'string' ? { token } : {}) };
14
+ }
15
+ // Keep this shared by access output: foreground, background, and already-running startup paths.
16
+ export function tokenWarning(message, color = !!process.stdout.isTTY) {
17
+ return color ? `\u001b[33m${message}\u001b[39m` : message;
18
+ }
@@ -8,7 +8,7 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { homedir } from 'node:os';
10
10
  import { spawnSync } from 'node:child_process';
11
- import { writeJsonAtomic, deployHookScripts, removeHookScripts } from './hookScaffold.js';
11
+ import { writeJsonAtomic, deployHookScripts, removeHookScripts, shellWord } from './hookScaffold.js';
12
12
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
13
13
  const settingsOf = (value) => isRecord(value) ? { ...value } : {};
14
14
  const readSettings = (home) => {
@@ -135,7 +135,7 @@ function addHook(hooks, e, dest) {
135
135
  dropOurHook(hooks, e.event);
136
136
  const existing = hooks[e.event];
137
137
  const groups = hooks[e.event] = Array.isArray(existing) ? [...existing] : [];
138
- groups.push({ matcher: e.matcher || '', hooks: [{ type: 'command', command: `${dest} ${e.src}`, async: true, timeout: 5 }] });
138
+ groups.push({ matcher: e.matcher || '', hooks: [{ type: 'command', command: `${shellWord(dest)} ${e.src}`, async: true, timeout: 5 }] });
139
139
  }
140
140
  // Pure: return a NEW settings object with our six hooks merged into settings.hooks, idempotently, leaving
141
141
  // the user's own hooks and other keys untouched. `dest` is the absolute path to the copied notify script.
@@ -193,7 +193,7 @@ export function hooksStatus(home = homedir()) {
193
193
  }
194
194
  function hasExpectedHook(hooks, event, dest) {
195
195
  const groups = hooks[event.event];
196
- const command = `${dest} ${event.src}`;
196
+ const command = `${shellWord(dest)} ${event.src}`;
197
197
  return Array.isArray(groups) && groups.some((group) => (isRecord(group)
198
198
  && group.matcher === (event.matcher || '')
199
199
  && Array.isArray(group.hooks)
@@ -3,6 +3,9 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  export const HOOK_SCRIPTS = ['handmux-notify.sh', 'handmux-write.cjs'];
5
5
  const LEGACY_HOOK_SCRIPTS = ['handmux-codex-usage.cjs'];
6
+ // Env files are sourced by /bin/sh; settings commands use the same shell word syntax.
7
+ export const shellWord = (value) => /^[a-zA-Z0-9_./-]+$/.test(value)
8
+ ? value : `'${value.replace(/'/g, `'"'"'`)}'`;
6
9
  // Atomic write (tmp + rename) so a crash can't leave a half-written config file. Text in, text out — callers
7
10
  // pass raw TOML for config.toml, or use writeJsonAtomic for pretty-printed settings.json.
8
11
  export function writeFileAtomic(file, text) {
@@ -24,7 +27,8 @@ export function deployHookScripts(hooksDir, srcDir, stateFile) {
24
27
  }
25
28
  fs.chmodSync(path.join(hooksDir, 'handmux-notify.sh'), 0o755);
26
29
  fs.writeFileSync(path.join(hooksDir, 'handmux-notify.env'), [
27
- `HANDMUX_STATE=${stateFile}`,
30
+ `HANDMUX_STATE=${shellWord(stateFile)}`,
31
+ `HANDMUX_CLAUDE_EVENTS=${shellWord(`${stateFile}.events`)}`,
28
32
  '',
29
33
  ].join('\n'), { mode: 0o600 });
30
34
  }
@@ -1,6 +1,48 @@
1
1
  // English catalog (the fallback locale). Keys are grouped by command/area. `{var}` placeholders are filled
2
2
  // by translate(). Keep this in lockstep with zh.js — a missing zh key silently falls back to the line here.
3
3
  export default {
4
+ 'auth.section': 'Authentication & security',
5
+ 'auth.trusted': 'Device protection',
6
+ 'auth.token': 'Token login',
7
+ 'auth.manageHint': 'Use the CLI for protection switches; view and manage devices and access addresses in Web settings.',
8
+ 'auth.noDevices': 'No devices are authorized yet. Open Handmux in the target browser, then run handmux auth device add on this computer.',
9
+ 'auth.access': 'Device authorization: open Handmux in the target browser, then run handmux auth device add here and follow the prompts.',
10
+ 'auth.warning': '⚠ Trusted device protection is off: a Token alone is enough to access Handmux. We recommend running handmux auth device on to enable it.',
11
+ 'auth.addressWarning': '⚠ Trusted address protection is off: any address can attempt to connect. We recommend running handmux auth address on to enable it.',
12
+ 'auth.deviceDisableConfirm': '⚠ Turning off trusted device protection increases security risk: a Token alone will grant access. Confirm you want to turn it off?',
13
+ 'auth.addressDisableConfirm': '⚠ Turning off trusted address protection increases security risk: any address can attempt to connect. Confirm you want to turn it off?',
14
+ 'auth.code': 'Enter the 6-digit code shown in the target browser',
15
+ 'auth.codeInvalid': 'Enter exactly 6 digits.',
16
+ 'auth.claimed': 'The authorization request was found. Set a device name and duration within 5 minutes; the browser can sign in after you confirm.',
17
+ 'auth.origin': 'Origin',
18
+ 'auth.name': 'Device name',
19
+ 'auth.expire': 'Authorization duration',
20
+ 'auth.custom': 'Custom',
21
+ 'auth.never': 'No expiry',
22
+ 'auth.duration': 'Enter a duration such as 1h, 7d, 30d, or never',
23
+ 'auth.confirm': 'Authorize this browser?',
24
+ 'auth.canceled': 'This authorization was canceled.',
25
+ 'auth.safety': 'Only confirm the browser you are adding. Never enter a code sent by someone else.',
26
+ 'auth.invalidName': 'The device name must be 1–80 characters and contain no control characters.',
27
+ 'auth.invalidDuration': 'Enter a duration such as 1h, 7d, 30d, or never.',
28
+ 'auth.error.unavailable': 'Cannot reach the Handmux authorization service. Make sure Handmux is running, then try again.',
29
+ 'auth.error.invalidCommand': 'That authorization operation is invalid. Try again.',
30
+ 'auth.error.codeInvalid': 'The code is invalid, expired, or already used. Request a new code in the browser.',
31
+ 'auth.error.rateLimit': 'Too many code attempts. Try again later.',
32
+ 'auth.error.pairingGone': 'This authorization request no longer exists or has expired. Request a new code in the browser.',
33
+ 'auth.error.deviceNotFound': 'Device not found. Run handmux auth device list to check the device ID.',
34
+ 'auth.error.deviceInactive': 'This device has expired or been unbound. Authorize it again.',
35
+ 'auth.error.conflict': 'The device was changed by another operation. List devices again, then retry.',
36
+ 'auth.error.invalidEdit': 'Provide at least --name or --expire.',
37
+ 'auth.error.invalidVersion': 'The device was updated. List devices again, then retry.',
38
+ 'auth.error.pairingCapacity': 'There are too many pending authorization requests. Try again later.',
39
+ 'auth.error.tokenRequired': 'Provide the Token before starting authorization.',
40
+ 'auth.error.deviceRequired': 'Authorize this browser before enabling device protection.',
41
+ 'auth.error.expiryCliOnly': 'Change device expiry with the handmux CLI on the server.',
42
+ 'auth.error.invalidOrigin': 'Invalid access address. Provide a complete http(s) URL.',
43
+ 'auth.error.originLimit': 'The trusted access-address limit has been reached. Remove one before adding another.',
44
+ 'auth.error.sessionInvalid': 'This device authorization is no longer valid. Pair it again.',
45
+ 'auth.error.controlFailed': 'The authorization operation did not complete. Check Handmux status, then try again.',
4
46
  // generic
5
47
  'err.generic': '✗ {msg}',
6
48
  'err.configNotFound': '✗ --config {path}: not found',
@@ -108,7 +150,7 @@ export default {
108
150
  'access.pending': '(pending…)',
109
151
  'access.lan': ' 📶 lan {url}',
110
152
  'access.local': ' 💻 local {url}',
111
- 'access.token': ' 🔑 token {token}',
153
+ 'access.token': ' 🔑 Token login {token}',
112
154
  'access.reachable': ' ✓ reachable',
113
155
  'access.unreachable': ' ⚠ tunnel up but {url} did not answer — check the server-side reverse proxy / DNS',
114
156
  'access.hint': ' handmux status | stop',
@@ -179,6 +221,7 @@ export default {
179
221
  'setup.askSshHost': 'ssh host (user@host[:port])',
180
222
  'setup.askRemotePort': 'remote port on the ssh host',
181
223
  'setup.askPublicUrl': 'public url — http(s):// as appropriate (blank = http://host:remotePort)',
224
+ 'setup.askDirectPublicUrl': 'custom access URL (your own reverse tunnel or proxy; blank = local/LAN direct only)',
182
225
  'setup.natappGuide': 'Where to get the authtoken: register free at https://natapp.cn → create a tunnel → copy its authtoken (the free tier is enough to start).',
183
226
  'setup.cpolarGuide': 'Where to get the authtoken: register free at https://cpolar.com → open the dashboard → Verify → copy your authtoken.',
184
227
  'setup.askAuthtoken': 'authtoken',
@@ -194,19 +237,19 @@ export default {
194
237
  'setup.secConnection': 'Connection',
195
238
  'setup.secName': 'Name',
196
239
  'setup.secPort': 'Port',
197
- 'setup.secToken': 'Token',
240
+ 'setup.secToken': 'Token login',
198
241
  'setup.secBrowser': 'Web preview',
199
242
  'setup.browserOff': 'not configured · Direct mode only',
200
243
  'setup.askBrowserDomain': 'Web preview proxy domain (blank = Direct mode only)',
201
244
  'setup.browserAbout': 'Enter a proxy domain (for example, preview.example.com) and route its wildcard subdomains to Handmux over HTTPS; leave it blank for Direct mode only.',
202
- 'setup.tokenAuto': 'auto · new each start',
203
- 'setup.tokenCustom': 'Set a custom token…',
204
- 'setup.tokenRandom': 'Generate a random one',
205
- 'setup.tokenReset': 'Reset to auto (new each start)',
206
- 'setup.askToken': 'Access token — it appears in the URL you open on the phone',
207
- 'setup.tokenGenerated': 'New token: {token}',
208
- 'setup.valToken': 'enter a token',
209
- 'setup.valTokenSpace': 'no spaces — the token goes in a URL',
245
+ 'setup.tokenAuto': 'saved (the Token does not rotate automatically)',
246
+ 'setup.tokenCustom': 'Change the Token…',
247
+ 'setup.tokenRandom': 'Generate and save a new random Token',
248
+ 'setup.tokenReset': 'The Token does not rotate automatically',
249
+ 'setup.askToken': 'Token — required together with a trusted device',
250
+ 'setup.tokenGenerated': 'New Token: {token}',
251
+ 'setup.valToken': 'enter the Token',
252
+ 'setup.valTokenSpace': 'no spaces — enter the Token separately on the login page',
210
253
  'setup.secLanguage': 'CLI language / 命令行语言',
211
254
  'setup.secPush': 'Push',
212
255
  'setup.secVoice': 'Voice',
@@ -236,6 +279,7 @@ export default {
236
279
  'setup.valRequired': '{label} is required',
237
280
  'setup.valHost': 'enter a valid domain (e.g. myapp.example.com)',
238
281
  'setup.valPreviewDomain': 'enter only a domain, e.g. preview.example.com; do not include http://, https://, a port, or *.',
282
+ 'setup.valPublicUrl': 'enter a complete http(s) origin such as https://handmux.example.com (no path or credentials)',
239
283
  'setup.valContact': 'use mailto:you@example.com or https://your.site (a real domain — Apple rejects fake ones)',
240
284
  'setup.sumTemp': 'temporary',
241
285
  'setup.sumFixed': 'fixed',
@@ -248,6 +292,7 @@ export default {
248
292
  'setup.connSshHost': 'SSH host',
249
293
  'setup.connRemotePort': 'Remote port',
250
294
  'setup.connPublicUrl': 'Public URL',
295
+ 'setup.connDirectAuto': '(blank = local / LAN direct)',
251
296
  'setup.connJump': 'Jump host',
252
297
  'setup.connDomain': 'Domain',
253
298
  'setup.connRegion': 'Region',
@@ -357,6 +402,16 @@ export default {
357
402
  handmux stop | restart | status
358
403
  handmux logs [--follow] [--lines N]
359
404
  handmux push <title> <body> notify your phone from a script (--session X · --device K · --tag T · --url U)
405
+ handmux auth device status show device protection status and devices
406
+ handmux auth device on|off turn device protection on or off
407
+ handmux auth device add interactively authorize a browser (automation uses --code, --name and --expire)
408
+ handmux auth device list list full device IDs, names, expiry and access times
409
+ handmux auth device edit <id> update the device name or expiry
410
+ handmux auth device revoke <id> revoke a device immediately
411
+ handmux auth address status show trusted address protection status and addresses
412
+ handmux auth address on|off turn trusted address protection on or off
413
+ handmux auth address add <origin> add an access address
414
+ handmux auth address remove <origin> remove an access address
360
415
  handmux codex [args...] launch a Codex TUI synchronized with chat view
361
416
  handmux pi [args...] launch Pi with its arguments unchanged
362
417
  handmux agent [list] show supported Agent integrations
@@ -384,7 +439,7 @@ start flags (matching env var in parens):
384
439
  --tunnel none|cloudflare|cloudflare-named|ssh|natapp|cpolar expose method (default: none)
385
440
  --port N server port (HANDMUX_PORT, default: 19999)
386
441
  --host H bind host (HANDMUX_HOST, default: 0.0.0.0)
387
- --token S auth token (HANDMUX_TOKEN, default: generated each start)
442
+ --token S auth token (HANDMUX_TOKEN, default: reuse existing or generate)
388
443
  --name "My Box" app name in the browser tab + home-screen icon (HANDMUX_APP_NAME)
389
444
  --public-url URL public url to advertise (HANDMUX_PUBLIC_URL; any tunnel, incl. none if
390
445
  you run your own; ssh defaults to http://host:remotePort; for