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,441 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { WebSocketServer } from 'ws';
3
+ import { tokenEquals } from './auth.js';
4
+ import { isPaneId } from './tmux/commands.js';
5
+ import { restoreCaptureBackgrounds } from './captureBackground.js';
6
+ const MAX_BUFFERED_BYTES = 1024 * 1024;
7
+ const MAX_CLIENT_MESSAGE_BYTES = 16 * 1024;
8
+ const START_TIMEOUT_MS = 5000;
9
+ const SUBSCRIBE_TIMEOUT_MS = 5000;
10
+ const HEARTBEAT_MS = 30000;
11
+ const INITIAL_HISTORY_LINES = 100;
12
+ const PANE_INFO = '"#{pane_width}\\t#{pane_height}\\t#{cursor_x}\\t#{cursor_y}\\t#{cursor_flag}\\t#{alternate_on}\\t#{mouse_any_flag}\\t#{mouse_sgr_flag}"';
13
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ const defaultSpawnControl = (command, args, options) => spawn(command, [...args], options);
15
+ export function echoTerminalProbe(ws, message) {
16
+ if (!isRecord(message) || message.type !== 'probe'
17
+ || typeof message.id !== 'number' || !Number.isSafeInteger(message.id) || message.id < 0)
18
+ return false;
19
+ if (ws.readyState === 1)
20
+ ws.send(JSON.stringify({ type: 'probe', id: message.id }));
21
+ return true;
22
+ }
23
+ export function startSubscribeDeadline(ws, timeoutMs = SUBSCRIBE_TIMEOUT_MS) {
24
+ const timer = setTimeout(() => {
25
+ if (ws.readyState < 2)
26
+ ws.close(4001, 'authentication timeout');
27
+ }, timeoutMs);
28
+ timer.unref?.();
29
+ return () => clearTimeout(timer);
30
+ }
31
+ function parsePaneInfo(infoLines) {
32
+ const values = Buffer.concat(infoLines).toString('utf8').split('\t').map(Number);
33
+ if (!values.every(Number.isFinite) || values.length !== 8
34
+ || (values[0] ?? 0) < 1 || (values[1] ?? 0) < 1) {
35
+ throw new Error('invalid tmux pane info');
36
+ }
37
+ return values;
38
+ }
39
+ export function decodeControlData(data) {
40
+ const bytes = [];
41
+ for (let i = 0; i < data.length;) {
42
+ const current = data[i];
43
+ if (current === 0x5c && data[i + 1] === 0x5c) {
44
+ bytes.push(0x5c);
45
+ i += 2;
46
+ continue;
47
+ }
48
+ if (current === 0x5c && i + 3 < data.length) {
49
+ const a = data[i + 1];
50
+ const b = data[i + 2];
51
+ const c = data[i + 3];
52
+ if (a !== undefined && b !== undefined && c !== undefined
53
+ && a >= 0x30 && a <= 0x37 && b >= 0x30 && b <= 0x37 && c >= 0x30 && c <= 0x37) {
54
+ bytes.push(((a - 0x30) << 6) | ((b - 0x30) << 3) | (c - 0x30));
55
+ i += 4;
56
+ continue;
57
+ }
58
+ }
59
+ if (current !== undefined)
60
+ bytes.push(current);
61
+ i += 1;
62
+ }
63
+ return Buffer.from(bytes);
64
+ }
65
+ export class PaneControlStream {
66
+ ws;
67
+ pane;
68
+ buffer;
69
+ waiters;
70
+ response;
71
+ phase;
72
+ wantLive;
73
+ pendingOutput;
74
+ pendingOutputBytes;
75
+ resyncing;
76
+ attached;
77
+ startTimer;
78
+ child;
79
+ resolveAttached;
80
+ rejectAttached;
81
+ lastError;
82
+ constructor({ ws, pane, session, spawnControl = defaultSpawnControl }) {
83
+ this.ws = ws;
84
+ this.pane = pane;
85
+ this.buffer = Buffer.alloc(0);
86
+ this.waiters = [];
87
+ this.response = null;
88
+ this.phase = 'attach';
89
+ this.wantLive = true;
90
+ this.pendingOutput = [];
91
+ this.pendingOutputBytes = 0;
92
+ this.resyncing = null;
93
+ this.attached = new Promise((resolve, reject) => {
94
+ this.resolveAttached = resolve;
95
+ this.rejectAttached = reject;
96
+ });
97
+ this.startTimer = setTimeout(() => this.rejectAttached(new Error('tmux control mode attach timed out')), START_TIMEOUT_MS);
98
+ this.child = spawnControl('tmux', ['-C', 'attach-session', '-t', session], {
99
+ stdio: ['pipe', 'pipe', 'pipe'],
100
+ });
101
+ this.child.stdout.on('data', (chunk) => this.onChunk(chunk));
102
+ this.child.stderr.on('data', (chunk) => { this.lastError = chunk.toString('utf8'); });
103
+ this.child.on('error', (error) => this.fail(error));
104
+ this.child.on('exit', (code) => {
105
+ if (this.phase !== 'closed')
106
+ this.fail(new Error(this.lastError || `tmux control mode exited (${code})`));
107
+ });
108
+ }
109
+ onChunk(chunk) {
110
+ this.buffer = Buffer.concat([this.buffer, chunk]);
111
+ for (;;) {
112
+ const newline = this.buffer.indexOf(0x0a);
113
+ if (newline < 0)
114
+ break;
115
+ const line = this.buffer.subarray(0, newline);
116
+ this.buffer = this.buffer.subarray(newline + 1);
117
+ this.onLine(line.at(-1) === 0x0d ? line.subarray(0, -1) : line);
118
+ }
119
+ }
120
+ onLine(line) {
121
+ if (line.subarray(0, 8).toString('ascii') === '%output ') {
122
+ const split = line.indexOf(0x20, 8);
123
+ if (split < 0 || line.subarray(8, split).toString('ascii') !== this.pane)
124
+ return;
125
+ // Paused/capturing output is intentionally discarded. Avoid decoding and allocating a second
126
+ // byte buffer for data that no client will consume.
127
+ if (this.phase !== 'buffer' && this.phase !== 'live')
128
+ return;
129
+ const output = decodeControlData(line.subarray(split + 1));
130
+ if (this.phase === 'buffer') {
131
+ if (this.pendingOutputBytes + output.length > MAX_BUFFERED_BYTES) {
132
+ this.wantLive = false;
133
+ this.phase = 'paused';
134
+ this.pendingOutput = [];
135
+ this.pendingOutputBytes = 0;
136
+ if (this.ws.readyState < 2)
137
+ this.ws.close(1013, 'stream fell behind');
138
+ }
139
+ else {
140
+ this.pendingOutput.push(output);
141
+ this.pendingOutputBytes += output.length;
142
+ }
143
+ }
144
+ else if (this.phase === 'live')
145
+ this.sendOutput(output);
146
+ return;
147
+ }
148
+ if (line.subarray(0, 7).toString('ascii') === '%begin ') {
149
+ this.response = { lines: [], waiter: this.waiters.shift() ?? null };
150
+ return;
151
+ }
152
+ const end = line.subarray(0, 5).toString('ascii') === '%end ';
153
+ const error = line.subarray(0, 7).toString('ascii') === '%error ';
154
+ if (end || error) {
155
+ const response = this.response;
156
+ this.response = null;
157
+ if (response?.waiter) {
158
+ if (error) {
159
+ response.waiter.reject(new Error(Buffer.concat(response.lines).toString('utf8')));
160
+ }
161
+ else {
162
+ try {
163
+ // Run the boundary callback synchronously, before onChunk can consume any notification
164
+ // following this %end in the same stdout chunk. Resync uses this to publish ready before
165
+ // later %output, so an older cursor snapshot can never overwrite newer streamed movement.
166
+ response.waiter.onEnd?.(response.lines);
167
+ response.waiter.resolve(response.lines);
168
+ }
169
+ catch (callbackError) {
170
+ response.waiter.reject(callbackError);
171
+ }
172
+ }
173
+ }
174
+ return;
175
+ }
176
+ if (this.response) {
177
+ this.response.lines.push(Buffer.from(line));
178
+ return;
179
+ }
180
+ if (line.subarray(0, 17).toString('ascii') === '%session-changed ') {
181
+ clearTimeout(this.startTimer);
182
+ this.resolveAttached();
183
+ }
184
+ }
185
+ request(command, onEnd) {
186
+ return new Promise((resolve, reject) => {
187
+ if (this.phase === 'closed') {
188
+ reject(new Error('tmux control stream closed'));
189
+ return;
190
+ }
191
+ this.waiters.push({ resolve, reject, ...(onEnd ? { onEnd } : {}) });
192
+ this.child.stdin.write(`${command}\n`);
193
+ });
194
+ }
195
+ sendOutput(output) {
196
+ if (this.ws.readyState !== 1)
197
+ return;
198
+ if (this.ws.bufferedAmount > MAX_BUFFERED_BYTES) {
199
+ this.ws.close(1013, 'stream fell behind');
200
+ return;
201
+ }
202
+ this.ws.send(output, { binary: true });
203
+ }
204
+ sendJson(message) {
205
+ if (this.ws.readyState !== 1)
206
+ return false;
207
+ if (this.ws.bufferedAmount > MAX_BUFFERED_BYTES) {
208
+ this.ws.close(1013, 'stream fell behind');
209
+ return false;
210
+ }
211
+ this.ws.send(JSON.stringify(message));
212
+ return true;
213
+ }
214
+ async start() {
215
+ await this.attached;
216
+ await this.resync();
217
+ }
218
+ pause() {
219
+ if (this.phase !== 'closed') {
220
+ this.wantLive = false;
221
+ this.phase = 'paused';
222
+ this.pendingOutput = [];
223
+ this.pendingOutputBytes = 0;
224
+ }
225
+ }
226
+ setPhase(phase) {
227
+ this.phase = phase;
228
+ }
229
+ resync() {
230
+ this.wantLive = true;
231
+ if (this.resyncing)
232
+ return this.resyncing;
233
+ this.resyncing = this.runResync().finally(() => { this.resyncing = null; });
234
+ return this.resyncing;
235
+ }
236
+ async runResync() {
237
+ await this.attached;
238
+ this.setPhase('capture');
239
+ this.pendingOutput = [];
240
+ this.pendingOutputBytes = 0;
241
+ const captureLines = await this.request(`capture-pane -p -e -N -S -${INITIAL_HISTORY_LINES} -t ${this.pane}`, () => { this.phase = this.wantLive ? 'buffer' : 'paused'; });
242
+ const preliminaryInfo = await this.request(`display-message -p -t ${this.pane} ${PANE_INFO}`);
243
+ const [, height, , , , alternateOn] = parsePaneInfo(preliminaryInfo);
244
+ if (!this.wantLive || this.phase === 'closed') {
245
+ this.pendingOutput = [];
246
+ this.pendingOutputBytes = 0;
247
+ if (this.phase !== 'closed')
248
+ this.phase = 'paused';
249
+ return;
250
+ }
251
+ const sourceLines = alternateOn === 1 ? captureLines.slice(-height) : captureLines;
252
+ const captured = Buffer.concat(sourceLines.flatMap((line) => [line, Buffer.from('\n')]))
253
+ .toString('utf8');
254
+ const restored = await restoreCaptureBackgrounds(captured, height, async (row) => {
255
+ const exact = await this.request(`capture-pane -p -e -N -S ${row} -E ${row} -t ${this.pane}`);
256
+ return `${Buffer.concat(exact).toString('utf8')}\n`;
257
+ });
258
+ const restoredLines = restored.ansi.endsWith('\n')
259
+ ? restored.ansi.slice(0, -1).split('\n').map((line) => Buffer.from(line))
260
+ : restored.ansi.split('\n').map((line) => Buffer.from(line));
261
+ // Re-read size/cursor after the targeted row checks. Output produced while those checks ran is
262
+ // buffered; the synchronous onEnd handoff below publishes seed → buffered output → fresh cursor
263
+ // before any later %output from the same control chunk can overtake it.
264
+ await this.request(`display-message -p -t ${this.pane} ${PANE_INFO}`, (infoLines) => this.finishResync(restoredLines, infoLines));
265
+ }
266
+ finishResync(captureLines, infoLines) {
267
+ const [width, height, cursorX, cursorY, cursorFlag, alternateOn, mouseAny, mouseSgr] = parsePaneInfo(infoLines);
268
+ if (!this.wantLive || this.phase === 'closed') {
269
+ this.pendingOutput = [];
270
+ this.pendingOutputBytes = 0;
271
+ if (this.phase !== 'closed')
272
+ this.phase = 'paused';
273
+ return;
274
+ }
275
+ // Alternate-screen apps have no history of their own. tmux's -S capture may prepend the main
276
+ // screen's scrollback, so keep only the alternate screen's real grid there.
277
+ const visibleCapture = alternateOn === 1 ? captureLines.slice(-height) : captureLines;
278
+ const historyLines = Math.max(0, visibleCapture.length - height);
279
+ const ansi = Buffer.concat(visibleCapture.flatMap((line) => [line, Buffer.from('\n')])).toString('utf8');
280
+ if (!this.sendJson({
281
+ type: 'seed',
282
+ ansi,
283
+ width,
284
+ height,
285
+ historyLines,
286
+ alt: alternateOn === 1,
287
+ mouseAware: mouseAny === 1,
288
+ mouseSgr: mouseSgr === 1,
289
+ }))
290
+ return;
291
+ for (const output of this.pendingOutput)
292
+ this.sendOutput(output);
293
+ if (!this.sendJson({
294
+ type: 'ready',
295
+ cur: { row: height - 1 - cursorY, col: cursorX, vis: cursorFlag === 1 },
296
+ }))
297
+ return;
298
+ this.pendingOutput = [];
299
+ this.pendingOutputBytes = 0;
300
+ this.phase = this.wantLive ? 'live' : 'paused';
301
+ }
302
+ fail(error) {
303
+ clearTimeout(this.startTimer);
304
+ this.rejectAttached(error);
305
+ this.response?.waiter?.reject(error);
306
+ this.response = null;
307
+ for (const waiter of this.waiters.splice(0))
308
+ waiter.reject(error);
309
+ if (this.ws.readyState < 2)
310
+ this.ws.close(1011, 'tmux stream failed');
311
+ }
312
+ close() {
313
+ clearTimeout(this.startTimer);
314
+ this.phase = 'closed';
315
+ this.wantLive = false;
316
+ const error = new Error('tmux control stream closed');
317
+ this.response?.waiter?.reject(error);
318
+ this.response = null;
319
+ for (const waiter of this.waiters.splice(0))
320
+ waiter.reject(error);
321
+ this.pendingOutput = [];
322
+ this.pendingOutputBytes = 0;
323
+ try {
324
+ this.child.kill();
325
+ }
326
+ catch { /* already gone */ }
327
+ }
328
+ }
329
+ export function createTerminalStream({ token, commands, spawnControl, }) {
330
+ const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_CLIENT_MESSAGE_BYTES });
331
+ const streams = new Set();
332
+ const heartbeat = setInterval(() => {
333
+ for (const socket of wss.clients) {
334
+ const ws = socket;
335
+ if (ws.readyState !== 1)
336
+ continue;
337
+ if (ws.isAlive === false) {
338
+ ws.terminate();
339
+ continue;
340
+ }
341
+ ws.isAlive = false;
342
+ ws.ping();
343
+ }
344
+ }, HEARTBEAT_MS);
345
+ heartbeat.unref?.();
346
+ wss.on('connection', (socket) => {
347
+ const ws = socket;
348
+ ws.isAlive = true;
349
+ ws.on('pong', () => { ws.isAlive = true; });
350
+ const cancelSubscribeDeadline = startSubscribeDeadline(ws);
351
+ let authenticating = false;
352
+ let stream = null;
353
+ ws.on('message', async (raw, binary) => {
354
+ if (binary)
355
+ return;
356
+ let message;
357
+ try {
358
+ message = JSON.parse(raw.toString());
359
+ }
360
+ catch {
361
+ ws.close(1003, 'bad message');
362
+ return;
363
+ }
364
+ if (!isRecord(message)) {
365
+ ws.close(1003, 'bad message');
366
+ return;
367
+ }
368
+ if (stream) {
369
+ if (echoTerminalProbe(ws, message))
370
+ return;
371
+ if (message.type === 'pause')
372
+ stream.pause();
373
+ else if (message.type === 'resync') {
374
+ try {
375
+ await stream.resync();
376
+ }
377
+ catch {
378
+ if (ws.readyState < 2)
379
+ ws.close(1011, 'stream resync failed');
380
+ }
381
+ }
382
+ return;
383
+ }
384
+ if (authenticating)
385
+ return;
386
+ if (message.type !== 'subscribe'
387
+ || !tokenEquals(message.token ?? '', token)
388
+ || !isPaneId(message.pane)) {
389
+ ws.close(4001, 'unauthorized');
390
+ return;
391
+ }
392
+ authenticating = true;
393
+ cancelSubscribeDeadline();
394
+ try {
395
+ const session = await commands.paneSession(message.pane);
396
+ if (ws.readyState !== 1)
397
+ return;
398
+ stream = new PaneControlStream({
399
+ ws, pane: message.pane, session,
400
+ ...(spawnControl ? { spawnControl } : {}),
401
+ });
402
+ streams.add(stream);
403
+ await stream.start();
404
+ }
405
+ catch {
406
+ if (ws.readyState < 2)
407
+ ws.close(1011, 'stream setup failed');
408
+ }
409
+ });
410
+ ws.on('close', () => {
411
+ cancelSubscribeDeadline();
412
+ if (stream) {
413
+ stream.close();
414
+ streams.delete(stream);
415
+ }
416
+ });
417
+ });
418
+ const onUpgrade = (req, socket, head) => {
419
+ let pathname;
420
+ try {
421
+ pathname = new URL(req.url ?? '', 'http://handmux.local').pathname;
422
+ }
423
+ catch {
424
+ return false;
425
+ }
426
+ if (pathname !== '/api/terminal-stream')
427
+ return false;
428
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
429
+ return true;
430
+ };
431
+ const close = () => {
432
+ clearInterval(heartbeat);
433
+ for (const stream of streams)
434
+ stream.close();
435
+ streams.clear();
436
+ for (const ws of wss.clients)
437
+ ws.close(1001, 'server shutting down');
438
+ wss.close();
439
+ };
440
+ return { onUpgrade, close };
441
+ }