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
@@ -0,0 +1,244 @@
1
+ // Terminal pane I/O routes: read the screen (/history), type text/keys, resize/reflow, and forward
2
+ // wheel scroll to full-screen apps. This is the hot path the phone polls. Mounted under /api.
3
+ import express from 'express';
4
+ import { createHash } from 'node:crypto';
5
+ import { gzipSync } from 'node:zlib';
6
+ import { isPaneId, isWindowId } from '../tmux/commands.js';
7
+ import { capTrailingBlankRows } from '../trimCapture.js';
8
+ import { isAllowedKey } from '../keyNames.js';
9
+ import { restoreCaptureBackgrounds } from '../captureBackground.js';
10
+ import { serializePaneInput } from '../paneInput.js';
11
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
12
+ const requestBody = (req) => isRecord(req.body) ? req.body : {};
13
+ export { isAllowedKey } from '../keyNames.js';
14
+ // Keys the mobile keyboard may send via /keys. A controlled vocabulary of named tmux keys, PLUS
15
+ // live-modifier combinations (Ctrl/Alt + a single letter or digit) so the keyboard's Ctrl modifier
16
+ // can compose any readline/tmux binding (C-r, C-w, C-a, the tmux prefix, …) without enumerating each
17
+ // one here. tmux send-keys key names are themselves a closed set, so this stays a strict allowlist
18
+ // (never a passthrough): a key either names an approved token or matches the modifier shape, or it's
19
+ // rejected. The old fixed C-c/C-d/C-z/C-l/C-r/C-o/C-e all still match `C-[a-z0-9]`.
20
+ // Keep a short fallback gap for applications that do not negotiate bracketed paste. Supporting TUIs get
21
+ // an explicit paste boundary from commands.sendText; the delay is no longer the correctness mechanism.
22
+ const RAW_INPUT_MAX_BYTES = 16 * 1024;
23
+ const SUBMIT_GAP_MS = 120;
24
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
25
+ const validHexInput = (hex) => typeof hex === 'string'
26
+ && hex.length >= 2
27
+ && hex.length <= RAW_INPUT_MAX_BYTES * 2
28
+ && hex.length % 2 === 0
29
+ && /^[0-9a-f]+$/i.test(hex);
30
+ const isMissingPane = (error) => /(?:can't find|cannot find|no such|unknown|not found).*\bpane\b|\bpane\b.*(?:can't find|cannot find|no such|unknown|not found)/i
31
+ .test(error instanceof Error ? error.message : String(error));
32
+ export function terminalRoutes({ commands }) {
33
+ const r = express.Router();
34
+ r.get('/history', async (req, res, next) => {
35
+ const paneId = req.query.pane;
36
+ if (!isPaneId(paneId))
37
+ return res.status(400).json({ error: 'bad pane id' });
38
+ const lines = Math.min(Math.max(Number(req.query.lines) || 1000, 1), 5000);
39
+ try {
40
+ // Read the pane's state FIRST — whether it's on the alternate screen decides how we capture it.
41
+ const { width, height, cursorX, cursorY, cursorVisible, altScreen, mouseAware } = await commands.paneInfo(paneId);
42
+ // An ALT-screen pane (a full-screen app: vim/less/htop) is a fixed height×width screen with NO
43
+ // scrollback. Asking tmux for history (-S -lines) there bleeds the MAIN screen's scrollback in
44
+ // ABOVE the app — you'd scroll up into terminal history that isn't the app's — and capping its
45
+ // trailing blank rows mangles the fixed screen (those blanks are real cells). So capture an alt
46
+ // pane as exactly its visible screen (lines=0) and skip the blank-trim. A normal pane still pulls
47
+ // `lines` of scrollback and caps the empty grid below the cursor (fresh shell = "prompt + a wall of
48
+ // blank rows") so the phone's bottom-anchored render shows content, not blank. See trimCapture.js.
49
+ const captured = await commands.capturePane(paneId, altScreen ? 0 : lines);
50
+ const capturePaneRow = commands.capturePaneRow;
51
+ const restored = typeof capturePaneRow === 'function'
52
+ ? await restoreCaptureBackgrounds(captured, height, (row) => capturePaneRow(paneId, row))
53
+ : {
54
+ ansi: captured,
55
+ historyLines: Math.max(0, (captured.endsWith('\n') ? captured.slice(0, -1) : captured).split('\n').length - height),
56
+ };
57
+ const raw = restored.ansi;
58
+ const ansi = altScreen ? raw : capTrailingBlankRows(raw);
59
+ // The cursor's row counted from the BOTTOM of the (trimmed) capture. The live screen is the
60
+ // capture's last `height` rows, so the cursor sits `height-1-cursorY` rows above the bottom —
61
+ // less however many trailing blank rows capTrailingBlankRows dropped (all of them below the
62
+ // cursor). The client re-places xterm's cursor this many rows up from the seed's last row.
63
+ const rowsOf = (value) => (value.endsWith('\n') ? value.slice(0, -1) : value).split('\n').length;
64
+ const historyLines = altScreen ? 0 : restored.historyLines;
65
+ const cur = {
66
+ row: Math.max(0, (height - 1 - cursorY) - (rowsOf(raw) - rowsOf(ansi))),
67
+ col: cursorX, vis: cursorVisible,
68
+ };
69
+ // Short content hash over size + cursor + ansi. The client echoes its last hash as ?since=… ;
70
+ // an unchanged screen returns 204 (empty) so an idle pane stops re-sending the whole capture.
71
+ // The cursor is folded in so a bare left/right (which moves the cursor but not the text) still
72
+ // yields a fresh frame — otherwise the move would 204 and the cursor would never visibly track.
73
+ // mouseAware is folded in too: toggling an app's mouse mode (e.g. `:set mouse=a` in vim) changes
74
+ // whether a swipe should wheel-scroll or just hint, and the client only re-reads it on a fresh frame.
75
+ const hash = createHash('sha1')
76
+ .update(`${width}x${height}\n${cur.col},${cursorY},${cur.vis ? 1 : 0}\n${altScreen ? 1 : 0}${mouseAware ? 'm' : ''}\n${ansi}`)
77
+ .digest('hex').slice(0, 16);
78
+ if (req.query.since === hash)
79
+ return res.status(204).end();
80
+ const json = JSON.stringify({
81
+ ansi, width, height, historyLines, hash, cur, alt: altScreen, mouseAware,
82
+ });
83
+ res.set('Content-Type', 'application/json');
84
+ res.set('Vary', 'Accept-Encoding'); // both 200 branches vary on encoding (correct for any caching proxy)
85
+ // Capture text is mostly SGR codes + spaces — gzip crushes it ~10x. (204s are empty, never gzipped.)
86
+ if (/\bgzip\b/.test(req.headers['accept-encoding'] || '')) {
87
+ res.set('Content-Encoding', 'gzip');
88
+ // gzipSync is fine here — captures are KBs; the event-loop stall is sub-ms.
89
+ return res.end(gzipSync(json));
90
+ }
91
+ else {
92
+ return res.end(json);
93
+ }
94
+ }
95
+ catch (e) {
96
+ return next(e);
97
+ }
98
+ });
99
+ r.post('/send', async (req, res, next) => {
100
+ const { pane, text, enter } = requestBody(req);
101
+ if (!isPaneId(pane))
102
+ return res.status(400).json({ error: 'bad pane id' });
103
+ const body = typeof text === 'string' ? text : '';
104
+ try {
105
+ await serializePaneInput(pane, async () => {
106
+ await commands.exitCopyModeIfActive(pane);
107
+ await commands.sendText(pane, body);
108
+ if (enter) {
109
+ if (body)
110
+ await delay(SUBMIT_GAP_MS); // a bare Enter has nothing to settle — send at once
111
+ await commands.sendEnter(pane);
112
+ }
113
+ });
114
+ return res.json({ ok: true });
115
+ }
116
+ catch (e) {
117
+ return next(e);
118
+ }
119
+ });
120
+ r.post('/input', async (req, res, next) => {
121
+ const { pane, hex } = requestBody(req);
122
+ if (!isPaneId(pane))
123
+ return res.status(400).json({ error: 'bad pane id' });
124
+ if (!validHexInput(hex))
125
+ return res.status(400).json({ error: 'bad input bytes' });
126
+ try {
127
+ await serializePaneInput(pane, async () => {
128
+ await commands.exitCopyModeIfActive(pane);
129
+ await commands.sendHexInput(pane, hex);
130
+ });
131
+ return res.json({ ok: true });
132
+ }
133
+ catch (error) {
134
+ if (isMissingPane(error))
135
+ return res.status(404).json({ error: 'pane not found' });
136
+ return next(error);
137
+ }
138
+ });
139
+ // Resize the tmux window so it reflows to the phone (auto:false), or hand sizing back to
140
+ // attached clients (auto:true). NOTE: this mutates the shared window — the PC's view of it
141
+ // changes too.
142
+ r.get('/layout', async (req, res, next) => {
143
+ if (!isWindowId(req.query.window))
144
+ return res.status(400).json({ error: 'bad window id' });
145
+ try {
146
+ return res.json({ layout: await commands.getWindowLayout(req.query.window) });
147
+ }
148
+ catch (e) {
149
+ return next(e);
150
+ }
151
+ });
152
+ // Restore only the pane arrangement. Window sizing remains untouched; handing the whole window
153
+ // back to an attached client is the separate `auto` resize operation below.
154
+ r.post('/layout', async (req, res, next) => {
155
+ const { window, layout } = requestBody(req);
156
+ if (!isWindowId(window))
157
+ return res.status(400).json({ error: 'bad window id' });
158
+ if (typeof layout !== 'string' || !layout)
159
+ return res.status(400).json({ error: 'bad window layout' });
160
+ try {
161
+ await commands.applyWindowLayout(window, layout);
162
+ return res.json({ ok: true });
163
+ }
164
+ catch (e) {
165
+ return next(e);
166
+ }
167
+ });
168
+ r.post('/resize', async (req, res, next) => {
169
+ const { window, pane, cols, rows, auto, layout } = requestBody(req);
170
+ const c = Math.min(Math.max(Number(cols) || 0, 20), 500);
171
+ try {
172
+ if (auto) {
173
+ if (!isWindowId(window))
174
+ return res.status(400).json({ error: 'bad window id' });
175
+ // restore the split arrangement (resizePane) then hand window sizing back (resizeWindow)
176
+ if (typeof layout === 'string' && layout)
177
+ await commands.applyWindowLayout(window, layout);
178
+ await commands.restoreWindowSize(window);
179
+ }
180
+ else if (isPaneId(pane)) {
181
+ await commands.resizePane(pane, c); // a pane in a split — resize only it
182
+ }
183
+ else if (isWindowId(window)) {
184
+ const rw = rows != null ? Math.min(Math.max(Number(rows) || 0, 5), 500) : null;
185
+ await commands.resizeWindow(window, c, rw); // a lone pane fills the window
186
+ }
187
+ else {
188
+ return res.status(400).json({ error: 'bad resize target' });
189
+ }
190
+ return res.json({ ok: true });
191
+ }
192
+ catch (e) {
193
+ return next(e);
194
+ }
195
+ });
196
+ r.post('/keys', async (req, res, next) => {
197
+ const { pane, keys } = requestBody(req);
198
+ if (!isPaneId(pane))
199
+ return res.status(400).json({ error: 'bad pane id' });
200
+ if (!Array.isArray(keys) || keys.some((k) => !isAllowedKey(k))) {
201
+ return res.status(400).json({ error: 'disallowed key' });
202
+ }
203
+ try {
204
+ await serializePaneInput(pane, async () => {
205
+ await commands.exitCopyModeIfActive(pane);
206
+ for (const k of keys)
207
+ await commands.sendKey(pane, k);
208
+ });
209
+ return res.json({ ok: true });
210
+ }
211
+ catch (e) {
212
+ return next(e);
213
+ }
214
+ });
215
+ // Swipe-to-scroll for full-screen (ALT-screen) apps: the client can't scroll the alt buffer itself
216
+ // (no scrollback), so it forwards the finger travel here and we inject wheel events the app scrolls on.
217
+ // SAFETY: we re-read the pane's mouse state at inject time and REFUSE unless it's actually reporting
218
+ // mouse — otherwise the raw escape bytes would print into the shell as literal `<65;…M` garbage. The
219
+ // client's cached mouseAware is only a UX hint; this check is the real guard (mouse mode can toggle
220
+ // between the poll and the gesture). Positioned at the pane centre so a split-aware app scrolls the
221
+ // region the finger is over; a lone full-screen app ignores the position.
222
+ r.post('/scroll', async (req, res, next) => {
223
+ const { pane, dir, lines } = requestBody(req);
224
+ if (!isPaneId(pane))
225
+ return res.status(400).json({ error: 'bad pane id' });
226
+ if (dir !== 'up' && dir !== 'down')
227
+ return res.status(400).json({ error: 'bad dir' });
228
+ try {
229
+ const { altScreen, mouseAware, mouseSgr, width, height } = await commands.paneInfo(pane);
230
+ if (!altScreen || !mouseAware)
231
+ return res.json({ ok: false, reason: 'no-mouse' });
232
+ await commands.sendWheel(pane, dir, Number(lines), {
233
+ sgr: mouseSgr,
234
+ col: Math.max(1, Math.floor((width || 2) / 2)),
235
+ row: Math.max(1, Math.floor((height || 2) / 2)),
236
+ });
237
+ return res.json({ ok: true });
238
+ }
239
+ catch (e) {
240
+ return next(e);
241
+ }
242
+ });
243
+ return r;
244
+ }
@@ -0,0 +1,267 @@
1
+ import express from 'express';
2
+ import { validateRecoveryMapping } from '../workspace/mapping.js';
3
+ import { isWorkspaceSafeId, parseWorkspaceRestoreRequest, } from '../workspaceProtocol.js';
4
+ const CHANGE_REASONS = new Set(['boot-changed', 'tmux-changed']);
5
+ const OPERATION_STATUSES = new Set(['pending', 'running', 'succeeded', 'partial', 'failed', 'interrupted']);
6
+ const RESULT_STATUSES = new Set(['restored', 'already-present', 'failed']);
7
+ const RESULT_STAGES = new Set(['plan', 'topology', 'agent', 'restore', 'reconcile']);
8
+ const WARNING_CODES = new Set(['cwd-fallback', 'layout-fallback', 'agent-warning', 'live-reconcile-failed', 'workspace-unavailable', 'restore-warning']);
9
+ const ERROR_MESSAGES = {
10
+ 'restore-interrupted': 'restore was interrupted; retry the restore',
11
+ 'checkpoint-not-found': 'checkpoint is unavailable; choose another checkpoint',
12
+ 'storage-full': 'workspace storage is full; free disk space and retry',
13
+ 'permission-denied': 'workspace storage is not writable; check permissions and retry',
14
+ 'plan-failed': 'restore plan is no longer usable; refresh and retry',
15
+ 'agent-unavailable': 'agent resume failed; open the restored shell and retry manually',
16
+ 'tmux-unavailable': 'tmux is unavailable; retry the restore',
17
+ 'restore-failed': 'workspace restore failed; retry the restore',
18
+ };
19
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
+ const record = (value) => (typeof value === 'object' && value !== null && !Array.isArray(value)
21
+ ? value
22
+ : null);
23
+ function isCheckpointMissing(error) {
24
+ const details = record(error);
25
+ if (details?.code === 'WORKSPACE_CHECKPOINT_NOT_FOUND' || details?.status === 404)
26
+ return true;
27
+ const message = error instanceof Error ? error.message : String(error);
28
+ return /(?:checkpoint.*(?:missing|not found)|no valid checkpoint)/i.test(message);
29
+ }
30
+ function sendFailure(res, error, { checkpoint = false } = {}) {
31
+ if (checkpoint && isCheckpointMissing(error)) {
32
+ return res.status(404).json({ error: 'checkpoint not found' });
33
+ }
34
+ return res.status(500).json({ error: 'workspace unavailable' });
35
+ }
36
+ function asyncHandler(handler, options = {}) {
37
+ return async (req, res) => {
38
+ try {
39
+ await handler(req, res);
40
+ }
41
+ catch (error) {
42
+ sendFailure(res, error, options);
43
+ }
44
+ };
45
+ }
46
+ function checkpointId(value, fallback) {
47
+ const id = value === undefined ? fallback : value;
48
+ return isWorkspaceSafeId(id) ? id : null;
49
+ }
50
+ function timestamp(value) {
51
+ return typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null;
52
+ }
53
+ function count(value) {
54
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0;
55
+ }
56
+ function displayName(value) {
57
+ return typeof value === 'string' && value.length > 0 && value.length <= 256
58
+ && !/[\x00-\x1f\x7f]/.test(value) ? value : null;
59
+ }
60
+ function projectCheckpoint(row) {
61
+ const source = record(row);
62
+ const id = isWorkspaceSafeId(source?.id) ? source.id : null;
63
+ const value = record(source?.value);
64
+ if (source?.status !== 'ok' || !value) {
65
+ const corrupt = source?.status === 'corrupt';
66
+ return {
67
+ status: corrupt ? 'corrupt' : 'unavailable',
68
+ id,
69
+ capturedAt: null,
70
+ archivedAt: null,
71
+ sessionCount: 0,
72
+ windowCount: 0,
73
+ paneCount: 0,
74
+ agentCount: 0,
75
+ endedReason: null,
76
+ errorCode: corrupt ? 'checkpoint-corrupt' : 'checkpoint-unavailable',
77
+ };
78
+ }
79
+ const sessions = Array.isArray(value.sessions) ? value.sessions : [];
80
+ const windows = Array.isArray(value.windows) ? value.windows : [];
81
+ const panes = windows.flatMap((window) => {
82
+ const item = record(window);
83
+ return Array.isArray(item?.panes) ? item.panes : [];
84
+ });
85
+ const environment = record(value.environment);
86
+ return {
87
+ status: 'ok',
88
+ id,
89
+ capturedAt: timestamp(value.capturedAt),
90
+ archivedAt: timestamp(value.archivedAt),
91
+ sessionCount: sessions.length,
92
+ windowCount: windows.length,
93
+ paneCount: panes.length,
94
+ agentCount: panes.filter((pane) => record(record(pane)?.agent) !== null).length,
95
+ endedReason: typeof environment?.endedReason === 'string' && CHANGE_REASONS.has(environment.endedReason) ? environment.endedReason : null,
96
+ errorCode: null,
97
+ };
98
+ }
99
+ function errorText(error) {
100
+ if (typeof error === 'string')
101
+ return error.toLowerCase();
102
+ if (error instanceof Error)
103
+ return error.message.toLowerCase();
104
+ return '';
105
+ }
106
+ function errorCode(error, { stage = null, status = null } = {}) {
107
+ const text = errorText(error);
108
+ if (status === 'interrupted' || /interrupt|process restart/.test(text))
109
+ return 'restore-interrupted';
110
+ if (/enospc|disk full|no space/.test(text))
111
+ return 'storage-full';
112
+ if (/eacces|eperm|permission|not writable/.test(text))
113
+ return 'permission-denied';
114
+ if (/checkpoint.*(?:missing|not found|unavailable)/.test(text))
115
+ return 'checkpoint-not-found';
116
+ if (stage === 'plan' || /restore plan|planning/.test(text))
117
+ return 'plan-failed';
118
+ if (stage === 'agent' || /\b(?:agent|claude|codex)\b|command not found|cli unavailable/.test(text))
119
+ return 'agent-unavailable';
120
+ if (stage === 'topology' || /\btmux\b|topology|server disappeared/.test(text))
121
+ return 'tmux-unavailable';
122
+ if (text || status === 'failed')
123
+ return 'restore-failed';
124
+ return null;
125
+ }
126
+ function warningCode(warning) {
127
+ const text = errorText(warning);
128
+ if (/live reconcile|reconcile.*(?:fail|unavailable)/.test(text))
129
+ return 'live-reconcile-failed';
130
+ if (/\blayout\b/.test(text))
131
+ return 'layout-fallback';
132
+ if (/\b(?:agent|claude|codex)\b|command not found|cli unavailable/.test(text))
133
+ return 'agent-warning';
134
+ if (/\bcwd\b|\bdirectory\b|fallback.*home|missing.*path/.test(text))
135
+ return 'cwd-fallback';
136
+ if (/enospc|disk full|no space|eacces|eperm|permission|not writable/.test(text))
137
+ return 'workspace-unavailable';
138
+ return 'restore-warning';
139
+ }
140
+ function projectWarningCodes(value, existing) {
141
+ const codes = [];
142
+ if (Array.isArray(existing)) {
143
+ for (const code of existing) {
144
+ if (typeof code === 'string' && WARNING_CODES.has(code))
145
+ codes.push(code);
146
+ }
147
+ }
148
+ if (Array.isArray(value))
149
+ for (const warning of value)
150
+ codes.push(warningCode(warning));
151
+ return [...new Set(codes)];
152
+ }
153
+ function projectMapping(value, id) {
154
+ if (!value || !id)
155
+ return null;
156
+ try {
157
+ const mapping = validateRecoveryMapping(value, id);
158
+ const copyEntries = (entries) => Object.fromEntries(Object.entries(entries));
159
+ return {
160
+ id: mapping.id,
161
+ checkpointId: mapping.checkpointId,
162
+ restoredAt: mapping.restoredAt,
163
+ names: copyEntries(mapping.names),
164
+ runtime: {
165
+ sessions: copyEntries(mapping.runtime.sessions),
166
+ windows: copyEntries(mapping.runtime.windows),
167
+ panes: copyEntries(mapping.runtime.panes),
168
+ },
169
+ logical: {
170
+ sessions: copyEntries(mapping.logical.sessions),
171
+ windows: copyEntries(mapping.logical.windows),
172
+ panes: copyEntries(mapping.logical.panes),
173
+ },
174
+ };
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ }
180
+ function projectResult(result) {
181
+ const source = record(result);
182
+ const status = typeof source?.status === 'string' && RESULT_STATUSES.has(source.status)
183
+ ? source.status : 'failed';
184
+ const stage = typeof source?.stage === 'string' && RESULT_STAGES.has(source.stage)
185
+ ? source.stage : null;
186
+ const code = errorCode(source?.error, { stage, status });
187
+ return {
188
+ logicalId: typeof source?.logicalId === 'string' && UUID.test(source.logicalId) ? source.logicalId : null,
189
+ sourceName: displayName(source?.sourceName),
190
+ targetName: displayName(source?.targetName),
191
+ status,
192
+ stage,
193
+ errorCode: code,
194
+ errorMessage: code ? ERROR_MESSAGES[code] : null,
195
+ warningCodes: projectWarningCodes(source?.warnings, source?.warningCodes),
196
+ };
197
+ }
198
+ function projectOperation(operation) {
199
+ const source = record(operation);
200
+ const request = record(source?.request) ?? {};
201
+ const id = checkpointId(request.checkpointId, null);
202
+ const status = typeof source?.status === 'string' && OPERATION_STATUSES.has(source.status)
203
+ ? source.status : 'failed';
204
+ const code = errorCode(source?.error, { status });
205
+ const progress = record(source?.progress);
206
+ const summary = record(source?.summary);
207
+ return {
208
+ id: isWorkspaceSafeId(source?.id) ? source.id : null,
209
+ status,
210
+ request: {
211
+ checkpointId: id,
212
+ sessions: Array.isArray(request.sessions) ? request.sessions.map(displayName).filter(Boolean) : [],
213
+ historical: request.historical === true,
214
+ },
215
+ createdAt: timestamp(source?.createdAt),
216
+ updatedAt: timestamp(source?.updatedAt),
217
+ startedAt: timestamp(source?.startedAt),
218
+ completedAt: timestamp(source?.completedAt),
219
+ progress: {
220
+ completed: count(progress?.completed),
221
+ total: count(progress?.total),
222
+ },
223
+ summary: {
224
+ sessions: count(summary?.sessions),
225
+ windows: count(summary?.windows),
226
+ panes: count(summary?.panes),
227
+ },
228
+ results: Array.isArray(source?.results) ? source.results.map(projectResult) : [],
229
+ errorCode: code,
230
+ errorMessage: code ? ERROR_MESSAGES[code] : null,
231
+ warningCodes: projectWarningCodes(source?.warnings, source?.warningCodes),
232
+ mapping: projectMapping(source?.mapping, id),
233
+ };
234
+ }
235
+ export function workspaceRoutes({ workspace }) {
236
+ const r = express.Router();
237
+ r.get('/workspace/status', asyncHandler(async (_req, res) => {
238
+ return res.json(await workspace.getProtectionStatus());
239
+ }));
240
+ r.get('/workspace/checkpoints', asyncHandler(async (_req, res) => {
241
+ const checkpoints = await workspace.listCheckpoints();
242
+ return res.json(Array.isArray(checkpoints) ? checkpoints.map(projectCheckpoint) : []);
243
+ }));
244
+ r.get('/workspace/restore-plan', asyncHandler(async (req, res) => {
245
+ const id = checkpointId(req.query.checkpoint, 'latest');
246
+ if (!id)
247
+ return res.status(400).json({ error: 'bad checkpoint id' });
248
+ // serverNow, promptEligible, pending recovery state, and mapping are runtime-authored. The route
249
+ // deliberately passes them through without interpreting expiresAt using a client-supplied clock.
250
+ return res.json(await workspace.getRestorePlan({ checkpointId: id }));
251
+ }, { checkpoint: true }));
252
+ r.post('/workspace/restore', asyncHandler(async (req, res) => {
253
+ const restoreRequest = parseWorkspaceRestoreRequest(req.body);
254
+ if (!restoreRequest)
255
+ return res.status(400).json({ error: 'bad request' });
256
+ return res.status(202).json(await workspace.startRestore(restoreRequest));
257
+ }, { checkpoint: true }));
258
+ r.get('/workspace/restore/:operationId', asyncHandler(async (req, res) => {
259
+ if (!isWorkspaceSafeId(req.params.operationId))
260
+ return res.status(400).json({ error: 'bad operation id' });
261
+ const operation = await workspace.getOperation(req.params.operationId);
262
+ if (!operation)
263
+ return res.status(404).json({ error: 'operation not found' });
264
+ return res.json(projectOperation(operation));
265
+ }));
266
+ return r;
267
+ }