handmux 0.23.0 → 0.25.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 (327) hide show
  1. package/README.md +31 -8
  2. package/README.zh-CN.md +31 -8
  3. package/bin/handmux.js +14 -688
  4. package/bin/node-version.js +20 -0
  5. package/dist/bin/handmux.js +1015 -0
  6. package/dist/connectors/bridgeClient.js +385 -0
  7. package/dist/connectors/claude/index.js +476 -0
  8. package/dist/connectors/pi/bridgeClient.js +8 -0
  9. package/dist/connectors/pi/index.js +851 -0
  10. package/dist/hooks/handmux-notify.sh +64 -0
  11. package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
  12. package/dist/hooks/handmux-write.cjs +264 -0
  13. package/dist/package.json +127 -0
  14. package/dist/public/assets/index-CTrqTqtT.css +32 -0
  15. package/dist/public/assets/index-DmjeMSQA.js +346 -0
  16. package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
  17. package/{public → dist/public}/index.html +5 -4
  18. package/dist/src/agent-runtime/adapter.js +204 -0
  19. package/dist/src/agent-runtime/bridge.js +1006 -0
  20. package/dist/src/agent-runtime/bridgeStore.js +25 -0
  21. package/dist/src/agent-runtime/bridgeTransport.js +619 -0
  22. package/dist/src/agent-runtime/bridgeTypes.js +13 -0
  23. package/dist/src/agent-runtime/builtinRuntime.js +193 -0
  24. package/dist/src/agent-runtime/conversation.js +2179 -0
  25. package/dist/src/agent-runtime/conversationActivation.js +75 -0
  26. package/dist/src/agent-runtime/conversationActivity.js +69 -0
  27. package/dist/src/agent-runtime/conversationControls.js +206 -0
  28. package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
  29. package/dist/src/agent-runtime/conversationStore.js +352 -0
  30. package/dist/src/agent-runtime/conversationTypes.js +1 -0
  31. package/dist/src/agent-runtime/conversationValidation.js +339 -0
  32. package/dist/src/agent-runtime/inbox.js +973 -0
  33. package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
  34. package/dist/src/agent-runtime/inboxStore.js +25 -0
  35. package/dist/src/agent-runtime/inboxTypes.js +1 -0
  36. package/dist/src/agent-runtime/interaction.js +829 -0
  37. package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
  38. package/dist/src/agent-runtime/interactionStore.js +18 -0
  39. package/dist/src/agent-runtime/interactionTypes.js +1 -0
  40. package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
  41. package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
  42. package/dist/src/agent-runtime/resourceTypes.js +1 -0
  43. package/dist/src/agent-runtime/resources.js +245 -0
  44. package/dist/src/agent-runtime/run.js +328 -0
  45. package/dist/src/agent-runtime/runtime.js +793 -0
  46. package/dist/src/agent-runtime/sessionControl.js +164 -0
  47. package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
  48. package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
  49. package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
  50. package/dist/src/agents/claude.js +203 -0
  51. package/dist/src/agents/claudeConversation.js +362 -0
  52. package/dist/src/agents/claudeInteraction.js +134 -0
  53. package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
  54. package/dist/src/agents/codex.js +262 -0
  55. package/dist/src/agents/codexConversation.js +1103 -0
  56. package/dist/src/agents/codexConversationActivation.js +121 -0
  57. package/dist/src/agents/codexConversationControls.js +256 -0
  58. package/dist/src/agents/codexInteraction.js +306 -0
  59. package/dist/src/agents/codexSessionControl.js +118 -0
  60. package/dist/src/agents/codexSubscriptionUsage.js +313 -0
  61. package/dist/src/agents/codexTerminalControl.js +23 -0
  62. package/dist/src/agents/conversationProjectionSafety.js +416 -0
  63. package/dist/src/agents/index.js +39 -0
  64. package/dist/src/agents/nativeInbox.js +264 -0
  65. package/dist/src/agents/nativeInboxSources.js +115 -0
  66. package/dist/src/agents/pi.js +28 -0
  67. package/dist/src/agents/piConversation.js +523 -0
  68. package/dist/src/agents/piConversationHistory.js +502 -0
  69. package/dist/src/agents/piInboxBridge.js +368 -0
  70. package/dist/src/agents/piSessionControl.js +32 -0
  71. package/dist/src/agents/processIdentity.js +94 -0
  72. package/dist/src/agents/scanUtils.js +262 -0
  73. package/dist/src/apiAccountProviders.js +194 -0
  74. package/dist/src/apiAccounts.js +592 -0
  75. package/dist/src/apiErrors.js +86 -0
  76. package/{src → dist/src}/appName.js +12 -13
  77. package/dist/src/asr/iflyConfig.js +7 -0
  78. package/dist/src/asr/iflySign.js +14 -0
  79. package/dist/src/auth.js +27 -0
  80. package/dist/src/browser/bootstrap.js +72 -0
  81. package/dist/src/browser/cookieProfiles.js +413 -0
  82. package/dist/src/browser/coordinator.js +51 -0
  83. package/dist/src/browser/hammerheadCompat.js +104 -0
  84. package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
  85. package/dist/src/browser/manager.js +560 -0
  86. package/dist/src/browser/originLabel.js +15 -0
  87. package/dist/src/browser/profilePersistence.js +235 -0
  88. package/dist/src/browser/publicProxy.js +176 -0
  89. package/dist/src/browser/routes.js +159 -0
  90. package/dist/src/browser/sessionStore.js +72 -0
  91. package/dist/src/browser/siteVersion.js +121 -0
  92. package/dist/src/browser/targetPolicy.js +163 -0
  93. package/{src → dist/src}/browser/worker.js +14 -18
  94. package/dist/src/browser/workerClient.js +286 -0
  95. package/dist/src/browser/workerServer.js +91 -0
  96. package/dist/src/captureBackground.js +95 -0
  97. package/dist/src/claudeEvents.js +525 -0
  98. package/dist/src/cli/agentIntegration.js +131 -0
  99. package/dist/src/cli/agentLauncher.js +26 -0
  100. package/dist/src/cli/claudeHooks.js +290 -0
  101. package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
  102. package/dist/src/cli/cloudflared.js +106 -0
  103. package/dist/src/cli/codexManaged.js +80 -0
  104. package/{src → dist/src}/cli/cpolarUrl.js +8 -9
  105. package/dist/src/cli/drivers.js +98 -0
  106. package/dist/src/cli/hookScaffold.js +39 -0
  107. package/dist/src/cli/i18n/en.js +392 -0
  108. package/{src → dist/src}/cli/i18n/index.js +27 -25
  109. package/dist/src/cli/i18n/zh.js +391 -0
  110. package/dist/src/cli/legacyCodexHooks.js +52 -0
  111. package/{src → dist/src}/cli/natappUrl.js +2 -3
  112. package/dist/src/cli/options.js +245 -0
  113. package/dist/src/cli/piExtension.js +155 -0
  114. package/dist/src/cli/probe.js +16 -0
  115. package/{src → dist/src}/cli/prompt.js +4 -6
  116. package/dist/src/cli/pushCmd.js +102 -0
  117. package/{src → dist/src}/cli/qr.js +19 -19
  118. package/dist/src/cli/service.js +154 -0
  119. package/dist/src/cli/setupModel.js +194 -0
  120. package/dist/src/cli/setupWizard.js +647 -0
  121. package/dist/src/cli/shortcutEditor.js +351 -0
  122. package/dist/src/cli/sshTunnel.js +18 -0
  123. package/dist/src/cli/state.js +117 -0
  124. package/dist/src/cli/statusLine.js +115 -0
  125. package/dist/src/cli/supervisor.js +278 -0
  126. package/dist/src/cli/supervisorHealth.js +21 -0
  127. package/dist/src/cli/supervisorLaunch.js +148 -0
  128. package/dist/src/cli/supervisorProcesses.js +62 -0
  129. package/dist/src/cli/supervisorState.js +62 -0
  130. package/dist/src/cli/tmuxVersion.js +53 -0
  131. package/{src → dist/src}/cli/tunlite.js +7 -10
  132. package/dist/src/cli/tunnelClients.js +72 -0
  133. package/{src → dist/src}/cli/updateCheck.js +98 -69
  134. package/{src → dist/src}/cli/urlHost.js +8 -4
  135. package/dist/src/cli/workspaceCmd.js +347 -0
  136. package/dist/src/codexAppServer.js +3436 -0
  137. package/dist/src/codexConversationProjection.js +199 -0
  138. package/dist/src/codexDiff.js +118 -0
  139. package/dist/src/codexMessageIdentity.js +18 -0
  140. package/dist/src/codexPlan.js +30 -0
  141. package/dist/src/codexQueueProtocol.js +137 -0
  142. package/dist/src/codexStreamProtocol.js +207 -0
  143. package/dist/src/codexToolProtocol.js +68 -0
  144. package/dist/src/codexTranscriptParse.js +835 -0
  145. package/dist/src/codexUsageSnapshot.js +228 -0
  146. package/dist/src/config.js +8 -0
  147. package/{src → dist/src}/docPath.js +22 -24
  148. package/dist/src/docs.js +337 -0
  149. package/dist/src/git.js +284 -0
  150. package/dist/src/healthProtocol.js +97 -0
  151. package/dist/src/httpApi.js +96 -0
  152. package/dist/src/jsonStore.js +15 -0
  153. package/{src → dist/src}/keyNames.js +3 -3
  154. package/dist/src/notifications.js +111 -0
  155. package/dist/src/orphans.js +167 -0
  156. package/dist/src/paneInput.js +42 -0
  157. package/dist/src/pendingPrompt.js +194 -0
  158. package/dist/src/previewServer.js +202 -0
  159. package/dist/src/previews.js +179 -0
  160. package/dist/src/privateStateStore.js +124 -0
  161. package/dist/src/projectTask/backup.js +224 -0
  162. package/dist/src/projectTask/lock.js +108 -0
  163. package/dist/src/projectTask/migrations.js +90 -0
  164. package/dist/src/projectTask/routes.js +155 -0
  165. package/dist/src/projectTask/runtime.js +182 -0
  166. package/dist/src/projectTask/schema.js +56 -0
  167. package/dist/src/projectTask/store.js +457 -0
  168. package/dist/src/push.js +224 -0
  169. package/dist/src/routes/agents.js +631 -0
  170. package/dist/src/routes/apiAccounts.js +82 -0
  171. package/dist/src/routes/files.js +230 -0
  172. package/dist/src/routes/git.js +89 -0
  173. package/dist/src/routes/health.js +16 -0
  174. package/dist/src/routes/notifications.js +16 -0
  175. package/dist/src/routes/previews.js +43 -0
  176. package/dist/src/routes/push.js +145 -0
  177. package/dist/src/routes/sessions.js +244 -0
  178. package/dist/src/routes/system.js +220 -0
  179. package/dist/src/routes/terminal.js +244 -0
  180. package/dist/src/routes/workspace.js +267 -0
  181. package/dist/src/server.js +315 -0
  182. package/dist/src/shortcutConfig.js +83 -0
  183. package/{src → dist/src}/staticCache.js +5 -3
  184. package/{src → dist/src}/staticCompression.js +5 -6
  185. package/dist/src/terminalStream.js +441 -0
  186. package/{src → dist/src}/tmux/commands.js +185 -172
  187. package/dist/src/tmux/format.js +47 -0
  188. package/dist/src/transcriptParse.js +261 -0
  189. package/dist/src/transcriptReader.js +209 -0
  190. package/{src → dist/src}/trimCapture.js +11 -11
  191. package/dist/src/uploadTypes.js +31 -0
  192. package/dist/src/urlPolicy.js +18 -0
  193. package/dist/src/usagePaths.js +15 -0
  194. package/dist/src/workspace/agentRunner.js +301 -0
  195. package/dist/src/workspace/atomicJson.js +31 -0
  196. package/dist/src/workspace/capture.js +139 -0
  197. package/dist/src/workspace/checkpointer.js +228 -0
  198. package/dist/src/workspace/environment.js +71 -0
  199. package/dist/src/workspace/lock.js +137 -0
  200. package/dist/src/workspace/mapping.js +128 -0
  201. package/dist/src/workspace/operations.js +271 -0
  202. package/dist/src/workspace/paths.js +15 -0
  203. package/dist/src/workspace/planner.js +292 -0
  204. package/dist/src/workspace/restore.js +363 -0
  205. package/dist/src/workspace/runtime.js +318 -0
  206. package/dist/src/workspace/schema.js +156 -0
  207. package/dist/src/workspace/store.js +464 -0
  208. package/dist/src/workspace/tmuxAdapter.js +704 -0
  209. package/dist/src/workspaceProtocol.js +158 -0
  210. package/package.json +30 -17
  211. package/hooks/handmux-notify.sh +0 -22
  212. package/hooks/handmux-write.cjs +0 -78
  213. package/public/assets/index-C3N7OkyP.css +0 -32
  214. package/public/assets/index-D1DWcC5u.js +0 -337
  215. package/src/.gitkeep +0 -0
  216. package/src/agents/claude.js +0 -159
  217. package/src/agents/codex.js +0 -168
  218. package/src/agents/index.js +0 -21
  219. package/src/agents/processIdentity.js +0 -72
  220. package/src/agents/scanUtils.js +0 -225
  221. package/src/asr/iflyConfig.js +0 -10
  222. package/src/asr/iflySign.js +0 -16
  223. package/src/auth.js +0 -30
  224. package/src/browser/bootstrap.js +0 -68
  225. package/src/browser/cookieProfiles.js +0 -403
  226. package/src/browser/coordinator.js +0 -47
  227. package/src/browser/hammerheadCompat.js +0 -86
  228. package/src/browser/hammerheadRedirectCompat.js +0 -38
  229. package/src/browser/manager.js +0 -556
  230. package/src/browser/originLabel.js +0 -17
  231. package/src/browser/profilePersistence.js +0 -241
  232. package/src/browser/publicProxy.js +0 -175
  233. package/src/browser/routes.js +0 -154
  234. package/src/browser/sessionStore.js +0 -78
  235. package/src/browser/siteVersion.js +0 -124
  236. package/src/browser/targetPolicy.js +0 -137
  237. package/src/browser/workerClient.js +0 -253
  238. package/src/browser/workerServer.js +0 -98
  239. package/src/captureBackground.js +0 -93
  240. package/src/claudeEvents.js +0 -399
  241. package/src/cli/claudeHooks.js +0 -206
  242. package/src/cli/cloudflared.js +0 -99
  243. package/src/cli/codexManaged.js +0 -77
  244. package/src/cli/drivers.js +0 -97
  245. package/src/cli/hookScaffold.js +0 -33
  246. package/src/cli/i18n/en.js +0 -385
  247. package/src/cli/i18n/zh.js +0 -384
  248. package/src/cli/legacyCodexHooks.js +0 -49
  249. package/src/cli/options.js +0 -211
  250. package/src/cli/probe.js +0 -16
  251. package/src/cli/pushCmd.js +0 -60
  252. package/src/cli/service.js +0 -126
  253. package/src/cli/setupModel.js +0 -154
  254. package/src/cli/setupWizard.js +0 -506
  255. package/src/cli/shortcutEditor.js +0 -288
  256. package/src/cli/sshTunnel.js +0 -12
  257. package/src/cli/state.js +0 -90
  258. package/src/cli/statusLine.js +0 -90
  259. package/src/cli/supervisor.js +0 -183
  260. package/src/cli/supervisorProcesses.js +0 -57
  261. package/src/cli/tmuxVersion.js +0 -49
  262. package/src/cli/tunnelClients.js +0 -62
  263. package/src/cli/workspaceCmd.js +0 -300
  264. package/src/codexAppServer.js +0 -1620
  265. package/src/codexDiff.js +0 -108
  266. package/src/codexTranscriptParse.js +0 -413
  267. package/src/codexUsageSnapshot.cjs +0 -129
  268. package/src/config.js +0 -9
  269. package/src/docs.js +0 -228
  270. package/src/git.js +0 -208
  271. package/src/httpApi.js +0 -72
  272. package/src/jsonStore.js +0 -24
  273. package/src/notifications.js +0 -68
  274. package/src/orphans.js +0 -152
  275. package/src/pendingPrompt.js +0 -163
  276. package/src/previewServer.js +0 -168
  277. package/src/previews.js +0 -116
  278. package/src/push.js +0 -183
  279. package/src/routes/codex.js +0 -473
  280. package/src/routes/files.js +0 -172
  281. package/src/routes/git.js +0 -57
  282. package/src/routes/notifications.js +0 -19
  283. package/src/routes/previews.js +0 -35
  284. package/src/routes/push.js +0 -119
  285. package/src/routes/sessions.js +0 -168
  286. package/src/routes/system.js +0 -132
  287. package/src/routes/terminal.js +0 -210
  288. package/src/routes/transcript.js +0 -171
  289. package/src/routes/workspace.js +0 -267
  290. package/src/server.js +0 -186
  291. package/src/shortcutConfig.js +0 -75
  292. package/src/terminalStream.js +0 -391
  293. package/src/tmux/format.js +0 -45
  294. package/src/transcriptParse.js +0 -180
  295. package/src/transcriptReader.js +0 -101
  296. package/src/uploadTypes.js +0 -30
  297. package/src/urlPolicy.js +0 -14
  298. package/src/usage.js +0 -260
  299. package/src/workspace/agentRunner.js +0 -188
  300. package/src/workspace/atomicJson.js +0 -32
  301. package/src/workspace/capture.js +0 -139
  302. package/src/workspace/checkpointer.js +0 -187
  303. package/src/workspace/environment.js +0 -75
  304. package/src/workspace/lock.js +0 -128
  305. package/src/workspace/mapping.js +0 -124
  306. package/src/workspace/operations.js +0 -252
  307. package/src/workspace/paths.js +0 -16
  308. package/src/workspace/planner.js +0 -247
  309. package/src/workspace/restore.js +0 -279
  310. package/src/workspace/runtime.js +0 -314
  311. package/src/workspace/schema.js +0 -136
  312. package/src/workspace/store.js +0 -382
  313. package/src/workspace/tmuxAdapter.js +0 -554
  314. /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  315. /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
  316. /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
  317. /package/{public → dist/public}/icons/badge-96.png +0 -0
  318. /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
  319. /package/{public → dist/public}/icons/icon-192.png +0 -0
  320. /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
  321. /package/{public → dist/public}/icons/icon-512.png +0 -0
  322. /package/{public → dist/public}/icons/logo.svg +0 -0
  323. /package/{public → dist/public}/manifest.webmanifest +0 -0
  324. /package/{public → dist/public}/offline.html +0 -0
  325. /package/{public → dist/public}/sw.js +0 -0
  326. /package/{src → dist/src}/browser/protocol.js +0 -0
  327. /package/{src → dist/src}/cli/cfNamed.js +0 -0
package/src/push.js DELETED
@@ -1,183 +0,0 @@
1
- // Web Push (VAPID) — minimal delivery layer. Sends notifications to subscribed devices via the
2
- // browser/OS push services (Android=FCM, iOS=APNs); the server only talks to those services, never
3
- // to the phone directly, so device reachability/queueing is their problem (see TTL/topic below).
4
- //
5
- // Subscriptions are stored as records: { subscription, boundSessions }. The subscription field is
6
- // the raw PushSubscription the browser hands us ({endpoint, keys:{p256dh, auth}}); boundSessions is
7
- // the list of session names this device cares about (used by sendToSession for targeted delivery). A
8
- // dead subscription (404/410 from the push service) is pruned on the next send.
9
- import webpush from 'web-push';
10
- import fs from 'node:fs';
11
- import path from 'node:path';
12
- import { fileURLToPath } from 'node:url';
13
- import crypto from 'node:crypto';
14
- import { writeJsonAtomic } from './jsonStore.js';
15
-
16
- const here = path.dirname(fileURLToPath(import.meta.url));
17
- const STORE = process.env.PUSH_STORE || path.resolve(here, '../data/push-subs.json');
18
-
19
- // VAPID is optional: without keys every send is a no-op, so the server still boots in an
20
- // environment that hasn't generated keys (configured=false surfaces as a 503 on /vapid). Push delivers
21
- // whenever VAPID is configured — there is no separate "dev vs prod server" anymore (one config file, one
22
- // `handmux start`). If you run a second instance on the same host and don't want it to deliver, leave
23
- // `vapid` out of that instance's config. Init is LAZY (first use) so the module is import-safe.
24
- let inited = false;
25
- let configured = false;
26
- function ensureInit() {
27
- if (inited) return;
28
- inited = true;
29
- configured = !!(process.env.VAPID_PUBLIC && process.env.VAPID_PRIVATE);
30
- if (configured) {
31
- webpush.setVapidDetails(
32
- // Apple (APNs) rejects a VAPID subject on a fake/.local domain with BadJwtToken — it must be a
33
- // valid mailto:/https: with a real-looking domain. example.com is reserved and accepted by both.
34
- process.env.VAPID_SUBJECT || 'mailto:admin@example.com',
35
- process.env.VAPID_PUBLIC,
36
- process.env.VAPID_PRIVATE,
37
- );
38
- }
39
- }
40
-
41
- const genKey = () => crypto.randomBytes(18).toString('base64url');
42
- const VALID_PUSH_KEY = /^[A-Za-z0-9_-]{16,128}$/;
43
-
44
- let subs = load();
45
-
46
- function load() {
47
- try {
48
- const raw = JSON.parse(fs.readFileSync(STORE, 'utf8')) || [];
49
- return raw.map((e) => (e && e.subscription)
50
- ? { subscription: e.subscription, boundSessions: e.boundSessions || [], pushKey: e.pushKey || undefined }
51
- : { subscription: e, boundSessions: [] }); // migrate old bare-subscription entries
52
- } catch { return []; }
53
- }
54
- function persist() { writeJsonAtomic(STORE, subs); }
55
-
56
- export function isConfigured() { ensureInit(); return configured; }
57
- export function publicKey() { ensureInit(); return process.env.VAPID_PUBLIC || null; }
58
- export function count() { return subs.length; }
59
-
60
- export function addSubscription(sub, boundSessions = [], preferredPushKey = null) {
61
- if (!sub || typeof sub.endpoint !== 'string') return false;
62
- const endpointRecord = subs.find((s) => s.subscription.endpoint === sub.endpoint);
63
- const requestedKey = VALID_PUSH_KEY.test(preferredPushKey || '') ? preferredPushKey : null;
64
- const keyRecord = requestedKey ? subs.find((s) => s.pushKey === requestedKey) : null;
65
- const pushKey = keyRecord?.pushKey || endpointRecord?.pushKey || requestedKey || genKey();
66
-
67
- // A browser PushSubscription is transport state, not device identity. Re-enabling push can produce a
68
- // different endpoint; replace the old endpoint that owns this stable key instead of creating a new
69
- // script-push target. Also collapse any legacy duplicate for the new endpoint.
70
- subs = subs.filter((s) => s.subscription.endpoint !== sub.endpoint && s.pushKey !== pushKey);
71
- subs.push({ subscription: sub, boundSessions, pushKey });
72
- persist();
73
- return pushKey;
74
- }
75
-
76
- export function updateBound(endpoint, boundSessions = []) {
77
- const rec = subs.find((s) => s.subscription.endpoint === endpoint);
78
- if (rec) { rec.boundSessions = boundSessions; persist(); }
79
- }
80
-
81
- export function removeSubscription(endpoint) {
82
- const removed = subs.find((s) => s.subscription.endpoint === endpoint);
83
- subs = subs.filter((s) => s.subscription.endpoint !== endpoint);
84
- if (removed) persist();
85
- return removed?.pushKey || null;
86
- }
87
-
88
- // The device-addressing id (NOT an auth credential — see /api/push/send-local). Lazy-generate for
89
- // records stored before the feature existed so an already-subscribed device still has one.
90
- export function getPushKey(endpoint) {
91
- const rec = subs.find((s) => s.subscription.endpoint === endpoint);
92
- if (!rec) return null;
93
- if (!rec.pushKey) { rec.pushKey = genKey(); persist(); }
94
- return rec.pushKey;
95
- }
96
-
97
- // The push-service Topic header (RFC 8030) must be ≤32 URL/filename-safe base64 chars [A-Za-z0-9_-].
98
- // An invalid topic makes the service reject the ENTIRE send with a non-404/410 error that deliver()
99
- // swallows — a silent zero-delivery, no log, no prune. tmux pane ids carry a '%' (e.g. %4), so a
100
- // pane-derived topic ("pane-%4") breaks every per-pane push (需要你 / 已完成). Sanitize here, the layer
101
- // that owns the web-push contract, so no caller has to know the rule.
102
- function safeTopic(t) {
103
- if (!t) return undefined;
104
- const s = t.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32);
105
- return s || undefined;
106
- }
107
-
108
- // TTL bounds staleness (a phone offline longer than this drops the push instead of getting it
109
- // hours later); topic collapses older undelivered messages with the same key so a device coming
110
- // back online sees only the latest per topic. urgency hints the OS how aggressively to wake.
111
- function options(opts = {}) {
112
- return { TTL: opts.ttl ?? 90, urgency: opts.urgency || 'normal', topic: safeTopic(opts.topic) };
113
- }
114
-
115
- export function classifyDeliveryFailure(error) {
116
- const status = Number(error?.statusCode) || 0;
117
- if (status === 404 || status === 410) return 'expired';
118
- if (status === 429) return 'rate_limited';
119
- if (status >= 500) return 'service_unavailable';
120
- if (status >= 400) return 'rejected';
121
- return 'network_error';
122
- }
123
-
124
- async function deliver(records, payload, opts = {}) {
125
- ensureInit();
126
- if (!configured) return {
127
- sent: 0, failed: 0, gone: 0, configured: false,
128
- deliveries: records.map((rec) => ({ pushKey: rec.pushKey, status: 'failed', reason: 'not_configured' })),
129
- };
130
- const data = JSON.stringify(payload);
131
- const dead = [];
132
- const deliveries = [];
133
- let sent = 0;
134
- let failed = 0;
135
- await Promise.all(records.map(async (rec) => {
136
- try {
137
- await webpush.sendNotification(rec.subscription, data, options(opts));
138
- sent += 1;
139
- deliveries.push({ pushKey: rec.pushKey, status: 'success' });
140
- }
141
- catch (e) {
142
- failed += 1;
143
- if (e?.statusCode === 404 || e?.statusCode === 410) dead.push(rec.subscription.endpoint);
144
- deliveries.push({ pushKey: rec.pushKey, status: 'failed', reason: classifyDeliveryFailure(e) });
145
- let host = 'unknown push service';
146
- try { host = new URL(rec.subscription.endpoint).host; } catch { /* malformed endpoints fail below */ }
147
- const detail = String(e?.body || e?.message || 'unknown error').replace(/\s+/g, ' ').slice(0, 200);
148
- console.warn(`[handmux] push delivery failed (${host}, HTTP ${e?.statusCode || 'unknown'}): ${detail}`);
149
- }
150
- }));
151
- if (dead.length) { subs = subs.filter((s) => !dead.includes(s.subscription.endpoint)); persist(); }
152
- return { sent, failed, gone: dead.length, configured: true, deliveries };
153
- }
154
-
155
- export const sendToAll = (payload, opts = {}) => deliver(subs, payload, opts);
156
- export const sendToSession = (session, payload, opts = {}) =>
157
- deliver(subs.filter((s) => s.boundSessions.includes(session)), payload, opts);
158
-
159
- export const sendToDevices = (keys, payload, opts = {}) =>
160
- deliver(subs.filter((s) => keys.includes(s.pushKey)), payload, opts);
161
-
162
- // Union of the given sessions, deduped: a device bound to several of them is still delivered once
163
- // (deliver() iterates the record list, so each record appears at most once here).
164
- export const sendToSessions = (sessions, payload, opts = {}) =>
165
- deliver(subs.filter((s) => s.boundSessions.some((x) => sessions.includes(x))), payload, opts);
166
-
167
- // The pushKeys a given scope resolves to (mirrors sendToDevices/sendToSessions/sendToAll targeting) — used
168
- // by the inbox to write a record into exactly the devices a manual push is delivered to.
169
- export const resolveTargetKeys = ({ devices, sessions } = {}) => {
170
- const pick = (devices && devices.length)
171
- ? subs.filter((s) => devices.includes(s.pushKey))
172
- : (sessions && sessions.length)
173
- ? subs.filter((s) => s.boundSessions.some((x) => sessions.includes(x)))
174
- : subs;
175
- return [...new Set(pick.map((s) => s.pushKey).filter(Boolean))];
176
- };
177
-
178
- // Back-compat: the /push/subscribe welcome still pushes to a single just-added subscription.
179
- export async function sendToOne(sub, payload, opts = {}) {
180
- const rec = subs.find((s) => s.subscription.endpoint === sub.endpoint)
181
- ?? { subscription: sub, boundSessions: [] };
182
- return deliver([rec], payload, opts);
183
- }
@@ -1,473 +0,0 @@
1
- import express from 'express';
2
- import { isPaneId } from '../tmux/commands.js';
3
- import { getAgent } from '../agents/index.js';
4
- import { codexExitSessionId } from '../agents/codex.js';
5
-
6
- const TAKEOVER_TERMINAL_HINT_MS = 10_000;
7
- const TAKEOVER_TIMEOUT_MS = 30_000;
8
- const INPUT_SETTLE_MS = 100;
9
- const EXIT_POLL_MS = 500;
10
- const EXIT_ATTEMPTS = 10;
11
- const RECOVERY_OUTPUT_ATTEMPTS = 20;
12
- const CLEAR_SWITCH_ATTEMPTS = 60;
13
- const STREAM_BATCH_MS = 60;
14
- const STREAM_HEARTBEAT_MS = 15_000;
15
- const PERMISSION_MODES = {
16
- default: {
17
- approvalPolicy: 'on-request', approvalsReviewer: 'user',
18
- sandboxPolicy: { type: 'workspaceWrite' },
19
- },
20
- 'auto-review': {
21
- approvalPolicy: 'on-request', approvalsReviewer: 'auto_review',
22
- sandboxPolicy: { type: 'workspaceWrite' },
23
- },
24
- 'full-access': {
25
- approvalPolicy: 'never', approvalsReviewer: 'user',
26
- sandboxPolicy: { type: 'dangerFullAccess' },
27
- },
28
- };
29
-
30
- const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
31
-
32
- async function paneAgent(commands, claudeEvents, pane) {
33
- const panes = await commands?.listLivePanes?.();
34
- const live = Array.isArray(panes) ? panes.find((candidate) => candidate.id === pane) : null;
35
- if (!live) return { live: null, agent: null };
36
- const agents = await claudeEvents?.identifyPaneAgents?.(panes) || {};
37
- return { live, agent: agents[pane] || null };
38
- }
39
-
40
- async function exitCurrentCodex(commands, claudeEvents, pane) {
41
- // During an active turn Codex can spend several seconds writing interruption details, token usage and
42
- // its recovery command. Give each Ctrl+C a full five-second window; only send a second one if the pane
43
- // still proves Codex is the foreground process after that wait.
44
- for (let press = 0; press < 2; press += 1) {
45
- await commands.sendKey(pane, 'C-c');
46
- for (let attempt = 0; attempt < EXIT_ATTEMPTS; attempt += 1) {
47
- await pause(EXIT_POLL_MS);
48
- const current = await paneAgent(commands, claudeEvents, pane);
49
- if (!current.live || current.agent !== 'codex') return current.live;
50
- }
51
- }
52
- return null;
53
- }
54
-
55
- async function waitForRecoverySession(commands, pane, isId) {
56
- // tmux can report pane_current_command=the shell before the last buffered Codex output has been painted.
57
- // Poll the visible pane briefly instead of treating the first incomplete frame as a permanent failure.
58
- for (let attempt = 0; attempt < RECOVERY_OUTPUT_ATTEMPTS; attempt += 1) {
59
- try {
60
- const sessionId = codexExitSessionId(await commands.capturePlain(pane));
61
- if (isId(sessionId)) return sessionId;
62
- } catch { return null; } // a direct-command pane may have closed with Codex
63
- await pause(INPUT_SETTLE_MS);
64
- }
65
- return null;
66
- }
67
-
68
- async function clearCurrentCodexThroughTui(codexApp, commands, pane, previousThreadId, wait) {
69
- if (typeof commands?.exitCopyModeIfActive !== 'function'
70
- || typeof commands?.sendKey !== 'function'
71
- || typeof commands?.sendText !== 'function'
72
- || typeof commands?.sendEnter !== 'function') {
73
- throw new Error('Codex terminal control is unavailable');
74
- }
75
- // /clear is not just thread/start: the remote TUI also replaces its active ChatWidget and redraws the
76
- // terminal. Drive that native action so terminal and chat remain one session. C-u makes the command exact
77
- // even if the hidden terminal composer retained a draft; if a modal owns input, confirmation below times
78
- // out instead of letting Handmux split onto a different thread.
79
- await commands.exitCopyModeIfActive(pane);
80
- await commands.sendKey(pane, 'C-u');
81
- await wait(INPUT_SETTLE_MS);
82
- await commands.sendText(pane, '/clear');
83
- await wait(INPUT_SETTLE_MS);
84
- await commands.sendEnter(pane);
85
-
86
- for (let attempt = 0; attempt < CLEAR_SWITCH_ATTEMPTS; attempt += 1) {
87
- const discovered = await codexApp.discover(pane);
88
- if (!discovered?.managed) throw new Error('Codex session is no longer managed by Handmux');
89
- if (discovered.threadId && discovered.threadId !== previousThreadId) {
90
- return { threadId: discovered.threadId };
91
- }
92
- await wait(INPUT_SETTLE_MS);
93
- }
94
- throw new Error('Codex terminal did not accept /clear; switch to the terminal, close any open panel, and try again');
95
- }
96
-
97
- async function binding(codexApp, pane) {
98
- if (!isPaneId(pane)) return { error: 'bad pane id', status: 400 };
99
- try {
100
- const discovered = await codexApp?.discover?.(pane);
101
- if (!discovered?.managed) return { error: 'Codex session is not managed by Handmux', status: 409 };
102
- if (discovered.threadId) return { pane, threadId: discovered.threadId };
103
- } catch (error) {
104
- return { error: error?.message || String(error), status: 503 };
105
- }
106
- return { error: 'Codex session is not bound yet', status: 409 };
107
- }
108
-
109
- function routeError(res, result) {
110
- if (!result.error) return false;
111
- res.status(result.status).json({ error: result.error });
112
- return true;
113
- }
114
-
115
- function codexError(res, error) {
116
- const message = error?.message || String(error);
117
- const conflict = /not managed|session changed|no longer pending|already being sent|being edited|edit is no longer active|queue is full|decision is unavailable|bad user input response/i.test(message);
118
- return res.status(conflict ? 409 : 503).json({ error: message });
119
- }
120
-
121
- // App Server can emit many tiny token deltas. Keep their order, but fold adjacent chunks for the same
122
- // message before crossing the tunnel to a phone; lifecycle events always flush immediately.
123
- export function appendCodexStreamEvent(queue, event) {
124
- const last = queue.at(-1);
125
- if (event?.type === 'delta' && last?.type === 'delta'
126
- && last.threadId === event.threadId && last.turnId === event.turnId && last.itemId === event.itemId) {
127
- last.delta += event.delta || '';
128
- } else if (event) queue.push({ ...event });
129
- return queue;
130
- }
131
-
132
- export function codexRoutes({ codexApp, commands, claudeEvents, wait = pause }) {
133
- const r = express.Router();
134
- if (!codexApp) return r;
135
- // A takeover is pane-scoped and deliberately kept in server memory. Besides making repeated taps
136
- // idempotent, this gates discovery while the replacement process starts: App Server may briefly list
137
- // another historical thread, but the UI must not enter chat until the exact pre-takeover thread appears.
138
- const takeovers = new Map(); // pane -> { threadId, startedAt }
139
- const takeoverView = (takeover) => {
140
- const elapsed = Date.now() - takeover.startedAt;
141
- return {
142
- state: elapsed >= TAKEOVER_TIMEOUT_MS ? 'timed-out' : 'starting',
143
- needsTerminal: elapsed >= TAKEOVER_TERMINAL_HINT_MS,
144
- };
145
- };
146
-
147
- r.get('/codex/session', async (req, res) => {
148
- const pane = req.query.pane;
149
- if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
150
- const takeover = takeovers.get(pane);
151
- try {
152
- const discovered = await codexApp.discover(pane);
153
- if (takeover && (!takeover.threadId || discovered?.threadId !== takeover.threadId)) {
154
- return res.json({ managed: false, threadId: null, takeover: takeoverView(takeover) });
155
- }
156
- if (takeover) takeovers.delete(pane);
157
- if (!discovered?.managed || !discovered.threadId) {
158
- return res.json({ managed: !!discovered?.managed, threadId: discovered?.threadId || null });
159
- }
160
- return res.json(await codexApp.status(pane, discovered.threadId));
161
- } catch (error) {
162
- // A killed plain Codex and its replacement App Server have a normal short disconnect window. Keep
163
- // the takeover page stable through it instead of flashing a generic connection failure.
164
- if (takeover) return res.json({ managed: false, threadId: null, takeover: takeoverView(takeover) });
165
- return codexError(res, error);
166
- }
167
- });
168
-
169
- r.get('/codex/stream', async (req, res) => {
170
- const target = await binding(codexApp, req.query.pane);
171
- if (routeError(res, target)) return;
172
-
173
- res.status(200);
174
- res.set({
175
- 'Content-Type': 'text/event-stream; charset=utf-8',
176
- 'Cache-Control': 'no-cache, no-transform',
177
- Connection: 'keep-alive',
178
- 'X-Accel-Buffering': 'no',
179
- });
180
- res.flushHeaders?.();
181
- res.write(`data: ${JSON.stringify({ type: 'ready', threadId: target.threadId })}\n\n`);
182
-
183
- let closed = false;
184
- let unsubscribe = null;
185
- let flushTimer = null;
186
- let heartbeat = null;
187
- const pending = [];
188
- const flush = () => {
189
- if (flushTimer) clearTimeout(flushTimer);
190
- flushTimer = null;
191
- if (closed || !pending.length) return;
192
- const events = pending.splice(0);
193
- res.write(`data: ${JSON.stringify({ type: 'events', events })}\n\n`);
194
- res.flush?.();
195
- };
196
- const cleanup = () => {
197
- if (closed) return;
198
- closed = true;
199
- if (flushTimer) clearTimeout(flushTimer);
200
- if (heartbeat) clearInterval(heartbeat);
201
- flushTimer = null;
202
- heartbeat = null;
203
- unsubscribe?.();
204
- unsubscribe = null;
205
- };
206
- const enqueue = (event) => {
207
- if (closed) return;
208
- if (event?.type === 'disconnected') {
209
- appendCodexStreamEvent(pending, event);
210
- flush();
211
- cleanup();
212
- res.end();
213
- return;
214
- }
215
- appendCodexStreamEvent(pending, event);
216
- if (event?.type === 'completed' || event?.type === 'turnCompleted') flush();
217
- else if (!flushTimer) {
218
- flushTimer = setTimeout(flush, STREAM_BATCH_MS);
219
- flushTimer.unref?.();
220
- }
221
- };
222
-
223
- req.once('aborted', cleanup);
224
- res.once('close', cleanup);
225
- try {
226
- unsubscribe = await codexApp.subscribe(target.pane, target.threadId, enqueue);
227
- if (closed) { unsubscribe(); unsubscribe = null; return; }
228
- heartbeat = setInterval(() => {
229
- if (!closed) res.write(`: keepalive ${Date.now()}\n\n`);
230
- }, STREAM_HEARTBEAT_MS);
231
- heartbeat.unref?.();
232
- } catch (error) {
233
- if (!closed) {
234
- res.write(`data: ${JSON.stringify({ type: 'error', message: error?.message || String(error) })}\n\n`);
235
- res.end();
236
- }
237
- cleanup();
238
- }
239
- });
240
-
241
- r.post('/codex/takeover', async (req, res) => {
242
- const pane = req.body?.pane;
243
- if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
244
- const pending = takeovers.get(pane);
245
- if (pending) return res.json({ started: true, takeover: takeoverView(pending) });
246
- try {
247
- const discovered = await codexApp.discover(pane);
248
- if (discovered?.managed && discovered.threadId) {
249
- return res.json({ started: false, managed: true, threadId: discovered.threadId });
250
- }
251
- if (discovered?.managed) return res.status(409).json({ error: 'codex-session-starting' });
252
-
253
- // Re-check live process identity immediately before interacting with the pane. A stale client must
254
- // never send slash commands to a pane whose Codex has already exited or changed.
255
- const current = await paneAgent(commands, claudeEvents, pane);
256
- if (!current.live) return res.status(404).json({ error: 'codex-pane-gone' });
257
- if (current.agent !== 'codex') return res.status(409).json({ error: 'codex-pane-changed' });
258
-
259
- const driver = getAgent('codex');
260
- const takeover = { threadId: null, startedAt: Date.now() };
261
- takeovers.set(pane, takeover);
262
- try {
263
- const exited = await exitCurrentCodex(commands, claudeEvents, pane);
264
- if (!exited) {
265
- takeovers.delete(pane);
266
- return res.status(409).json({ error: 'codex-exit-blocked' });
267
- }
268
- const sessionId = await waitForRecoverySession(commands, pane, driver.sessions.isId);
269
- if (!sessionId) {
270
- takeovers.delete(pane);
271
- return res.status(409).json({ error: 'codex-session-unconfirmed' });
272
- }
273
- takeover.threadId = sessionId;
274
- await pause(INPUT_SETTLE_MS); // let the restored shell finish drawing its prompt
275
- await commands.runPaneCommand(pane, driver.sessions.managedResumeCmd(sessionId));
276
- } catch (error) {
277
- takeovers.delete(pane);
278
- throw error;
279
- }
280
- return res.json({ started: true, takeover: takeoverView(takeover) });
281
- } catch (error) { return codexError(res, error); }
282
- });
283
-
284
- r.post('/codex/send', async (req, res) => {
285
- const target = await binding(codexApp, req.body?.pane);
286
- if (routeError(res, target)) return;
287
- const text = typeof req.body?.text === 'string' ? req.body.text.trim() : '';
288
- if (!text) return res.status(400).json({ error: 'message is empty' });
289
- const requestId = typeof req.body?.requestId === 'string' ? req.body.requestId.trim() : '';
290
- if (requestId && (requestId.length > 128 || !/^[a-zA-Z0-9._:-]+$/.test(requestId))) {
291
- return res.status(400).json({ error: 'bad Codex request id' });
292
- }
293
- try { res.json(await codexApp.send(target.pane, target.threadId, text, requestId || null)); }
294
- catch (error) { codexError(res, error); }
295
- });
296
-
297
- for (const [path, method] of [['/codex/queue/steer', 'steerQueued'], ['/codex/queue/remove', 'removeQueued']]) {
298
- r.post(path, async (req, res) => {
299
- const target = await binding(codexApp, req.body?.pane);
300
- if (routeError(res, target)) return;
301
- const id = typeof req.body?.id === 'string' ? req.body.id.trim() : '';
302
- if (!id || id.length > 128) return res.status(400).json({ error: 'bad queued message id' });
303
- try { res.json(await codexApp[method](target.pane, target.threadId, id)); }
304
- catch (error) { codexError(res, error); }
305
- });
306
- }
307
-
308
- r.post('/codex/queue/edit/begin', async (req, res) => {
309
- const target = await binding(codexApp, req.body?.pane);
310
- if (routeError(res, target)) return;
311
- const id = typeof req.body?.id === 'string' ? req.body.id.trim() : '';
312
- if (!id || id.length > 128) return res.status(400).json({ error: 'bad queued message id' });
313
- try { res.json(await codexApp.beginQueuedEdit(target.pane, target.threadId, id)); }
314
- catch (error) { codexError(res, error); }
315
- });
316
-
317
- for (const [action, method] of [
318
- ['renew', 'renewQueuedEdit'], ['commit', 'commitQueuedEdit'], ['cancel', 'cancelQueuedEdit'],
319
- ]) {
320
- r.post(`/codex/queue/edit/${action}`, async (req, res) => {
321
- const target = await binding(codexApp, req.body?.pane);
322
- if (routeError(res, target)) return;
323
- const id = typeof req.body?.id === 'string' ? req.body.id.trim() : '';
324
- const token = typeof req.body?.token === 'string' ? req.body.token.trim() : '';
325
- if (!id || id.length > 128) return res.status(400).json({ error: 'bad queued message id' });
326
- if (!token || token.length > 128) return res.status(400).json({ error: 'bad queued message edit token' });
327
- if (action === 'commit') {
328
- const text = typeof req.body?.text === 'string' ? req.body.text.trim() : '';
329
- if (!text) return res.status(400).json({ error: 'message is empty' });
330
- try { return res.json(await codexApp[method](target.pane, target.threadId, id, token, text)); }
331
- catch (error) { return codexError(res, error); }
332
- }
333
- try { return res.json(await codexApp[method](target.pane, target.threadId, id, token)); }
334
- catch (error) { return codexError(res, error); }
335
- });
336
- }
337
-
338
- r.post('/codex/compact', async (req, res) => {
339
- const target = await binding(codexApp, req.body?.pane);
340
- if (routeError(res, target)) return;
341
- try { res.json(await codexApp.compact(target.pane, target.threadId)); }
342
- catch (error) { codexError(res, error); }
343
- });
344
-
345
- r.post('/codex/clear', async (req, res) => {
346
- const target = await binding(codexApp, req.body?.pane);
347
- if (routeError(res, target)) return;
348
- try {
349
- res.json(await clearCurrentCodexThroughTui(
350
- codexApp, commands, target.pane, target.threadId, wait,
351
- ));
352
- }
353
- catch (error) { codexError(res, error); }
354
- });
355
-
356
- r.get('/codex/models', async (req, res) => {
357
- const target = await binding(codexApp, req.query.pane);
358
- if (routeError(res, target)) return;
359
- try { res.json({ models: await codexApp.models(target.pane, target.threadId) }); }
360
- catch (error) { codexError(res, error); }
361
- });
362
-
363
- r.get('/codex/goal', async (req, res) => {
364
- const target = await binding(codexApp, req.query.pane);
365
- if (routeError(res, target)) return;
366
- try { res.json({ goal: await codexApp.getGoal(target.pane, target.threadId) }); }
367
- catch (error) { codexError(res, error); }
368
- });
369
-
370
- r.post('/codex/goal', async (req, res) => {
371
- const target = await binding(codexApp, req.body?.pane);
372
- if (routeError(res, target)) return;
373
- const updates = {};
374
- if (Object.hasOwn(req.body || {}, 'objective')) {
375
- const objective = req.body.objective;
376
- const trimmed = typeof objective === 'string' ? objective.trim() : '';
377
- if (!trimmed || trimmed.length > 4_000) {
378
- return res.status(400).json({ error: 'bad goal objective' });
379
- }
380
- updates.objective = trimmed;
381
- }
382
- if (Object.hasOwn(req.body || {}, 'status')) {
383
- const status = req.body.status;
384
- if (!['active', 'paused'].includes(status)) return res.status(400).json({ error: 'bad goal status' });
385
- updates.status = status;
386
- }
387
- if (!Object.keys(updates).length) return res.status(400).json({ error: 'no goal update supplied' });
388
- try { res.json({ goal: await codexApp.updateGoal(target.pane, target.threadId, updates) }); }
389
- catch (error) { codexError(res, error); }
390
- });
391
-
392
- r.post('/codex/goal/clear', async (req, res) => {
393
- const target = await binding(codexApp, req.body?.pane);
394
- if (routeError(res, target)) return;
395
- try { res.json(await codexApp.clearGoal(target.pane, target.threadId)); }
396
- catch (error) { codexError(res, error); }
397
- });
398
-
399
- r.post('/codex/settings', async (req, res) => {
400
- const target = await binding(codexApp, req.body?.pane);
401
- if (routeError(res, target)) return;
402
- const updates = {};
403
- for (const key of ['model', 'effort']) {
404
- const value = req.body?.[key];
405
- if (value == null) continue;
406
- if (typeof value !== 'string' || !value.trim() || value.length > 128) {
407
- return res.status(400).json({ error: `bad ${key}` });
408
- }
409
- updates[key] = value.trim();
410
- }
411
- if (Object.hasOwn(req.body || {}, 'serviceTier')) {
412
- const value = req.body.serviceTier;
413
- if (value !== null && (typeof value !== 'string' || !value.trim() || value.length > 128)) {
414
- return res.status(400).json({ error: 'bad serviceTier' });
415
- }
416
- updates.serviceTier = value === null ? null : value.trim();
417
- }
418
- const approvalPolicy = req.body?.approvalPolicy;
419
- if (approvalPolicy != null) {
420
- if (!['untrusted', 'on-request', 'never'].includes(approvalPolicy)) {
421
- return res.status(400).json({ error: 'bad approvalPolicy' });
422
- }
423
- updates.approvalPolicy = approvalPolicy;
424
- }
425
- const permissionMode = req.body?.permissionMode;
426
- if (permissionMode != null) {
427
- if (typeof permissionMode !== 'string' || !PERMISSION_MODES[permissionMode]) {
428
- return res.status(400).json({ error: 'bad permissionMode' });
429
- }
430
- Object.assign(updates, PERMISSION_MODES[permissionMode]);
431
- }
432
- if (!Object.keys(updates).length) return res.status(400).json({ error: 'no settings supplied' });
433
- try { res.json({ settings: await codexApp.updateSettings(target.pane, target.threadId, updates) }); }
434
- catch (error) { codexError(res, error); }
435
- });
436
-
437
- r.post('/codex/interrupt', async (req, res) => {
438
- const target = await binding(codexApp, req.body?.pane);
439
- if (routeError(res, target)) return;
440
- try { res.json(await codexApp.interrupt(target.pane, target.threadId)); }
441
- catch (error) { codexError(res, error); }
442
- });
443
-
444
- r.post('/codex/approval', async (req, res) => {
445
- const target = await binding(codexApp, req.body?.pane);
446
- if (routeError(res, target)) return;
447
- const requestId = req.body?.requestId;
448
- const decision = req.body?.decision;
449
- if ((typeof requestId !== 'string' && typeof requestId !== 'number') || typeof decision !== 'string') {
450
- return res.status(400).json({ error: 'bad approval response' });
451
- }
452
- try { res.json(await codexApp.decide(target.pane, target.threadId, requestId, decision)); }
453
- catch (error) { codexError(res, error); }
454
- });
455
-
456
- r.post('/codex/input', async (req, res) => {
457
- const target = await binding(codexApp, req.body?.pane);
458
- if (routeError(res, target)) return;
459
- const requestId = req.body?.requestId;
460
- const answers = req.body?.answers;
461
- const entries = answers && typeof answers === 'object' && !Array.isArray(answers)
462
- ? Object.entries(answers) : [];
463
- if ((typeof requestId !== 'string' && typeof requestId !== 'number') || !entries.length
464
- || entries.some(([questionId, value]) => !questionId || !Array.isArray(value) || !value.length
465
- || value.some((answer) => typeof answer !== 'string' || !answer.trim()))) {
466
- return res.status(400).json({ error: 'bad user input response' });
467
- }
468
- try { res.json(await codexApp.answerInput(target.pane, target.threadId, requestId, answers)); }
469
- catch (error) { codexError(res, error); }
470
- });
471
-
472
- return r;
473
- }