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
@@ -460,13 +460,22 @@ function turnPrompt(turn) {
460
460
  }
461
461
  return '';
462
462
  }
463
+ function currentTurnId(state, remembered = state?.activeTurnId) {
464
+ // Native history can retain unfinished internal turns after the thread is idle (for example /review).
465
+ // Historical turn records must never override the authoritative current thread lifecycle.
466
+ if ((state?.status || state?.thread?.status)?.type === 'idle')
467
+ return null;
468
+ return remembered || [...(state?.thread?.turns || [])].reverse()
469
+ .find((turn) => turn.status === 'inProgress')?.id || null;
470
+ }
463
471
  function activeTurnPrompt(state) {
472
+ if ((state?.status || state?.thread?.status)?.type === 'idle')
473
+ return '';
474
+ const turnId = currentTurnId(state);
464
475
  if (state?.activePrompt)
465
476
  return state.activePrompt;
466
477
  const turns = Array.isArray(state?.thread?.turns) ? state.thread.turns : [];
467
- const active = state?.activeTurnId
468
- ? turns.find((turn) => turn.id === state.activeTurnId)
469
- : [...turns].reverse().find((turn) => turn.status === 'inProgress');
478
+ const active = turns.find((turn) => turn.id === turnId);
470
479
  return turnPrompt(active);
471
480
  }
472
481
  function activeKind(status) {
@@ -867,6 +876,10 @@ class CodexAppConnection {
867
876
  else if (message.method === 'thread/status/changed') {
868
877
  const state = this.state(params.threadId);
869
878
  state.status = params.status ?? null;
879
+ if (state.status?.type === 'idle') {
880
+ state.activeTurnId = null;
881
+ state.activePrompt = '';
882
+ }
870
883
  const kind = activeKind(params.status);
871
884
  if (!this.isCurrentThread(params.threadId)) {
872
885
  /* retain the state for that thread, but never let a late background event rebind this pane */
@@ -931,11 +944,10 @@ class CodexAppConnection {
931
944
  // Native Goal notifications do not consistently carry turnId. Bind every live lifecycle transition
932
945
  // to the turn that is currently producing it so an active Goal set before the first item event cannot
933
946
  // later mistake that same turn for its next durable injection opportunity.
934
- const state = this.state(params.threadId);
935
947
  const notifiedGoal = parseCodexGoal(params.goal);
936
948
  const pendingTurnId = this.pendingGoalTurn(params.threadId);
937
949
  const turnId = params.turnId
938
- || (pendingTurnId !== undefined ? pendingTurnId : state.activeTurnId)
950
+ || (pendingTurnId !== undefined ? pendingTurnId : this.activeTurn(params.threadId))
939
951
  || null;
940
952
  this.applyGoalSnapshot(params.threadId, notifiedGoal, turnId, { emitClear: true });
941
953
  }
@@ -1349,9 +1361,7 @@ class CodexAppConnection {
1349
1361
  }
1350
1362
  activeTurn(threadId) {
1351
1363
  const state = this.state(threadId);
1352
- return state.activeTurnId
1353
- || [...(state.thread?.turns || [])].reverse().find((turn) => turn.status === 'inProgress')?.id
1354
- || null;
1364
+ return currentTurnId(state);
1355
1365
  }
1356
1366
  beginGoalMutation(threadId) {
1357
1367
  const mutation = { token: Symbol('goal-mutation'), turnId: this.activeTurn(threadId) };
@@ -2356,8 +2366,7 @@ class CodexAppConnection {
2356
2366
  state.status = resumedThread?.status || state.status;
2357
2367
  const previousActiveTurnId = state.activeTurnId;
2358
2368
  const previousActivePrompt = state.activePrompt;
2359
- state.activeTurnId = [...(resumedThread?.turns || [])].reverse()
2360
- .find((turn) => turn.status === 'inProgress')?.id || null;
2369
+ state.activeTurnId = currentTurnId(state, null);
2361
2370
  const resumedPrompt = state.activeTurnId
2362
2371
  ? turnPrompt((resumedThread?.turns || []).find((turn) => turn.id === state.activeTurnId))
2363
2372
  : '';
@@ -2439,7 +2448,8 @@ class CodexAppConnection {
2439
2448
  // base snapshot still applies, and the cache now represents that current combined projection.
2440
2449
  state.readRevision = state.revision;
2441
2450
  state.status = fresh?.status || state.status;
2442
- const active = [...(state.thread?.turns || [])].reverse().find((turn) => turn.status === 'inProgress');
2451
+ const activeId = currentTurnId(state, null);
2452
+ const active = state.thread?.turns?.find((turn) => turn.id === activeId);
2443
2453
  const previousActiveTurnId = state.activeTurnId;
2444
2454
  const previousActivePrompt = state.activePrompt;
2445
2455
  state.activeTurnId = active?.id || null;
@@ -3418,12 +3428,12 @@ export function createCodexAppServer({ home, connect = connectUnixWebSocket, exi
3418
3428
  if (!client)
3419
3429
  throw new Error('Codex session is not managed by Handmux');
3420
3430
  await client.assertCurrentThread(threadId);
3421
- const state = await client.ensureThread(threadId);
3422
- let turnId = state.activeTurnId;
3431
+ await client.ensureThread(threadId);
3432
+ let turnId = client.activeTurn(threadId);
3423
3433
  if (!turnId) {
3424
- const thread = await client.readThread(threadId);
3434
+ await client.readThread(threadId);
3425
3435
  client.requireCurrentThread(threadId);
3426
- turnId = [...(thread?.turns || [])].reverse().find((turn) => turn.status === 'inProgress')?.id || null;
3436
+ turnId = client.activeTurn(threadId);
3427
3437
  }
3428
3438
  if (!turnId)
3429
3439
  return { interrupted: false };
@@ -376,6 +376,36 @@ function applyPatchCalls(patch) {
376
376
  };
377
377
  });
378
378
  }
379
+ const isImageViewTool = (name) => name === 'view_image' || name === 'functions.view_image';
380
+ // Only literal path/cwd fields are evidence of a file location. The generic source parser deliberately
381
+ // keeps unknown JavaScript expressions as strings, which must never become download paths.
382
+ function imageToolInput(script, raw, before) {
383
+ const input = asRecord(parseToolInput(script, raw, before));
384
+ delete input.path;
385
+ delete input.cwd;
386
+ const value = raw.trim();
387
+ if (!value.startsWith('{') || !value.endsWith('}'))
388
+ return input;
389
+ for (const field of splitTopLevel(value.slice(1, -1), ',')) {
390
+ if (/^(\.\.\.|\[|get\s|set\s)/.test(field.trim())) {
391
+ delete input.path;
392
+ delete input.cwd;
393
+ continue;
394
+ }
395
+ const pieces = splitTopLevel(field, ':');
396
+ const key = pieces.shift()?.trim() || '';
397
+ const name = decodeJsString(key) ?? key;
398
+ if (name !== 'path' && name !== 'cwd')
399
+ continue;
400
+ const expression = pieces.join(':').trim();
401
+ const literal = stringTokenAt(expression, 0) === expression ? decodeJsString(expression) : null;
402
+ if (literal !== null)
403
+ input[name] = literal;
404
+ else
405
+ delete input[name];
406
+ }
407
+ return input;
408
+ }
379
409
  function extractCustomCalls(script) {
380
410
  const calls = [];
381
411
  const re = /tools\.([A-Za-z][\w]*)\s*\(/g;
@@ -394,7 +424,8 @@ function extractCustomCalls(script) {
394
424
  : applyPatchCalls(decoded)));
395
425
  }
396
426
  else {
397
- calls.push({ name, input: parseToolInput(script, arg.text, match.index) });
427
+ calls.push({ name, input: isImageViewTool(name)
428
+ ? imageToolInput(script, arg.text, match.index) : parseToolInput(script, arg.text, match.index) });
398
429
  }
399
430
  re.lastIndex = arg.end;
400
431
  }
@@ -595,6 +626,14 @@ export function createCodexTranscriptParser() {
595
626
  const ts = typeof row.timestamp === 'string' ? row.timestamp : undefined;
596
627
  if (row.type === 'event_msg') {
597
628
  const payload = asRecord(row.payload);
629
+ const payloadError = asRecord(payload.error);
630
+ if (payload.type === 'task_complete' && typeof payloadError.message === 'string'
631
+ && payloadError.message.trim()) {
632
+ messages.push({
633
+ i, type: 'notice', noticeLevel: 'warning', text: payloadError.message.trim(), ts,
634
+ ...(typeof payload.turn_id === 'string' && payload.turn_id ? { turnId: payload.turn_id } : {}),
635
+ });
636
+ }
598
637
  adjacentUserClientId = payload.type === 'user_message'
599
638
  && typeof payload.client_id === 'string' && payload.client_id
600
639
  ? payload.client_id : undefined;
@@ -45,7 +45,7 @@ function normalize(record) {
45
45
  const rawLimits = isRecord(payload.rate_limits) ? payload.rate_limits : {};
46
46
  // A rollout may carry a model-specific limit (for example GPT-5.3-Codex-Spark's
47
47
  // `codex_bengalfox`) instead of the account's main Codex limit. Keep its token/context data, but never
48
- // project that independent percentage as the generic Codex quota shown by HandMux.
48
+ // project that independent percentage as the generic Codex quota shown by handmux.
49
49
  const limitId = typeof rawLimits.limit_id === 'string' ? rawLimits.limit_id : null;
50
50
  const limits = limitId === null || limitId === 'codex' ? rawLimits : {};
51
51
  return {
@@ -0,0 +1,134 @@
1
+ import { networkInterfaces } from 'node:os';
2
+ import { originPatternMatches } from './deviceAuth/service.js';
3
+ import { readSessionSecret } from './deviceAuth/service.js';
4
+ import { setSessionCookie } from './deviceAuth/http.js';
5
+ import { withRequestAuthority } from './requestAuthority.js';
6
+ import { bearerFrom } from './auth.js';
7
+ import { requestOrigin } from './requestOrigin.js';
8
+ /** Host and forwarded headers select a known entry point; they never create a trusted origin. */
9
+ export function createAuthOriginResolver({ port, host, publicUrl, previewDomain, trustedOrigins = () => [], runtimePublicUrl = () => null, trustedOriginEnabled = () => true, }) {
10
+ const local = new Set();
11
+ const add = (hostname) => {
12
+ if (!hostname || hostname === '0.0.0.0' || hostname === '::')
13
+ return;
14
+ try {
15
+ local.add(new URL(`http://${hostname.includes(':') ? `[${hostname}]` : hostname}:${port}`).origin);
16
+ }
17
+ catch { }
18
+ };
19
+ for (const hostname of ['localhost', '127.0.0.1', '::1', host])
20
+ add(hostname);
21
+ const configuredPatterns = [];
22
+ if (previewDomain) {
23
+ try {
24
+ const url = new URL(/^https?:\/\//i.test(previewDomain) ? previewDomain : `https://${previewDomain}`);
25
+ if (url.hostname && !url.hostname.includes('*'))
26
+ configuredPatterns.push(`https://*.${url.hostname}`);
27
+ }
28
+ catch { /* an invalid preview domain is rejected by its own setup validation */ }
29
+ }
30
+ for (const entries of Object.values(networkInterfaces())) {
31
+ for (const entry of entries ?? [])
32
+ if (!entry.address.includes('%'))
33
+ add(entry.address);
34
+ }
35
+ return (req) => {
36
+ const origin = requestOrigin(req);
37
+ if (!origin)
38
+ return null;
39
+ // Origin restriction is an independent policy. Even when disabled, return the
40
+ // syntactically valid request origin so authentication can proceed with Token
41
+ // (and, when enabled, a trusted-device cookie).
42
+ if (!trustedOriginEnabled())
43
+ return origin;
44
+ if (local.has(origin))
45
+ return origin;
46
+ for (const known of [publicUrl, runtimePublicUrl()]) {
47
+ if (!known)
48
+ continue;
49
+ try {
50
+ const url = new URL(known);
51
+ if (!url.username && !url.password && ['http:', 'https:'].includes(url.protocol) && url.origin === origin)
52
+ return origin;
53
+ }
54
+ catch { }
55
+ }
56
+ for (const pattern of [...configuredPatterns, ...trustedOrigins()]) {
57
+ if (!pattern)
58
+ continue;
59
+ if (originPatternMatches(pattern, origin))
60
+ return origin;
61
+ }
62
+ return null;
63
+ };
64
+ }
65
+ export function createDeviceAccess({ service, resolveOrigin }) {
66
+ const responses = new Map();
67
+ const stop = service.onRevoke((deviceId) => {
68
+ for (const res of responses.get(deviceId) ?? [])
69
+ res.destroy();
70
+ responses.delete(deviceId);
71
+ });
72
+ const authenticate = (req) => {
73
+ const origin = resolveOrigin(req);
74
+ if (!origin)
75
+ return null;
76
+ const device = service.authenticateRequest(req, origin);
77
+ const token = service.authenticateToken?.(bearerFrom(typeof req.headers.authorization === 'string' ? req.headers.authorization : undefined)
78
+ ?? req.headers['x-handmux-token'], origin) ?? null;
79
+ if (!token)
80
+ return null;
81
+ return service.trustedDeviceEnabled === false ? token : device && token ? device : null;
82
+ };
83
+ const middleware = (req, res, next) => {
84
+ res.setHeader('Cache-Control', 'no-store');
85
+ if (service.available === false) {
86
+ res.status(503).json({ error: 'authentication service unavailable', code: 'auth_unavailable' });
87
+ return;
88
+ }
89
+ const origin = resolveOrigin(req);
90
+ if (!origin || (req.headers.origin !== undefined && req.headers.origin !== origin)) {
91
+ res.status(403).json({ error: 'untrusted request origin', code: 'origin_rejected' });
92
+ return;
93
+ }
94
+ let principal;
95
+ try {
96
+ principal = authenticate(req);
97
+ }
98
+ catch {
99
+ res.status(503).json({ error: 'authentication storage unavailable', code: 'auth_unavailable' });
100
+ return;
101
+ }
102
+ if (!principal) {
103
+ res.status(401).json({ error: 'unauthorized', code: 'auth_required' });
104
+ return;
105
+ }
106
+ res.locals.deviceAuth = principal;
107
+ let accepted = false;
108
+ const assertActive = () => {
109
+ if (!accepted && !service.isActive(principal)) {
110
+ throw Object.assign(new Error('Device authorization is no longer active'), { status: 401, code: 'auth_required' });
111
+ }
112
+ };
113
+ // Accepted Agent/background jobs may continue after logout; not-yet-issued HTTP work may not.
114
+ // Destroy/abort never marks a request accepted.
115
+ res.once('finish', () => { if (res.statusCode >= 200 && res.statusCode < 300)
116
+ accepted = true; });
117
+ res.locals.assertDeviceActive = assertActive;
118
+ const secret = readSessionSecret(req, origin);
119
+ if (secret && !principal.deviceId.startsWith('token_'))
120
+ setSessionCookie(res, origin, secret, principal.expiresAt);
121
+ const group = responses.get(principal.deviceId) ?? new Set();
122
+ group.add(res);
123
+ responses.set(principal.deviceId, group);
124
+ const cleanup = () => {
125
+ group.delete(res);
126
+ if (group.size === 0)
127
+ responses.delete(principal.deviceId);
128
+ };
129
+ res.once('finish', cleanup);
130
+ res.once('close', cleanup);
131
+ withRequestAuthority(assertActive, next);
132
+ };
133
+ return { middleware, authenticate, close: stop };
134
+ }
@@ -0,0 +1,154 @@
1
+ import net from 'node:net';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { DeviceAuthError } from './service.js';
6
+ export const authSocketPath = (home) => path.join(home, '.handmux', 'auth.sock');
7
+ export class AuthControlError extends Error {
8
+ code;
9
+ constructor(code, message) {
10
+ super(message);
11
+ this.code = code;
12
+ this.name = 'AuthControlError';
13
+ }
14
+ }
15
+ export async function startDeviceAuthControl({ service, home, handlePush, handleShortcuts }) {
16
+ const socketPath = authSocketPath(home);
17
+ await fs.mkdir(path.dirname(socketPath), { recursive: true, mode: 0o700 });
18
+ await fs.chmod(path.dirname(socketPath), 0o700);
19
+ try {
20
+ const existing = await fs.lstat(socketPath);
21
+ if (!existing.isSocket())
22
+ throw new Error('Auth control path is not a socket; refusing to replace it');
23
+ // The shared DB single-instance lock must already be acquired by the caller.
24
+ await fs.unlink(socketPath);
25
+ }
26
+ catch (error) {
27
+ if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'))
28
+ throw error;
29
+ }
30
+ const sockets = new Set();
31
+ const server = net.createServer(socket => {
32
+ const owner = randomUUID();
33
+ let buffer = '';
34
+ let chain = Promise.resolve();
35
+ sockets.add(socket);
36
+ socket.setTimeout(360_000, () => socket.destroy());
37
+ socket.on('close', () => { sockets.delete(socket); service.cancelOwner(owner); });
38
+ socket.on('error', () => { });
39
+ socket.on('data', bytes => {
40
+ buffer += bytes.toString('utf8');
41
+ if (Buffer.byteLength(buffer) > 16_384) {
42
+ socket.destroy();
43
+ return;
44
+ }
45
+ let newline;
46
+ while ((newline = buffer.indexOf('\n')) >= 0) {
47
+ const line = buffer.slice(0, newline);
48
+ buffer = buffer.slice(newline + 1);
49
+ chain = chain.then(async () => {
50
+ if (socket.destroyed)
51
+ return;
52
+ try {
53
+ const input = JSON.parse(line);
54
+ if (!input || typeof input !== 'object')
55
+ throw new DeviceAuthError('INVALID_COMMAND', 'Invalid control command');
56
+ const args = input;
57
+ let result;
58
+ if (args.op === 'claim')
59
+ result = service.claim(args.code, owner);
60
+ else if (args.op === 'authorize')
61
+ result = service.authorize(String(args.id ?? ''), owner, { name: args.name, expire: args.expire });
62
+ else if (args.op === 'cancel') {
63
+ service.cancelOwner(owner);
64
+ result = { ok: true };
65
+ }
66
+ else if (args.op === 'device-status')
67
+ result = { enabled: service.trustedDeviceEnabled, devices: service.list() };
68
+ else if (args.op === 'device-policy') {
69
+ service.setTrustedDeviceEnabled(args.enabled === true);
70
+ result = { enabled: service.trustedDeviceEnabled };
71
+ }
72
+ else if (args.op === 'device-list')
73
+ result = service.list();
74
+ else if (args.op === 'device-edit')
75
+ result = service.edit(String(args.id ?? ''), { name: args.name, expire: args.expire });
76
+ else if (args.op === 'device-revoke')
77
+ result = service.revoke(String(args.id ?? ''));
78
+ else if (args.op === 'address-status')
79
+ result = { enabled: service.trustedOriginEnabled, origins: service.trustedOrigins };
80
+ else if (args.op === 'address-policy') {
81
+ service.setTrustedOriginEnabled(args.enabled === true);
82
+ result = { enabled: service.trustedOriginEnabled };
83
+ }
84
+ else if (args.op === 'address-add')
85
+ result = { origins: service.addTrustedOrigin(args.origin) };
86
+ else if (args.op === 'address-remove')
87
+ result = { origins: service.removeTrustedOrigin(args.origin) };
88
+ else if (args.op === 'push' && handlePush)
89
+ result = await handlePush(args.body);
90
+ else if (args.op === 'shortcuts' && handleShortcuts)
91
+ result = await handleShortcuts(args.body);
92
+ else
93
+ throw new DeviceAuthError('INVALID_COMMAND', 'Unknown control command');
94
+ if (!socket.destroyed)
95
+ socket.write(`${JSON.stringify({ ok: true, result })}\n`);
96
+ }
97
+ catch (error) {
98
+ if (!socket.destroyed)
99
+ socket.write(`${JSON.stringify({ ok: false, error: error instanceof DeviceAuthError ? error.code : 'AUTH_UNAVAILABLE', message: error instanceof DeviceAuthError ? error.message : 'Authentication operation unavailable; retry or restart handmux' })}\n`);
100
+ }
101
+ });
102
+ }
103
+ });
104
+ });
105
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, () => { server.off('error', reject); resolve(); }); });
106
+ await fs.chmod(socketPath, 0o600);
107
+ return { close: async () => { for (const socket of sockets)
108
+ socket.destroy(); await new Promise(resolve => server.close(() => resolve())); await fs.unlink(socketPath).catch(() => { }); } };
109
+ }
110
+ export async function connectAuthControl(home) {
111
+ const socket = net.createConnection(authSocketPath(home));
112
+ let buffer = '';
113
+ let terminalError;
114
+ let pending;
115
+ const fail = (error) => { terminalError = error; pending?.reject(error); pending = undefined; };
116
+ socket.on('error', fail);
117
+ socket.on('close', () => fail(new Error('handmux control connection closed; start handmux and retry')));
118
+ socket.setTimeout(360_000, () => socket.destroy(new Error('Device setup timed out; pair again')));
119
+ socket.on('data', bytes => {
120
+ buffer += bytes.toString('utf8');
121
+ if (buffer.length > 4_000_000) {
122
+ socket.destroy(new Error('Control response exceeds limit'));
123
+ return;
124
+ }
125
+ const newline = buffer.indexOf('\n');
126
+ if (newline < 0)
127
+ return;
128
+ const line = buffer.slice(0, newline);
129
+ buffer = buffer.slice(newline + 1);
130
+ try {
131
+ const out = JSON.parse(line);
132
+ const p = pending;
133
+ pending = undefined;
134
+ if (out.ok)
135
+ p?.resolve(out.result);
136
+ else
137
+ p?.reject(new AuthControlError(out.error ?? 'AUTH_UNAVAILABLE', out.message ?? 'Authentication command unavailable; retry or restart handmux'));
138
+ }
139
+ catch {
140
+ fail(new Error('Invalid handmux control response'));
141
+ }
142
+ });
143
+ await new Promise((resolve, reject) => { socket.once('connect', resolve); socket.once('error', reject); });
144
+ return {
145
+ request(args) {
146
+ if (terminalError || socket.destroyed || !socket.writable)
147
+ return Promise.reject(terminalError ?? new Error('handmux control connection closed; start handmux and pair again'));
148
+ if (pending)
149
+ return Promise.reject(new Error('Control command already pending'));
150
+ return new Promise((resolve, reject) => { pending = { resolve, reject }; socket.write(`${JSON.stringify(args)}\n`); });
151
+ },
152
+ close() { socket.destroy(); },
153
+ };
154
+ }