handmux 0.23.0 → 0.24.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 (252) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  3. package/bin/handmux.js +3 -689
  4. package/dist/bin/handmux.js +850 -0
  5. package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
  6. package/{hooks → dist/hooks}/handmux-write.cjs +24 -3
  7. package/dist/package.json +126 -0
  8. package/dist/public/assets/index-CicdY9E9.css +32 -0
  9. package/dist/public/assets/index-D4VP5t8h.js +337 -0
  10. package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
  11. package/{public → dist/public}/index.html +5 -4
  12. package/{src → dist/src}/agents/claude.js +105 -83
  13. package/dist/src/agents/codex.js +217 -0
  14. package/{src → dist/src}/agents/index.js +7 -7
  15. package/dist/src/agents/processIdentity.js +80 -0
  16. package/dist/src/agents/scanUtils.js +262 -0
  17. package/dist/src/apiErrors.js +77 -0
  18. package/{src → dist/src}/appName.js +12 -13
  19. package/dist/src/asr/iflyConfig.js +7 -0
  20. package/dist/src/asr/iflySign.js +14 -0
  21. package/dist/src/auth.js +27 -0
  22. package/dist/src/browser/bootstrap.js +72 -0
  23. package/dist/src/browser/cookieProfiles.js +413 -0
  24. package/dist/src/browser/coordinator.js +51 -0
  25. package/dist/src/browser/hammerheadCompat.js +104 -0
  26. package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
  27. package/dist/src/browser/manager.js +560 -0
  28. package/dist/src/browser/originLabel.js +15 -0
  29. package/dist/src/browser/profilePersistence.js +235 -0
  30. package/dist/src/browser/publicProxy.js +176 -0
  31. package/dist/src/browser/routes.js +159 -0
  32. package/dist/src/browser/sessionStore.js +72 -0
  33. package/dist/src/browser/siteVersion.js +121 -0
  34. package/dist/src/browser/targetPolicy.js +163 -0
  35. package/{src → dist/src}/browser/worker.js +14 -18
  36. package/dist/src/browser/workerClient.js +286 -0
  37. package/dist/src/browser/workerServer.js +91 -0
  38. package/dist/src/captureBackground.js +95 -0
  39. package/dist/src/claudeEvents.js +499 -0
  40. package/{src → dist/src}/cli/claudeHooks.js +146 -108
  41. package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
  42. package/dist/src/cli/cloudflared.js +106 -0
  43. package/dist/src/cli/codexManaged.js +77 -0
  44. package/{src → dist/src}/cli/cpolarUrl.js +8 -9
  45. package/dist/src/cli/drivers.js +98 -0
  46. package/dist/src/cli/hookScaffold.js +36 -0
  47. package/dist/src/cli/i18n/en.js +366 -0
  48. package/{src → dist/src}/cli/i18n/index.js +27 -25
  49. package/dist/src/cli/i18n/zh.js +365 -0
  50. package/dist/src/cli/legacyCodexHooks.js +52 -0
  51. package/{src → dist/src}/cli/natappUrl.js +2 -3
  52. package/dist/src/cli/options.js +245 -0
  53. package/dist/src/cli/probe.js +16 -0
  54. package/{src → dist/src}/cli/prompt.js +4 -6
  55. package/dist/src/cli/pushCmd.js +102 -0
  56. package/{src → dist/src}/cli/qr.js +19 -19
  57. package/dist/src/cli/service.js +144 -0
  58. package/dist/src/cli/setupModel.js +194 -0
  59. package/dist/src/cli/setupWizard.js +647 -0
  60. package/dist/src/cli/shortcutEditor.js +351 -0
  61. package/dist/src/cli/sshTunnel.js +18 -0
  62. package/dist/src/cli/state.js +114 -0
  63. package/{src → dist/src}/cli/statusLine.js +63 -46
  64. package/dist/src/cli/supervisor.js +278 -0
  65. package/dist/src/cli/supervisorHealth.js +21 -0
  66. package/dist/src/cli/supervisorLaunch.js +148 -0
  67. package/dist/src/cli/supervisorProcesses.js +62 -0
  68. package/dist/src/cli/supervisorState.js +62 -0
  69. package/dist/src/cli/tmuxVersion.js +53 -0
  70. package/{src → dist/src}/cli/tunlite.js +7 -10
  71. package/dist/src/cli/tunnelClients.js +72 -0
  72. package/{src → dist/src}/cli/updateCheck.js +98 -69
  73. package/{src → dist/src}/cli/urlHost.js +8 -4
  74. package/dist/src/cli/workspaceCmd.js +347 -0
  75. package/dist/src/codexAppServer.js +2299 -0
  76. package/dist/src/codexConversationProjection.js +199 -0
  77. package/dist/src/codexDiff.js +118 -0
  78. package/dist/src/codexMessageIdentity.js +18 -0
  79. package/dist/src/codexPlan.js +30 -0
  80. package/dist/src/codexQueueProtocol.js +96 -0
  81. package/dist/src/codexStreamProtocol.js +207 -0
  82. package/dist/src/codexToolProtocol.js +68 -0
  83. package/dist/src/codexTranscriptParse.js +724 -0
  84. package/dist/src/codexUsageSnapshot.js +205 -0
  85. package/dist/src/config.js +8 -0
  86. package/{src → dist/src}/docPath.js +22 -24
  87. package/dist/src/docs.js +337 -0
  88. package/dist/src/git.js +272 -0
  89. package/dist/src/healthProtocol.js +97 -0
  90. package/dist/src/httpApi.js +68 -0
  91. package/dist/src/jsonStore.js +15 -0
  92. package/{src → dist/src}/keyNames.js +3 -3
  93. package/dist/src/notifications.js +111 -0
  94. package/dist/src/orphans.js +157 -0
  95. package/dist/src/pendingPrompt.js +194 -0
  96. package/dist/src/previewServer.js +202 -0
  97. package/dist/src/previews.js +179 -0
  98. package/dist/src/privateStateStore.js +89 -0
  99. package/dist/src/push.js +224 -0
  100. package/dist/src/routes/codex.js +606 -0
  101. package/dist/src/routes/files.js +230 -0
  102. package/dist/src/routes/git.js +78 -0
  103. package/dist/src/routes/health.js +16 -0
  104. package/dist/src/routes/notifications.js +16 -0
  105. package/dist/src/routes/previews.js +43 -0
  106. package/dist/src/routes/push.js +145 -0
  107. package/dist/src/routes/sessions.js +232 -0
  108. package/dist/src/routes/system.js +156 -0
  109. package/dist/src/routes/terminal.js +238 -0
  110. package/dist/src/routes/transcript.js +197 -0
  111. package/dist/src/routes/workspace.js +267 -0
  112. package/{src → dist/src}/server.js +100 -74
  113. package/dist/src/shortcutConfig.js +83 -0
  114. package/{src → dist/src}/staticCache.js +5 -3
  115. package/{src → dist/src}/staticCompression.js +5 -6
  116. package/dist/src/terminalStream.js +441 -0
  117. package/{src → dist/src}/tmux/commands.js +156 -171
  118. package/dist/src/tmux/format.js +47 -0
  119. package/dist/src/transcriptParse.js +240 -0
  120. package/dist/src/transcriptReader.js +113 -0
  121. package/{src → dist/src}/trimCapture.js +11 -11
  122. package/dist/src/uploadTypes.js +29 -0
  123. package/dist/src/urlPolicy.js +18 -0
  124. package/dist/src/usage.js +260 -0
  125. package/dist/src/workspace/agentRunner.js +204 -0
  126. package/dist/src/workspace/atomicJson.js +31 -0
  127. package/dist/src/workspace/capture.js +139 -0
  128. package/dist/src/workspace/checkpointer.js +228 -0
  129. package/dist/src/workspace/environment.js +71 -0
  130. package/dist/src/workspace/lock.js +137 -0
  131. package/dist/src/workspace/mapping.js +128 -0
  132. package/dist/src/workspace/operations.js +271 -0
  133. package/dist/src/workspace/paths.js +15 -0
  134. package/dist/src/workspace/planner.js +292 -0
  135. package/dist/src/workspace/restore.js +314 -0
  136. package/dist/src/workspace/runtime.js +318 -0
  137. package/dist/src/workspace/schema.js +156 -0
  138. package/dist/src/workspace/store.js +464 -0
  139. package/dist/src/workspace/tmuxAdapter.js +694 -0
  140. package/dist/src/workspaceProtocol.js +158 -0
  141. package/package.json +27 -15
  142. package/public/assets/index-C3N7OkyP.css +0 -32
  143. package/public/assets/index-D1DWcC5u.js +0 -337
  144. package/src/.gitkeep +0 -0
  145. package/src/agents/codex.js +0 -168
  146. package/src/agents/processIdentity.js +0 -72
  147. package/src/agents/scanUtils.js +0 -225
  148. package/src/asr/iflyConfig.js +0 -10
  149. package/src/asr/iflySign.js +0 -16
  150. package/src/auth.js +0 -30
  151. package/src/browser/bootstrap.js +0 -68
  152. package/src/browser/cookieProfiles.js +0 -403
  153. package/src/browser/coordinator.js +0 -47
  154. package/src/browser/hammerheadCompat.js +0 -86
  155. package/src/browser/hammerheadRedirectCompat.js +0 -38
  156. package/src/browser/manager.js +0 -556
  157. package/src/browser/originLabel.js +0 -17
  158. package/src/browser/profilePersistence.js +0 -241
  159. package/src/browser/publicProxy.js +0 -175
  160. package/src/browser/routes.js +0 -154
  161. package/src/browser/sessionStore.js +0 -78
  162. package/src/browser/siteVersion.js +0 -124
  163. package/src/browser/targetPolicy.js +0 -137
  164. package/src/browser/workerClient.js +0 -253
  165. package/src/browser/workerServer.js +0 -98
  166. package/src/captureBackground.js +0 -93
  167. package/src/claudeEvents.js +0 -399
  168. package/src/cli/cloudflared.js +0 -99
  169. package/src/cli/codexManaged.js +0 -77
  170. package/src/cli/drivers.js +0 -97
  171. package/src/cli/hookScaffold.js +0 -33
  172. package/src/cli/i18n/en.js +0 -385
  173. package/src/cli/i18n/zh.js +0 -384
  174. package/src/cli/legacyCodexHooks.js +0 -49
  175. package/src/cli/options.js +0 -211
  176. package/src/cli/probe.js +0 -16
  177. package/src/cli/pushCmd.js +0 -60
  178. package/src/cli/service.js +0 -126
  179. package/src/cli/setupModel.js +0 -154
  180. package/src/cli/setupWizard.js +0 -506
  181. package/src/cli/shortcutEditor.js +0 -288
  182. package/src/cli/sshTunnel.js +0 -12
  183. package/src/cli/state.js +0 -90
  184. package/src/cli/supervisor.js +0 -183
  185. package/src/cli/supervisorProcesses.js +0 -57
  186. package/src/cli/tmuxVersion.js +0 -49
  187. package/src/cli/tunnelClients.js +0 -62
  188. package/src/cli/workspaceCmd.js +0 -300
  189. package/src/codexAppServer.js +0 -1620
  190. package/src/codexDiff.js +0 -108
  191. package/src/codexTranscriptParse.js +0 -413
  192. package/src/codexUsageSnapshot.cjs +0 -129
  193. package/src/config.js +0 -9
  194. package/src/docs.js +0 -228
  195. package/src/git.js +0 -208
  196. package/src/httpApi.js +0 -72
  197. package/src/jsonStore.js +0 -24
  198. package/src/notifications.js +0 -68
  199. package/src/orphans.js +0 -152
  200. package/src/pendingPrompt.js +0 -163
  201. package/src/previewServer.js +0 -168
  202. package/src/previews.js +0 -116
  203. package/src/push.js +0 -183
  204. package/src/routes/codex.js +0 -473
  205. package/src/routes/files.js +0 -172
  206. package/src/routes/git.js +0 -57
  207. package/src/routes/notifications.js +0 -19
  208. package/src/routes/previews.js +0 -35
  209. package/src/routes/push.js +0 -119
  210. package/src/routes/sessions.js +0 -168
  211. package/src/routes/system.js +0 -132
  212. package/src/routes/terminal.js +0 -210
  213. package/src/routes/transcript.js +0 -171
  214. package/src/routes/workspace.js +0 -267
  215. package/src/shortcutConfig.js +0 -75
  216. package/src/terminalStream.js +0 -391
  217. package/src/tmux/format.js +0 -45
  218. package/src/transcriptParse.js +0 -180
  219. package/src/transcriptReader.js +0 -101
  220. package/src/uploadTypes.js +0 -30
  221. package/src/urlPolicy.js +0 -14
  222. package/src/usage.js +0 -260
  223. package/src/workspace/agentRunner.js +0 -188
  224. package/src/workspace/atomicJson.js +0 -32
  225. package/src/workspace/capture.js +0 -139
  226. package/src/workspace/checkpointer.js +0 -187
  227. package/src/workspace/environment.js +0 -75
  228. package/src/workspace/lock.js +0 -128
  229. package/src/workspace/mapping.js +0 -124
  230. package/src/workspace/operations.js +0 -252
  231. package/src/workspace/paths.js +0 -16
  232. package/src/workspace/planner.js +0 -247
  233. package/src/workspace/restore.js +0 -279
  234. package/src/workspace/runtime.js +0 -314
  235. package/src/workspace/schema.js +0 -136
  236. package/src/workspace/store.js +0 -382
  237. package/src/workspace/tmuxAdapter.js +0 -554
  238. /package/{hooks → dist/hooks}/handmux-notify.sh +0 -0
  239. /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  240. /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
  241. /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
  242. /package/{public → dist/public}/icons/badge-96.png +0 -0
  243. /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
  244. /package/{public → dist/public}/icons/icon-192.png +0 -0
  245. /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
  246. /package/{public → dist/public}/icons/icon-512.png +0 -0
  247. /package/{public → dist/public}/icons/logo.svg +0 -0
  248. /package/{public → dist/public}/manifest.webmanifest +0 -0
  249. /package/{public → dist/public}/offline.html +0 -0
  250. /package/{public → dist/public}/sw.js +0 -0
  251. /package/{src → dist/src}/browser/protocol.js +0 -0
  252. /package/{src → dist/src}/cli/cfNamed.js +0 -0
@@ -0,0 +1,606 @@
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
+ import { parseCodexStreamEvent } from '../codexStreamProtocol.js';
6
+ const errorMessage = (error) => {
7
+ if (error instanceof Error)
8
+ return error.message;
9
+ if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') {
10
+ return error.message;
11
+ }
12
+ return String(error);
13
+ };
14
+ const TAKEOVER_TERMINAL_HINT_MS = 10_000;
15
+ const TAKEOVER_TIMEOUT_MS = 30_000;
16
+ const INPUT_SETTLE_MS = 100;
17
+ const EXIT_POLL_MS = 500;
18
+ const EXIT_ATTEMPTS = 10;
19
+ const RECOVERY_OUTPUT_ATTEMPTS = 20;
20
+ const CLEAR_SWITCH_ATTEMPTS = 60;
21
+ const STREAM_BATCH_MS = 60;
22
+ const STREAM_HEARTBEAT_MS = 15_000;
23
+ const PERMISSION_MODES = {
24
+ default: {
25
+ approvalPolicy: 'on-request', approvalsReviewer: 'user',
26
+ sandboxPolicy: { type: 'workspaceWrite' },
27
+ },
28
+ 'auto-review': {
29
+ approvalPolicy: 'on-request', approvalsReviewer: 'auto_review',
30
+ sandboxPolicy: { type: 'workspaceWrite' },
31
+ },
32
+ 'full-access': {
33
+ approvalPolicy: 'never', approvalsReviewer: 'user',
34
+ sandboxPolicy: { type: 'dangerFullAccess' },
35
+ },
36
+ };
37
+ const isPermissionMode = (value) => (typeof value === 'string' && Object.hasOwn(PERMISSION_MODES, value));
38
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
39
+ async function paneAgent(commands, claudeEvents, pane) {
40
+ const panes = await commands?.listLivePanes?.();
41
+ const live = Array.isArray(panes) ? panes.find((candidate) => candidate.id === pane) : null;
42
+ if (!live)
43
+ return { live: null, agent: null };
44
+ const agents = await claudeEvents?.identifyPaneAgents?.(panes) || {};
45
+ return { live, agent: agents[pane] || null };
46
+ }
47
+ async function exitCurrentCodex(commands, claudeEvents, pane) {
48
+ if (!commands.sendKey)
49
+ throw new Error('Codex terminal control is unavailable');
50
+ // During an active turn Codex can spend several seconds writing interruption details, token usage and
51
+ // its recovery command. Give each Ctrl+C a full five-second window; only send a second one if the pane
52
+ // still proves Codex is the foreground process after that wait.
53
+ for (let press = 0; press < 2; press += 1) {
54
+ await commands.sendKey(pane, 'C-c');
55
+ for (let attempt = 0; attempt < EXIT_ATTEMPTS; attempt += 1) {
56
+ await pause(EXIT_POLL_MS);
57
+ const current = await paneAgent(commands, claudeEvents, pane);
58
+ if (!current.live || current.agent !== 'codex')
59
+ return current.live;
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+ async function waitForRecoverySession(commands, pane, isId) {
65
+ if (!commands.capturePlain)
66
+ return null;
67
+ // tmux can report pane_current_command=the shell before the last buffered Codex output has been painted.
68
+ // Poll the visible pane briefly instead of treating the first incomplete frame as a permanent failure.
69
+ for (let attempt = 0; attempt < RECOVERY_OUTPUT_ATTEMPTS; attempt += 1) {
70
+ try {
71
+ const sessionId = codexExitSessionId(await commands.capturePlain(pane));
72
+ if (isId(sessionId))
73
+ return sessionId;
74
+ }
75
+ catch {
76
+ return null;
77
+ } // a direct-command pane may have closed with Codex
78
+ await pause(INPUT_SETTLE_MS);
79
+ }
80
+ return null;
81
+ }
82
+ async function clearCurrentCodexThroughTui(codexApp, commands, pane, previousThreadId, wait) {
83
+ if (typeof commands?.exitCopyModeIfActive !== 'function'
84
+ || typeof commands?.sendKey !== 'function'
85
+ || typeof commands?.sendText !== 'function'
86
+ || typeof commands?.sendEnter !== 'function') {
87
+ throw new Error('Codex terminal control is unavailable');
88
+ }
89
+ // /clear is not just thread/start: the remote TUI also replaces its active ChatWidget and redraws the
90
+ // terminal. Drive that native action so terminal and chat remain one session. C-u makes the command exact
91
+ // even if the hidden terminal composer retained a draft; if a modal owns input, confirmation below times
92
+ // out instead of letting Handmux split onto a different thread.
93
+ await commands.exitCopyModeIfActive(pane);
94
+ await commands.sendKey(pane, 'C-u');
95
+ await wait(INPUT_SETTLE_MS);
96
+ await commands.sendText(pane, '/clear');
97
+ await wait(INPUT_SETTLE_MS);
98
+ await commands.sendEnter(pane);
99
+ for (let attempt = 0; attempt < CLEAR_SWITCH_ATTEMPTS; attempt += 1) {
100
+ const discovered = await codexApp.discover(pane);
101
+ if (!discovered?.managed)
102
+ throw new Error('Codex session is no longer managed by Handmux');
103
+ if (discovered.threadId && discovered.threadId !== previousThreadId) {
104
+ return { threadId: discovered.threadId };
105
+ }
106
+ await wait(INPUT_SETTLE_MS);
107
+ }
108
+ throw new Error('Codex terminal did not accept /clear; switch to the terminal, close any open panel, and try again');
109
+ }
110
+ async function binding(codexApp, pane) {
111
+ if (!isPaneId(pane))
112
+ return { error: 'bad pane id', status: 400 };
113
+ try {
114
+ const discovered = await codexApp?.discover?.(pane);
115
+ if (!discovered?.managed)
116
+ return { error: 'Codex session is not managed by Handmux', status: 409 };
117
+ if (discovered.threadId)
118
+ return { pane, threadId: discovered.threadId };
119
+ }
120
+ catch (error) {
121
+ return { error: errorMessage(error), status: 503 };
122
+ }
123
+ return { error: 'Codex session is not bound yet', status: 409 };
124
+ }
125
+ function routeError(res, result) {
126
+ if (!result.error)
127
+ return false;
128
+ res.status(result.status).json({ error: result.error });
129
+ return true;
130
+ }
131
+ function codexError(res, error) {
132
+ const message = errorMessage(error);
133
+ 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);
134
+ return res.status(conflict ? 409 : 503).json({ error: message });
135
+ }
136
+ // Legacy/unsequenced App Server deltas may still be folded. Projected events keep every sequence intact:
137
+ // collapsing two cursor-addressable events would create an artificial gap on the next reconnect.
138
+ export function appendCodexStreamEvent(queue, value) {
139
+ const event = parseCodexStreamEvent(value);
140
+ if (!event)
141
+ return queue;
142
+ const last = queue.at(-1);
143
+ if (event?.sequence == null && event?.type === 'delta' && last?.type === 'delta'
144
+ && last.threadId === event.threadId && last.turnId === event.turnId && last.itemId === event.itemId) {
145
+ last.delta = (last.delta || '') + (event.delta || '');
146
+ }
147
+ else if (event)
148
+ queue.push({ ...event });
149
+ return queue;
150
+ }
151
+ export function codexRoutes({ codexApp, commands, claudeEvents, wait = pause }) {
152
+ const r = express.Router();
153
+ if (!codexApp)
154
+ return r;
155
+ // A takeover is pane-scoped and deliberately kept in server memory. Besides making repeated taps
156
+ // idempotent, this gates discovery while the replacement process starts: App Server may briefly list
157
+ // another historical thread, but the UI must not enter chat until the exact pre-takeover thread appears.
158
+ const takeovers = new Map(); // pane -> { threadId, startedAt }
159
+ const takeoverView = (takeover) => {
160
+ const elapsed = Date.now() - takeover.startedAt;
161
+ return {
162
+ state: elapsed >= TAKEOVER_TIMEOUT_MS ? 'timed-out' : 'starting',
163
+ needsTerminal: elapsed >= TAKEOVER_TERMINAL_HINT_MS,
164
+ };
165
+ };
166
+ r.get('/codex/session', async (req, res) => {
167
+ const pane = req.query.pane;
168
+ if (!isPaneId(pane))
169
+ return res.status(400).json({ error: 'bad pane id' });
170
+ const takeover = takeovers.get(pane);
171
+ try {
172
+ const discovered = await codexApp.discover(pane);
173
+ if (takeover && (!takeover.threadId || discovered?.threadId !== takeover.threadId)) {
174
+ return res.json({ managed: false, threadId: null, takeover: takeoverView(takeover) });
175
+ }
176
+ if (takeover)
177
+ takeovers.delete(pane);
178
+ if (!discovered?.managed || !discovered.threadId) {
179
+ return res.json({ managed: !!discovered?.managed, threadId: discovered?.threadId || null });
180
+ }
181
+ return res.json(await codexApp.status(pane, discovered.threadId));
182
+ }
183
+ catch (error) {
184
+ // A killed plain Codex and its replacement App Server have a normal short disconnect window. Keep
185
+ // the takeover page stable through it instead of flashing a generic connection failure.
186
+ if (takeover)
187
+ return res.json({ managed: false, threadId: null, takeover: takeoverView(takeover) });
188
+ return codexError(res, error);
189
+ }
190
+ });
191
+ r.get('/codex/stream', async (req, res) => {
192
+ const target = await binding(codexApp, req.query.pane);
193
+ if (routeError(res, target))
194
+ return;
195
+ const rawAfter = req.query.after;
196
+ const afterSequence = rawAfter == null || rawAfter === '' ? null : Number(rawAfter);
197
+ if (afterSequence != null && (!Number.isSafeInteger(afterSequence) || afterSequence < 0)) {
198
+ return res.status(400).json({ error: 'bad Codex stream cursor' });
199
+ }
200
+ res.status(200);
201
+ res.set({
202
+ 'Content-Type': 'text/event-stream; charset=utf-8',
203
+ 'Cache-Control': 'no-cache, no-transform',
204
+ Connection: 'keep-alive',
205
+ 'X-Accel-Buffering': 'no',
206
+ });
207
+ res.flushHeaders?.();
208
+ res.write(`data: ${JSON.stringify({ type: 'ready', threadId: target.threadId })}\n\n`);
209
+ let closed = false;
210
+ let unsubscribe = null;
211
+ let flushTimer = null;
212
+ let heartbeat = null;
213
+ const pending = [];
214
+ const flush = () => {
215
+ if (flushTimer)
216
+ clearTimeout(flushTimer);
217
+ flushTimer = null;
218
+ if (closed || !pending.length)
219
+ return;
220
+ const events = pending.splice(0);
221
+ res.write(`data: ${JSON.stringify({ type: 'events', events })}\n\n`);
222
+ res.flush?.();
223
+ };
224
+ const cleanup = () => {
225
+ if (closed)
226
+ return;
227
+ closed = true;
228
+ if (flushTimer)
229
+ clearTimeout(flushTimer);
230
+ if (heartbeat)
231
+ clearInterval(heartbeat);
232
+ flushTimer = null;
233
+ heartbeat = null;
234
+ unsubscribe?.();
235
+ unsubscribe = null;
236
+ };
237
+ const enqueue = (event) => {
238
+ if (closed)
239
+ return;
240
+ if (event?.type === 'disconnected') {
241
+ appendCodexStreamEvent(pending, event);
242
+ flush();
243
+ cleanup();
244
+ res.end();
245
+ return;
246
+ }
247
+ appendCodexStreamEvent(pending, event);
248
+ if (['completed', 'turnCompleted', 'goal', 'goalCleared'].includes(event?.type))
249
+ flush();
250
+ else if (!flushTimer) {
251
+ flushTimer = setTimeout(flush, STREAM_BATCH_MS);
252
+ flushTimer.unref?.();
253
+ }
254
+ };
255
+ req.once('aborted', cleanup);
256
+ res.once('close', cleanup);
257
+ try {
258
+ unsubscribe = await codexApp.subscribe(target.pane, target.threadId, enqueue, afterSequence);
259
+ if (closed) {
260
+ unsubscribe();
261
+ unsubscribe = null;
262
+ return;
263
+ }
264
+ heartbeat = setInterval(() => {
265
+ if (!closed)
266
+ res.write(`: keepalive ${Date.now()}\n\n`);
267
+ }, STREAM_HEARTBEAT_MS);
268
+ heartbeat.unref?.();
269
+ }
270
+ catch (error) {
271
+ if (!closed) {
272
+ res.write(`data: ${JSON.stringify({ type: 'error', message: errorMessage(error) })}\n\n`);
273
+ res.end();
274
+ }
275
+ cleanup();
276
+ }
277
+ return undefined;
278
+ });
279
+ r.post('/codex/takeover', async (req, res) => {
280
+ const pane = req.body?.pane;
281
+ if (!isPaneId(pane))
282
+ return res.status(400).json({ error: 'bad pane id' });
283
+ const pending = takeovers.get(pane);
284
+ if (pending)
285
+ return res.json({ started: true, takeover: takeoverView(pending) });
286
+ try {
287
+ const discovered = await codexApp.discover(pane);
288
+ if (discovered?.managed && discovered.threadId) {
289
+ return res.json({ started: false, managed: true, threadId: discovered.threadId });
290
+ }
291
+ if (discovered?.managed)
292
+ return res.status(409).json({ error: 'codex-session-starting' });
293
+ // Re-check live process identity immediately before interacting with the pane. A stale client must
294
+ // never send slash commands to a pane whose Codex has already exited or changed.
295
+ const current = await paneAgent(commands, claudeEvents, pane);
296
+ if (!current.live)
297
+ return res.status(404).json({ error: 'codex-pane-gone' });
298
+ if (current.agent !== 'codex')
299
+ return res.status(409).json({ error: 'codex-pane-changed' });
300
+ const driver = getAgent('codex');
301
+ const takeover = { threadId: null, startedAt: Date.now() };
302
+ takeovers.set(pane, takeover);
303
+ try {
304
+ const exited = await exitCurrentCodex(commands, claudeEvents, pane);
305
+ if (!exited) {
306
+ takeovers.delete(pane);
307
+ return res.status(409).json({ error: 'codex-exit-blocked' });
308
+ }
309
+ const sessionId = await waitForRecoverySession(commands, pane, driver.sessions.isId);
310
+ if (!sessionId) {
311
+ takeovers.delete(pane);
312
+ return res.status(409).json({ error: 'codex-session-unconfirmed' });
313
+ }
314
+ takeover.threadId = sessionId;
315
+ await pause(INPUT_SETTLE_MS); // let the restored shell finish drawing its prompt
316
+ const managedResumeCmd = driver.sessions.managedResumeCmd;
317
+ if (!managedResumeCmd || !commands.runPaneCommand) {
318
+ throw new Error('Codex managed resume command is unavailable');
319
+ }
320
+ await commands.runPaneCommand(pane, managedResumeCmd(sessionId));
321
+ }
322
+ catch (error) {
323
+ takeovers.delete(pane);
324
+ throw error;
325
+ }
326
+ return res.json({ started: true, takeover: takeoverView(takeover) });
327
+ }
328
+ catch (error) {
329
+ return codexError(res, error);
330
+ }
331
+ });
332
+ r.post('/codex/send', async (req, res) => {
333
+ const target = await binding(codexApp, req.body?.pane);
334
+ if (routeError(res, target))
335
+ return;
336
+ const text = typeof req.body?.text === 'string' ? req.body.text.trim() : '';
337
+ if (!text)
338
+ return res.status(400).json({ error: 'message is empty' });
339
+ const requestId = typeof req.body?.requestId === 'string' ? req.body.requestId.trim() : '';
340
+ if (requestId && (requestId.length > 128 || !/^[a-zA-Z0-9._:-]+$/.test(requestId))) {
341
+ return res.status(400).json({ error: 'bad Codex request id' });
342
+ }
343
+ try {
344
+ return res.json(await codexApp.send(target.pane, target.threadId, text, requestId || null));
345
+ }
346
+ catch (error) {
347
+ return codexError(res, error);
348
+ }
349
+ });
350
+ const queueMethods = [
351
+ ['/codex/queue/steer', 'steerQueued'], ['/codex/queue/remove', 'removeQueued'],
352
+ ];
353
+ for (const [path, method] of queueMethods) {
354
+ r.post(path, async (req, res) => {
355
+ const target = await binding(codexApp, req.body?.pane);
356
+ if (routeError(res, target))
357
+ return;
358
+ const id = typeof req.body?.id === 'string' ? req.body.id.trim() : '';
359
+ if (!id || id.length > 128)
360
+ return res.status(400).json({ error: 'bad queued message id' });
361
+ try {
362
+ return res.json(await codexApp[method](target.pane, target.threadId, id));
363
+ }
364
+ catch (error) {
365
+ return codexError(res, error);
366
+ }
367
+ });
368
+ }
369
+ r.post('/codex/queue/edit/begin', async (req, res) => {
370
+ const target = await binding(codexApp, req.body?.pane);
371
+ if (routeError(res, target))
372
+ return;
373
+ const id = typeof req.body?.id === 'string' ? req.body.id.trim() : '';
374
+ if (!id || id.length > 128)
375
+ return res.status(400).json({ error: 'bad queued message id' });
376
+ try {
377
+ return res.json(await codexApp.beginQueuedEdit(target.pane, target.threadId, id));
378
+ }
379
+ catch (error) {
380
+ return codexError(res, error);
381
+ }
382
+ });
383
+ for (const action of ['renew', 'commit', 'cancel']) {
384
+ r.post(`/codex/queue/edit/${action}`, async (req, res) => {
385
+ const target = await binding(codexApp, req.body?.pane);
386
+ if (routeError(res, target))
387
+ return;
388
+ const id = typeof req.body?.id === 'string' ? req.body.id.trim() : '';
389
+ const token = typeof req.body?.token === 'string' ? req.body.token.trim() : '';
390
+ if (!id || id.length > 128)
391
+ return res.status(400).json({ error: 'bad queued message id' });
392
+ if (!token || token.length > 128)
393
+ return res.status(400).json({ error: 'bad queued message edit token' });
394
+ if (action === 'commit') {
395
+ const text = typeof req.body?.text === 'string' ? req.body.text.trim() : '';
396
+ if (!text)
397
+ return res.status(400).json({ error: 'message is empty' });
398
+ try {
399
+ return res.json(await codexApp.commitQueuedEdit(target.pane, target.threadId, id, token, text));
400
+ }
401
+ catch (error) {
402
+ return codexError(res, error);
403
+ }
404
+ }
405
+ try {
406
+ const result = action === 'renew'
407
+ ? await codexApp.renewQueuedEdit(target.pane, target.threadId, id, token)
408
+ : await codexApp.cancelQueuedEdit(target.pane, target.threadId, id, token);
409
+ return res.json(result);
410
+ }
411
+ catch (error) {
412
+ return codexError(res, error);
413
+ }
414
+ });
415
+ }
416
+ r.post('/codex/compact', async (req, res) => {
417
+ const target = await binding(codexApp, req.body?.pane);
418
+ if (routeError(res, target))
419
+ return;
420
+ try {
421
+ res.json(await codexApp.compact(target.pane, target.threadId));
422
+ }
423
+ catch (error) {
424
+ codexError(res, error);
425
+ }
426
+ });
427
+ r.post('/codex/clear', async (req, res) => {
428
+ const target = await binding(codexApp, req.body?.pane);
429
+ if (routeError(res, target))
430
+ return;
431
+ try {
432
+ res.json(await clearCurrentCodexThroughTui(codexApp, commands, target.pane, target.threadId, wait));
433
+ }
434
+ catch (error) {
435
+ codexError(res, error);
436
+ }
437
+ });
438
+ r.get('/codex/models', async (req, res) => {
439
+ const target = await binding(codexApp, req.query.pane);
440
+ if (routeError(res, target))
441
+ return;
442
+ try {
443
+ res.json({ models: await codexApp.models(target.pane, target.threadId) });
444
+ }
445
+ catch (error) {
446
+ codexError(res, error);
447
+ }
448
+ });
449
+ r.get('/codex/goal', async (req, res) => {
450
+ const target = await binding(codexApp, req.query.pane);
451
+ if (routeError(res, target))
452
+ return;
453
+ try {
454
+ res.json({ goal: await codexApp.getGoal(target.pane, target.threadId) });
455
+ }
456
+ catch (error) {
457
+ codexError(res, error);
458
+ }
459
+ });
460
+ r.post('/codex/goal/start', async (req, res) => {
461
+ const target = await binding(codexApp, req.body?.pane);
462
+ if (routeError(res, target))
463
+ return;
464
+ const objective = typeof req.body?.objective === 'string' ? req.body.objective.trim() : '';
465
+ if (!objective || objective.length > 4_000) {
466
+ return res.status(400).json({ error: 'bad goal objective' });
467
+ }
468
+ try {
469
+ return res.json({ goal: await codexApp.startGoal(target.pane, target.threadId, objective) });
470
+ }
471
+ catch (error) {
472
+ return codexError(res, error);
473
+ }
474
+ });
475
+ r.post('/codex/goal', async (req, res) => {
476
+ const target = await binding(codexApp, req.body?.pane);
477
+ if (routeError(res, target))
478
+ return;
479
+ const updates = {};
480
+ if (Object.hasOwn(req.body || {}, 'objective')) {
481
+ const objective = req.body.objective;
482
+ const trimmed = typeof objective === 'string' ? objective.trim() : '';
483
+ if (!trimmed || trimmed.length > 4_000) {
484
+ return res.status(400).json({ error: 'bad goal objective' });
485
+ }
486
+ updates.objective = trimmed;
487
+ }
488
+ if (Object.hasOwn(req.body || {}, 'status')) {
489
+ const status = req.body.status;
490
+ if (status !== 'active' && status !== 'paused')
491
+ return res.status(400).json({ error: 'bad goal status' });
492
+ updates.status = status;
493
+ }
494
+ if (!Object.keys(updates).length)
495
+ return res.status(400).json({ error: 'no goal update supplied' });
496
+ try {
497
+ return res.json({ goal: await codexApp.updateGoal(target.pane, target.threadId, updates) });
498
+ }
499
+ catch (error) {
500
+ return codexError(res, error);
501
+ }
502
+ });
503
+ r.post('/codex/goal/clear', async (req, res) => {
504
+ const target = await binding(codexApp, req.body?.pane);
505
+ if (routeError(res, target))
506
+ return;
507
+ try {
508
+ res.json(await codexApp.clearGoal(target.pane, target.threadId));
509
+ }
510
+ catch (error) {
511
+ codexError(res, error);
512
+ }
513
+ });
514
+ r.post('/codex/settings', async (req, res) => {
515
+ const target = await binding(codexApp, req.body?.pane);
516
+ if (routeError(res, target))
517
+ return;
518
+ const updates = {};
519
+ for (const key of ['model', 'effort']) {
520
+ const value = req.body?.[key];
521
+ if (value == null)
522
+ continue;
523
+ if (typeof value !== 'string' || !value.trim() || value.length > 128) {
524
+ return res.status(400).json({ error: `bad ${key}` });
525
+ }
526
+ updates[key] = value.trim();
527
+ }
528
+ if (Object.hasOwn(req.body || {}, 'serviceTier')) {
529
+ const value = req.body.serviceTier;
530
+ if (value !== null && (typeof value !== 'string' || !value.trim() || value.length > 128)) {
531
+ return res.status(400).json({ error: 'bad serviceTier' });
532
+ }
533
+ updates.serviceTier = value === null ? null : value.trim();
534
+ }
535
+ const approvalPolicy = req.body?.approvalPolicy;
536
+ if (approvalPolicy != null) {
537
+ if (!['untrusted', 'on-request', 'never'].includes(approvalPolicy)) {
538
+ return res.status(400).json({ error: 'bad approvalPolicy' });
539
+ }
540
+ updates.approvalPolicy = approvalPolicy;
541
+ }
542
+ const permissionMode = req.body?.permissionMode;
543
+ if (permissionMode != null) {
544
+ if (!isPermissionMode(permissionMode)) {
545
+ return res.status(400).json({ error: 'bad permissionMode' });
546
+ }
547
+ Object.assign(updates, PERMISSION_MODES[permissionMode]);
548
+ }
549
+ if (!Object.keys(updates).length)
550
+ return res.status(400).json({ error: 'no settings supplied' });
551
+ try {
552
+ return res.json({ settings: await codexApp.updateSettings(target.pane, target.threadId, updates) });
553
+ }
554
+ catch (error) {
555
+ return codexError(res, error);
556
+ }
557
+ });
558
+ r.post('/codex/interrupt', async (req, res) => {
559
+ const target = await binding(codexApp, req.body?.pane);
560
+ if (routeError(res, target))
561
+ return;
562
+ try {
563
+ res.json(await codexApp.interrupt(target.pane, target.threadId));
564
+ }
565
+ catch (error) {
566
+ codexError(res, error);
567
+ }
568
+ });
569
+ r.post('/codex/approval', async (req, res) => {
570
+ const target = await binding(codexApp, req.body?.pane);
571
+ if (routeError(res, target))
572
+ return;
573
+ const requestId = req.body?.requestId;
574
+ const decision = req.body?.decision;
575
+ if ((typeof requestId !== 'string' && typeof requestId !== 'number') || typeof decision !== 'string') {
576
+ return res.status(400).json({ error: 'bad approval response' });
577
+ }
578
+ try {
579
+ return res.json(await codexApp.decide(target.pane, target.threadId, requestId, decision));
580
+ }
581
+ catch (error) {
582
+ return codexError(res, error);
583
+ }
584
+ });
585
+ r.post('/codex/input', async (req, res) => {
586
+ const target = await binding(codexApp, req.body?.pane);
587
+ if (routeError(res, target))
588
+ return;
589
+ const requestId = req.body?.requestId;
590
+ const answers = req.body?.answers;
591
+ const entries = answers && typeof answers === 'object' && !Array.isArray(answers)
592
+ ? Object.entries(answers) : [];
593
+ if ((typeof requestId !== 'string' && typeof requestId !== 'number') || !entries.length
594
+ || entries.some(([questionId, value]) => !questionId || !Array.isArray(value) || !value.length
595
+ || value.some((answer) => typeof answer !== 'string' || !answer.trim()))) {
596
+ return res.status(400).json({ error: 'bad user input response' });
597
+ }
598
+ try {
599
+ return res.json(await codexApp.answerInput(target.pane, target.threadId, requestId, answers));
600
+ }
601
+ catch (error) {
602
+ return codexError(res, error);
603
+ }
604
+ });
605
+ return r;
606
+ }