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
@@ -15,7 +15,8 @@ export function previewRoutes({ previews }) {
15
15
  if (typeof name !== 'string' || !name || typeof dir !== 'string' || !dir)
16
16
  return res.status(400).json({ error: 'bad request' });
17
17
  try {
18
- const out = await previews.register({ name, dir });
18
+ const deviceId = res.locals.deviceAuth?.deviceId;
19
+ const out = await (deviceId ? previews.register({ name, dir }, deviceId) : previews.register({ name, dir }));
19
20
  if ('error' in out)
20
21
  return res.status(out.status).json({ error: out.error });
21
22
  const url = `/preview/${encodeURIComponent(out.name)}/${encodeURIComponent(out.accessToken)}/`;
@@ -28,7 +29,8 @@ export function previewRoutes({ previews }) {
28
29
  r.get('/previews', (_req, res) => {
29
30
  if (!previews)
30
31
  return res.status(503).json({ error: 'previews disabled' });
31
- return res.json({ previews: previews.list() });
32
+ const deviceId = res.locals.deviceAuth?.deviceId;
33
+ return res.json({ previews: deviceId ? previews.list(deviceId) : previews.list() });
32
34
  });
33
35
  r.delete('/previews/:name', (req, res) => {
34
36
  if (!previews)
@@ -36,7 +38,11 @@ export function previewRoutes({ previews }) {
36
38
  const name = req.params.name;
37
39
  if (!name || !safePreviewName(name))
38
40
  return res.status(400).json({ error: 'bad name' });
39
- previews.remove(name);
41
+ const deviceId = res.locals.deviceAuth?.deviceId;
42
+ if (deviceId)
43
+ previews.remove(name, deviceId);
44
+ else
45
+ previews.remove(name);
40
46
  return res.status(204).end();
41
47
  });
42
48
  return r;
@@ -8,7 +8,52 @@ const strings = (value) => Array.isArray(value)
8
8
  ? value.filter((item) => typeof item === 'string' && item.length > 0)
9
9
  : [];
10
10
  const errorMessage = (error) => error instanceof Error ? error.message : String(error);
11
- export function pushRoutes({ push, notifications }) {
11
+ // Shared by the legacy route and private Unix control socket. Never grant general HTTP access to CLI.
12
+ export async function sendLocalPush({ push, notifications }, requestBody) {
13
+ const data = isRecord(requestBody) ? requestBody : {};
14
+ const { title, body, tag, url } = data;
15
+ const invalid = (message) => { throw Object.assign(new Error(message), { status: 400 }); };
16
+ if (typeof title !== 'string' || !title.trim())
17
+ return invalid('title required');
18
+ if (typeof body !== 'string' || !body.trim())
19
+ return invalid('body required');
20
+ const safeUrl = url == null ? null : sanitizeNotificationUrl(url);
21
+ if (url != null && !safeUrl)
22
+ return invalid('url must be http(s) or relative');
23
+ const sessions = strings(data.sessions);
24
+ const devices = strings(data.devices);
25
+ if (sessions.length && devices.length)
26
+ return invalid('use --session or --device, not both');
27
+ const payload = { title, body };
28
+ if (typeof tag === 'string' && tag)
29
+ payload.tag = tag;
30
+ const opts = { urgency: 'normal', ttl: 1800 };
31
+ if (typeof payload.tag === 'string')
32
+ opts.topic = payload.tag;
33
+ let rec = null;
34
+ if (notifications) {
35
+ const targetKeys = push.resolveTargetKeys({ devices: devices.length ? devices : null, sessions: sessions.length ? sessions : null });
36
+ rec = notifications.record(targetKeys, { title, body, tag: payload.tag, url: safeUrl, delivery: { status: 'pending' } });
37
+ payload.data = { inboxId: rec.id };
38
+ }
39
+ const out = devices.length ? await push.sendToDevices(devices, payload, opts)
40
+ : sessions.length ? await push.sendToSessions(sessions, payload, opts) : await push.sendToAll(payload, opts);
41
+ if (notifications && rec) {
42
+ for (const delivery of out.deliveries || []) {
43
+ if (!delivery.pushKey)
44
+ continue;
45
+ try {
46
+ notifications.updateDelivery(delivery.pushKey, rec.id, delivery);
47
+ }
48
+ catch (error) {
49
+ console.warn(`[handmux] notification delivery status update failed: ${errorMessage(error)}`);
50
+ }
51
+ }
52
+ }
53
+ const { deliveries: _deliveries, ...summary } = out;
54
+ return summary;
55
+ }
56
+ export function pushRoutes({ push, notifications, deviceAuth = false }) {
12
57
  const r = express.Router();
13
58
  // The client needs the VAPID public key to subscribe; 503 if the server has no keys configured.
14
59
  r.get('/push/vapid', (_req, res) => {
@@ -26,7 +71,7 @@ export function pushRoutes({ push, notifications }) {
26
71
  if (!sub)
27
72
  return res.status(400).json({ error: 'bad subscription' });
28
73
  try {
29
- const pushKey = push.addSubscription(sub, boundSessions, preferredPushKey);
74
+ const pushKey = push.addSubscription(sub, boundSessions, preferredPushKey, res.locals.deviceAuth?.deviceId);
30
75
  if (!pushKey)
31
76
  return res.status(400).json({ error: 'bad subscription' });
32
77
  const delivery = await push.sendToOne(sub, { title: '通知已开启 ✅', body: '会话「需要你」或「已完成」时提醒你', tag: 'handmux-welcome' }, { topic: 'handmux', urgency: 'high' });
@@ -48,7 +93,7 @@ export function pushRoutes({ push, notifications }) {
48
93
  r.post('/push/unsubscribe', (req, res) => {
49
94
  const body = req.body;
50
95
  const endpoint = isRecord(body) ? body.endpoint : undefined;
51
- const pushKey = typeof endpoint === 'string' ? push.removeSubscription(endpoint) : null;
96
+ const pushKey = typeof endpoint === 'string' ? push.removeSubscription(endpoint, res.locals.deviceAuth?.deviceId) : null;
52
97
  return res.json({ ok: true, pushKey });
53
98
  });
54
99
  // Manual "send me a test" — pushes to every stored subscription.
@@ -68,68 +113,20 @@ export function pushRoutes({ push, notifications }) {
68
113
  const endpoint = isRecord(requestBody) ? requestBody.endpoint : undefined;
69
114
  const boundSessions = strings(isRecord(requestBody) ? requestBody.boundSessions : undefined);
70
115
  if (typeof endpoint === 'string')
71
- push.updateBound(endpoint, boundSessions);
116
+ push.updateBound(endpoint, boundSessions, res.locals.deviceAuth?.deviceId);
72
117
  return res.json({ ok: true });
73
118
  });
74
119
  // Local script push (`handmux push`): loopback + server token. Scope is mutually exclusive —
75
120
  // devices (by pushKey) > sessions > all. This is the ONLY push-send entry; no public/remote variant.
76
121
  r.post('/push/send-local', async (req, res, next) => {
77
- const requestBody = req.body;
78
- const data = isRecord(requestBody) ? requestBody : {};
79
- const { title, body, tag, url } = data;
80
- if (typeof title !== 'string' || !title.trim())
81
- return res.status(400).json({ error: 'title required' });
82
- if (typeof body !== 'string' || !body.trim())
83
- return res.status(400).json({ error: 'body required' });
84
- const safeUrl = url == null ? null : sanitizeNotificationUrl(url);
85
- if (url != null && !safeUrl)
86
- return res.status(400).json({ error: 'url must be http(s) or relative' });
87
- const sessions = strings(data.sessions);
88
- const devices = strings(data.devices);
89
- const hasSessions = sessions.length > 0;
90
- const hasDevices = devices.length > 0;
91
- if (hasSessions && hasDevices)
92
- return res.status(400).json({ error: 'use --session or --device, not both' });
93
- const payload = { title, body };
94
- if (typeof tag === 'string' && tag)
95
- payload.tag = tag;
96
- const opts = { urgency: 'normal', ttl: 1800 };
97
- if (typeof payload.tag === 'string')
98
- opts.topic = payload.tag;
99
- // Record FIRST so the notification tap can deep-link to this exact message's detail page. `--url`
100
- // is stored on the record (surfaced in the detail), NOT used as the tap target.
101
- let targetKeys = [];
102
- let rec = null;
103
- if (notifications) {
104
- targetKeys = push.resolveTargetKeys({ devices: hasDevices ? devices : null, sessions: hasSessions ? sessions : null });
105
- rec = notifications.record(targetKeys, {
106
- title, body, tag: payload.tag, url: safeUrl, delivery: { status: 'pending' },
107
- });
108
- payload.data = { inboxId: rec.id };
109
- }
122
+ if (deviceAuth)
123
+ return res.status(404).json({ error: 'use the local handmux push CLI' });
110
124
  try {
111
- const out = hasDevices ? await push.sendToDevices(devices, payload, opts)
112
- : hasSessions ? await push.sendToSessions(sessions, payload, opts)
113
- : await push.sendToAll(payload, opts);
114
- if (notifications && rec) {
115
- for (const delivery of out.deliveries || []) {
116
- if (!delivery.pushKey)
117
- continue;
118
- try {
119
- notifications.updateDelivery(delivery.pushKey, rec.id, delivery);
120
- }
121
- catch (error) {
122
- // Delivery already happened. Do not turn a status-metadata write failure into an HTTP error:
123
- // CLI automation would retry and send the real notification twice. The pending record remains
124
- // honest (unknown final state) and the server log retains the storage failure for diagnosis.
125
- console.warn(`[handmux] notification delivery status update failed: ${errorMessage(error)}`);
126
- }
127
- }
128
- }
129
- const { deliveries: _deliveries, ...summary } = out;
130
- return res.json(summary);
125
+ return res.json(await sendLocalPush({ push, ...(notifications ? { notifications } : {}) }, req.body));
131
126
  }
132
127
  catch (e) {
128
+ if (isRecord(e) && e.status === 400)
129
+ return res.status(400).json({ error: errorMessage(e) });
133
130
  return next(e);
134
131
  }
135
132
  });
@@ -139,7 +136,7 @@ export function pushRoutes({ push, notifications }) {
139
136
  const endpoint = isRecord(requestBody) ? requestBody.endpoint : undefined;
140
137
  if (typeof endpoint !== 'string')
141
138
  return res.status(400).json({ error: 'endpoint required' });
142
- return res.json({ pushKey: push.getPushKey(endpoint) });
139
+ return res.json({ pushKey: push.getPushKey(endpoint, res.locals.deviceAuth?.deviceId) });
143
140
  });
144
141
  return r;
145
142
  }
@@ -40,9 +40,9 @@ const PKG_VERSION = (() => {
40
40
  return null;
41
41
  }
42
42
  })();
43
- export function systemRoutes({ commands, claudeEvents, agentRuntime, asrEnv, shortcuts, home, stateFile, previewDomain, agentIntegrationContext, }) {
43
+ export function systemRoutes({ commands, claudeEvents, agentRuntime, asrEnv, shortcuts, home, stateFile, previewDomain, agentIntegrationContext, shortcutState, }) {
44
44
  const r = express.Router();
45
- let activeShortcuts = normalizeShortcuts(shortcuts);
45
+ const activeShortcuts = shortcutState ?? { value: normalizeShortcuts(shortcuts) };
46
46
  const integrationContext = agentIntegrationContext ?? defaultAgentIntegrationContext({
47
47
  home,
48
48
  piEntryFile: PI_ENTRY,
@@ -65,7 +65,7 @@ export function systemRoutes({ commands, claudeEvents, agentRuntime, asrEnv, sho
65
65
  asrFillerFilter: voiceAdapter?.capabilities?.fillerFilter === true,
66
66
  claudeHooks: hooksStatus(home),
67
67
  managedCodex: true,
68
- shortcuts: activeShortcuts,
68
+ shortcuts: activeShortcuts.value,
69
69
  browserProxy: !!previewDomain,
70
70
  });
71
71
  });
@@ -75,7 +75,7 @@ export function systemRoutes({ commands, claudeEvents, agentRuntime, asrEnv, sho
75
75
  return res.status(400).json({ error: 'shortcuts required' });
76
76
  }
77
77
  try {
78
- activeShortcuts = normalizeShortcuts(body.shortcuts);
78
+ activeShortcuts.value = normalizeShortcuts(body.shortcuts);
79
79
  }
80
80
  catch (error) {
81
81
  return res.status(400).json({ error: errorMessage(error) });
@@ -8,6 +8,7 @@ import { capTrailingBlankRows } from '../trimCapture.js';
8
8
  import { isAllowedKey } from '../keyNames.js';
9
9
  import { restoreCaptureBackgrounds } from '../captureBackground.js';
10
10
  import { serializePaneInput } from '../paneInput.js';
11
+ import { assertRequestAuthority } from '../requestAuthority.js';
11
12
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
12
13
  const requestBody = (req) => isRecord(req.body) ? req.body : {};
13
14
  export { isAllowedKey } from '../keyNames.js';
@@ -104,10 +105,12 @@ export function terminalRoutes({ commands }) {
104
105
  try {
105
106
  await serializePaneInput(pane, async () => {
106
107
  await commands.exitCopyModeIfActive(pane);
108
+ assertRequestAuthority();
107
109
  await commands.sendText(pane, body);
108
110
  if (enter) {
109
111
  if (body)
110
112
  await delay(SUBMIT_GAP_MS); // a bare Enter has nothing to settle — send at once
113
+ assertRequestAuthority();
111
114
  await commands.sendEnter(pane);
112
115
  }
113
116
  });
@@ -126,6 +129,7 @@ export function terminalRoutes({ commands }) {
126
129
  try {
127
130
  await serializePaneInput(pane, async () => {
128
131
  await commands.exitCopyModeIfActive(pane);
132
+ assertRequestAuthority();
129
133
  await commands.sendHexInput(pane, hex);
130
134
  });
131
135
  return res.json({ ok: true });
@@ -175,6 +179,7 @@ export function terminalRoutes({ commands }) {
175
179
  // restore the split arrangement (resizePane) then hand window sizing back (resizeWindow)
176
180
  if (typeof layout === 'string' && layout)
177
181
  await commands.applyWindowLayout(window, layout);
182
+ assertRequestAuthority();
178
183
  await commands.restoreWindowSize(window);
179
184
  }
180
185
  else if (isPaneId(pane)) {
@@ -203,8 +208,10 @@ export function terminalRoutes({ commands }) {
203
208
  try {
204
209
  await serializePaneInput(pane, async () => {
205
210
  await commands.exitCopyModeIfActive(pane);
206
- for (const k of keys)
211
+ for (const k of keys) {
212
+ assertRequestAuthority();
207
213
  await commands.sendKey(pane, k);
214
+ }
208
215
  });
209
216
  return res.json({ ok: true });
210
217
  }
@@ -229,6 +236,7 @@ export function terminalRoutes({ commands }) {
229
236
  const { altScreen, mouseAware, mouseSgr, width, height } = await commands.paneInfo(pane);
230
237
  if (!altScreen || !mouseAware)
231
238
  return res.json({ ok: false, reason: 'no-mouse' });
239
+ assertRequestAuthority();
232
240
  await commands.sendWheel(pane, dir, Number(lines), {
233
241
  sgr: mouseSgr,
234
242
  col: Math.max(1, Math.floor((width || 2) / 2)),
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { loadConfig } from './config.js';
6
- import { expressAuth, loadToken } from './auth.js';
6
+ import { loadToken } from './auth.js';
7
7
  import { createApiRouter } from './httpApi.js';
8
8
  import { loadUploadExts } from './uploadTypes.js';
9
9
  import { createClaudeEvents } from './claudeEvents.js';
@@ -13,6 +13,14 @@ import { removeLegacyCodexHooks } from './cli/legacyCodexHooks.js';
13
13
  import { agentRuntimeDirectoryPath, claudeStatePath, codexOutboxPath, } from './cli/state.js';
14
14
  import * as commands from './tmux/commands.js';
15
15
  import * as push from './push.js';
16
+ import * as notifications from './notifications.js';
17
+ import { sendLocalPush } from './routes/push.js';
18
+ import { DeviceAuthService } from './deviceAuth/service.js';
19
+ import { createDeviceAuthRouter } from './deviceAuth/http.js';
20
+ import { startDeviceAuthControl } from './deviceAuth/control.js';
21
+ import { createAuthOriginResolver, createDeviceAccess } from './deviceAccess.js';
22
+ import { readState } from './cli/state.js';
23
+ import { normalizeShortcuts } from './shortcutConfig.js';
16
24
  import { cacheControlFor } from './staticCache.js';
17
25
  import { compressStaticAssets } from './staticCompression.js';
18
26
  import { applyAppName, applyManifestName } from './appName.js';
@@ -33,7 +41,8 @@ import { apiErrorBoundary, apiRequestContext } from './apiErrors.js';
33
41
  import { RuntimeHealth } from './healthProtocol.js';
34
42
  import { healthRoutes } from './routes/health.js';
35
43
  import { createBuiltinAgentRuntime } from './agent-runtime/builtinRuntime.js';
36
- import { interruptPane, sendPaneChoice, sendPanePrompt } from './paneInput.js';
44
+ import { interruptClaudePane, sendPaneChoice } from './paneInput.js';
45
+ import { sendClaudePanePrompt } from './agents/claudePaneInput.js';
37
46
  import { createLocalAgentProcessContext, TmuxAgentPaneSource, } from './agent-runtime/tmuxRuntime.js';
38
47
  import { InboxPushProjection } from './agent-runtime/inboxPushProjection.js';
39
48
  import { defaultGit } from './git.js';
@@ -67,8 +76,40 @@ const projectTask = await createProjectTaskRuntime({
67
76
  },
68
77
  });
69
78
  if (projectTask.status().status === 'unavailable') {
70
- console.warn(`[handmux] Project Task unavailable: ${projectTask.status().error?.message ?? 'unknown error'}`);
79
+ throw new Error(`Authentication database unavailable: ${projectTask.status().error?.message ?? 'unknown error'}`);
71
80
  }
81
+ const auth = new DeviceAuthService({ db: projectTask.requireDatabase(), token,
82
+ onSuccessfulWrite: () => projectTask.successfulWrite() });
83
+ const configuredPublicUrl = process.env.HANDMUX_PUBLIC_URL || null;
84
+ const runtimeAdvertisedUrl = () => {
85
+ const state = readState(home);
86
+ if (!state || state.serverPid !== process.pid || state.port !== cfg.port)
87
+ return configuredPublicUrl;
88
+ // In Direct mode the supervisor's LAN/local fallback is a display URL, not
89
+ // a configured Public URL. Keep the setting explicitly blank so the web
90
+ // page can distinguish it from a user-managed reverse-tunnel address.
91
+ return state.tunnel === 'none' && !configuredPublicUrl ? null : state.publicUrl ?? configuredPublicUrl;
92
+ };
93
+ const resolveAuthOrigin = createAuthOriginResolver({ port: cfg.port, host: cfg.host,
94
+ ...(configuredPublicUrl ? { publicUrl: configuredPublicUrl } : {}),
95
+ ...(previewDomain ? { previewDomain } : {}),
96
+ trustedOrigins: () => auth.trustedOrigins,
97
+ trustedOriginEnabled: () => auth.trustedOriginEnabled,
98
+ runtimePublicUrl: runtimeAdvertisedUrl,
99
+ });
100
+ const deviceAccess = createDeviceAccess({ service: auth, resolveOrigin: resolveAuthOrigin });
101
+ const authenticate = deviceAccess.middleware;
102
+ push.setDeviceAuthorization((id) => auth.isDeviceActive(id));
103
+ const shortcutState = { value: cfg.shortcuts };
104
+ const authControl = await startDeviceAuthControl({ service: auth, home,
105
+ handlePush: (body) => sendLocalPush({ push, notifications }, body),
106
+ handleShortcuts: async (body) => {
107
+ if (!body || typeof body !== 'object' || !('shortcuts' in body))
108
+ throw new Error('shortcuts required');
109
+ shortcutState.value = normalizeShortcuts(body.shortcuts);
110
+ return { ok: true };
111
+ },
112
+ });
72
113
  // One writer set is shared by background capture today and the restore runtime added on top of it. The
73
114
  // lock is filesystem-backed because the CLI may restore while this daemon is alive in another process.
74
115
  const workspaceStore = createWorkspaceStore({ home });
@@ -145,8 +186,8 @@ const agentRuntime = createBuiltinAgentRuntime({
145
186
  ...(conversationStartupBlockReason === undefined ? {} : { conversationStartupBlockReason }),
146
187
  claudeEvents: events,
147
188
  claudeConversationControl: {
148
- sendPrompt: (paneId, text, guard) => sendPanePrompt(commands, paneId, text, guard),
149
- interrupt: (paneId) => interruptPane(commands, paneId),
189
+ sendPrompt: (paneId, text, guard) => sendClaudePanePrompt(commands, paneId, text, () => events?.paneRestoredPrompt(paneId) ?? null, guard),
190
+ interrupt: (paneId) => interruptClaudePane(commands, paneId),
150
191
  },
151
192
  claudeInteractionControl: {
152
193
  capturePlain: (paneId) => commands.capturePlain(paneId),
@@ -169,6 +210,7 @@ const agentRuntime = createBuiltinAgentRuntime({
169
210
  },
170
211
  });
171
212
  const claudeInboxBridge = new ClaudeHookBridgeConnector({
213
+ nativeTail: events.nativeTail,
172
214
  socketPath: agentRuntime.socketPath,
173
215
  credentialFile: path.join(agentRuntimeDirectory, 'bridge-credential.json'),
174
216
  stateDirectory: path.join(agentRuntimeDirectory, 'connectors', 'claude'),
@@ -190,7 +232,11 @@ const inboxPush = new InboxPushProjection({
190
232
  });
191
233
  inboxPush.start();
192
234
  agentRuntime.start().catch((error) => {
193
- console.warn(`[handmux] Agent Runtime unavailable: ${error instanceof Error ? error.message : String(error)}`);
235
+ const detail = error instanceof Error ? error.stack ?? error.message : String(error);
236
+ console.warn(`[handmux] Agent Runtime unavailable\n${detail}`);
237
+ for (const entry of agentRuntime.health()) {
238
+ console.warn(`[handmux] Agent Runtime adapter ${entry.adapterId}: ${entry.availability}${entry.message ? ` — ${entry.message}` : ''}`);
239
+ }
194
240
  });
195
241
  claudeInboxBridge.start();
196
242
  workspace.start().catch(() => { });
@@ -219,7 +265,7 @@ try {
219
265
  catch { /* best effort — migration never fails startup */ }
220
266
  // Static directory preview remains for folders without a web server. Arbitrary sites and local ports
221
267
  // use the built-in browser below; previewDomain may provide its dedicated public origin.
222
- const previews = createPreviews({ home });
268
+ const previews = createPreviews({ home, isDeviceActive: (id) => auth.isDeviceActive(id) });
223
269
  const preview = createPreview({ previews });
224
270
  const handmuxOrigin = (() => {
225
271
  try {
@@ -229,8 +275,29 @@ const handmuxOrigin = (() => {
229
275
  return `http://127.0.0.1:${cfg.port}`;
230
276
  }
231
277
  })();
232
- const browserWorker = createBrowserWorkerClient({ appToken: token, previewDomain, handmuxOrigin });
278
+ const browserWorker = createBrowserWorkerClient({ appToken: token, previewDomain, handmuxOrigin,
279
+ deviceAuthorization: {
280
+ getDeviceId: (req) => deviceAccess.authenticate(req)?.deviceId ?? null,
281
+ isActive: (id) => auth.isDeviceActive(id),
282
+ isMainRequest: (req) => resolveAuthOrigin(req) !== null,
283
+ },
284
+ });
285
+ auth.onRevoke((id) => { push.revokeDevice(id); });
286
+ auth.onRevoke((id) => { previews.revokeDevice(id); });
287
+ auth.onRevoke((id) => { browserWorker.revokeDevice(id); });
233
288
  const app = express();
289
+ app.disable('x-powered-by');
290
+ app.use((_req, res, next) => {
291
+ res.setHeader('X-Content-Type-Options', 'nosniff');
292
+ res.setHeader('Referrer-Policy', 'no-referrer');
293
+ next();
294
+ });
295
+ app.use('/api/auth', (_req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, express.json({ limit: '16kb' }), createDeviceAuthRouter({
296
+ service: auth,
297
+ resolveOrigin: resolveAuthOrigin,
298
+ previewDomain,
299
+ resolvePublicUrl: runtimeAdvertisedUrl,
300
+ }));
234
301
  app.use(healthRoutes({
235
302
  health,
236
303
  refresh: async () => {
@@ -243,10 +310,10 @@ app.use(healthRoutes({
243
310
  // Browser proxy leases stay behind normal Handmux auth. Client-owned direct tabs never enter
244
311
  // server state; proxy operations and all claimed Hammerhead paths use the isolated worker.
245
312
  app.use(browserWorker.publicHandler);
246
- app.use('/api/browser-proxy', apiRequestContext(), expressAuth(token), express.json(), browserWorker.apiHandler);
313
+ app.use('/api/browser-proxy', apiRequestContext(), authenticate, express.json(), browserWorker.apiHandler);
247
314
  app.use('/api', createApiRouter({
248
315
  token, events, uploadExts, previews, shortcuts: cfg.shortcuts, workspace, previewDomain,
249
- agentRuntime, projectTask, apiAccounts,
316
+ agentRuntime, projectTask, apiAccounts, authentication: authenticate, deviceAuth: true, shortcutState,
250
317
  }));
251
318
  app.use('/preview', preview.router);
252
319
  app.use(preview.refererFallback);
@@ -275,6 +342,12 @@ if (appName) {
275
342
  }
276
343
  // index:false so the renamed shell below owns "/" too (otherwise static would serve the generic one).
277
344
  app.use(compressStaticAssets);
345
+ // Only the main app owns this policy. Browser proxy documents use a separate origin and must be
346
+ // embeddable by handmux; static previews already set their own opaque-origin sandbox policy above.
347
+ app.use((_req, res, next) => {
348
+ res.setHeader('Content-Security-Policy', "base-uri 'self'; object-src 'none'; frame-ancestors 'self'");
349
+ next();
350
+ });
278
351
  app.use(express.static(staticDir, {
279
352
  index: false,
280
353
  // Cache-Control policy lives in staticCache.js (unit-tested): index.html + sw.js are never cached
@@ -301,7 +374,9 @@ app.use(apiErrorBoundary());
301
374
  const server = app.listen(cfg.port, cfg.host, () => {
302
375
  console.log(`[handmux] listening on http://${cfg.host}:${cfg.port} (serving ${staticDir})`);
303
376
  });
304
- const terminalStream = createTerminalStream({ token, commands });
377
+ const terminalStream = createTerminalStream({ token, commands,
378
+ deviceAuth: { service: auth, resolveOrigin: resolveAuthOrigin },
379
+ });
305
380
  server.on('upgrade', (req, socket, head) => {
306
381
  if (terminalStream.onUpgrade(req, socket, head))
307
382
  return;
@@ -310,12 +385,13 @@ server.on('upgrade', (req, socket, head) => {
310
385
  });
311
386
  const shutdown = createGracefulShutdown({ events, workspace, browser: browserWorker, server });
312
387
  const handleSignal = () => {
388
+ deviceAccess.close();
313
389
  terminalStream.close();
314
390
  codexApp.close();
315
391
  inboxPush.close();
316
392
  Promise.all([
317
393
  claudeInboxBridge.close(),
318
- projectTask.close(),
394
+ authControl.close().then(() => { auth.close(); }).finally(() => projectTask.close()),
319
395
  agentRuntime.close(),
320
396
  shutdown(),
321
397
  ]).catch(() => { });
@@ -302,9 +302,24 @@ export class PaneControlStream {
302
302
  catch { /* already gone */ }
303
303
  }
304
304
  }
305
- export function createTerminalStream({ token, commands, spawnControl, }) {
305
+ export function createTerminalStream({ token, commands, spawnControl, deviceAuth, }) {
306
306
  const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_CLIENT_MESSAGE_BYTES });
307
307
  const streams = new Set();
308
+ const principals = new WeakMap();
309
+ const origins = new WeakMap();
310
+ const socketStreams = new Map();
311
+ const unsubscribe = deviceAuth?.service.onRevoke((deviceId) => {
312
+ for (const ws of wss.clients) {
313
+ if (principals.get(ws)?.deviceId !== deviceId)
314
+ continue;
315
+ const stream = socketStreams.get(ws);
316
+ stream?.close();
317
+ if (stream)
318
+ streams.delete(stream);
319
+ socketStreams.delete(ws);
320
+ ws.close(4001, 'unauthorized');
321
+ }
322
+ });
308
323
  const heartbeat = setInterval(() => {
309
324
  for (const socket of wss.clients) {
310
325
  const ws = socket;
@@ -321,12 +336,37 @@ export function createTerminalStream({ token, commands, spawnControl, }) {
321
336
  heartbeat.unref?.();
322
337
  wss.on('connection', (socket) => {
323
338
  const ws = socket;
339
+ let principal = principals.get(ws);
340
+ const stillAuthorized = () => {
341
+ if (!deviceAuth)
342
+ return true;
343
+ try {
344
+ return Boolean(principal && deviceAuth.service.isActive(principal));
345
+ }
346
+ catch {
347
+ return false;
348
+ }
349
+ };
324
350
  ws.isAlive = true;
325
- ws.on('pong', () => { ws.isAlive = true; });
351
+ ws.on('pong', () => {
352
+ if (!stillAuthorized()) {
353
+ ws.close(4001, 'unauthorized');
354
+ return;
355
+ }
356
+ ws.isAlive = true;
357
+ if (principal)
358
+ deviceAuth?.service.touch(principal);
359
+ });
326
360
  const cancelSubscribeDeadline = startSubscribeDeadline(ws);
327
361
  let authenticating = false;
328
362
  let stream = null;
329
363
  ws.on('message', async (raw, binary) => {
364
+ if (principal && !stillAuthorized()) {
365
+ ws.close(4001, 'unauthorized');
366
+ return;
367
+ }
368
+ if (principal)
369
+ deviceAuth?.service.touch(principal);
330
370
  if (binary)
331
371
  return;
332
372
  let message;
@@ -363,7 +403,24 @@ export function createTerminalStream({ token, commands, spawnControl, }) {
363
403
  ws.close(1003, 'bad subscribe message');
364
404
  return;
365
405
  }
366
- if (!tokenEquals(message.token ?? '', token)) {
406
+ if (deviceAuth) {
407
+ const origin = origins.get(ws) ?? '';
408
+ const tokenPrincipal = deviceAuth.service.authenticateToken?.(message.token, origin);
409
+ if (!tokenPrincipal) {
410
+ ws.close(4001, 'unauthorized');
411
+ return;
412
+ }
413
+ // Trusted-device protection is an independent policy. When it is off,
414
+ // the Token principal is sufficient and becomes the socket principal.
415
+ if (!principal && deviceAuth.service.trustedDeviceEnabled === false) {
416
+ principal = tokenPrincipal;
417
+ }
418
+ if (!principal || !stillAuthorized()) {
419
+ ws.close(4001, 'unauthorized');
420
+ return;
421
+ }
422
+ }
423
+ else if (!tokenEquals(message.token ?? '', token)) {
367
424
  ws.close(4001, 'unauthorized');
368
425
  return;
369
426
  }
@@ -371,13 +428,14 @@ export function createTerminalStream({ token, commands, spawnControl, }) {
371
428
  cancelSubscribeDeadline();
372
429
  try {
373
430
  const session = await commands.paneSession(message.pane);
374
- if (ws.readyState !== 1)
431
+ if (ws.readyState !== 1 || !stillAuthorized())
375
432
  return;
376
433
  stream = new PaneControlStream({
377
434
  ws, pane: message.pane, session,
378
435
  ...(spawnControl ? { spawnControl } : {}),
379
436
  });
380
437
  streams.add(stream);
438
+ socketStreams.set(ws, stream);
381
439
  await stream.start();
382
440
  }
383
441
  catch {
@@ -387,6 +445,7 @@ export function createTerminalStream({ token, commands, spawnControl, }) {
387
445
  });
388
446
  ws.on('close', () => {
389
447
  cancelSubscribeDeadline();
448
+ socketStreams.delete(ws);
390
449
  if (stream) {
391
450
  stream.close();
392
451
  streams.delete(stream);
@@ -403,11 +462,37 @@ export function createTerminalStream({ token, commands, spawnControl, }) {
403
462
  }
404
463
  if (pathname !== '/api/terminal-stream')
405
464
  return false;
406
- wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
465
+ let principal = null;
466
+ let requestOrigin = null;
467
+ if (deviceAuth) {
468
+ try {
469
+ const origin = deviceAuth.resolveOrigin(req);
470
+ if (origin && req.headers.origin === origin) {
471
+ requestOrigin = origin;
472
+ principal = deviceAuth.service.authenticateRequest(req, origin);
473
+ }
474
+ }
475
+ catch {
476
+ socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
477
+ return true;
478
+ }
479
+ if (!requestOrigin || (!principal && !deviceAuth.service.tokenEnabled)) {
480
+ socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
481
+ return true;
482
+ }
483
+ }
484
+ wss.handleUpgrade(req, socket, head, (ws) => {
485
+ if (principal)
486
+ principals.set(ws, principal);
487
+ if (requestOrigin)
488
+ origins.set(ws, requestOrigin);
489
+ wss.emit('connection', ws, req);
490
+ });
407
491
  return true;
408
492
  };
409
493
  const close = () => {
410
494
  clearInterval(heartbeat);
495
+ unsubscribe?.();
411
496
  for (const stream of streams)
412
497
  stream.close();
413
498
  streams.clear();
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import os from 'node:os';
3
3
  import { parseTmuxRows, tmuxFormat } from './format.js';
4
4
  import { TmuxPaneOutputCapture } from './paneOutputCapture.js';
5
+ import { assertRequestAuthority } from '../requestAuthority.js';
5
6
  export const isPaneId = (value) => typeof value === 'string' && /^%\d+$/.test(value);
6
7
  export const isWindowId = (value) => typeof value === 'string' && /^@\d+$/.test(value);
7
8
  export const isSessionId = (value) => typeof value === 'string' && /^\$\d+$/.test(value);
@@ -21,6 +22,7 @@ export const isValidStartupCmd = (value) => typeof value === 'string' && value.l
21
22
  });
22
23
  export function runTmux(args) {
23
24
  return new Promise((resolve, reject) => {
25
+ assertRequestAuthority();
24
26
  execFile('tmux', args, { maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
25
27
  if (err)
26
28
  reject(new Error(stderr?.toString() || err.message));
@@ -214,7 +214,10 @@ export function createTranscriptParser() {
214
214
  // bubbles) — the pushed `it.text` below is the untrimmed original, so real text's internal/
215
215
  // leading/trailing whitespace is preserved verbatim.
216
216
  if (it.type === 'text' && typeof it.text === 'string' && it.text.trim()) {
217
- msgs.push({ i, role, type: 'text', text: it.text, ts });
217
+ msgs.push({ i, role, type: 'text', text: it.text, ts,
218
+ ...(o.type === 'user' && m.role === 'user' && o.promptSource === 'system'
219
+ && record(o.origin)?.kind === 'task-notification' ? { source: 'task-notification' } : {}),
220
+ });
218
221
  }
219
222
  else if (it.type === 'thinking' && typeof it.thinking === 'string' && it.thinking) {
220
223
  msgs.push({ i, role: 'assistant', type: 'thinking', text: it.thinking, ts });