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
@@ -1,115 +1,114 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import os from 'node:os';
3
3
  import { parseTmuxRows, tmuxFormat } from './format.js';
4
-
5
- export const isPaneId = (s) => typeof s === 'string' && /^%\d+$/.test(s);
6
- export const isWindowId = (s) => typeof s === 'string' && /^@\d+$/.test(s);
7
- export const isSessionId = (s) => typeof s === 'string' && /^\$\d+$/.test(s);
8
-
4
+ export const isPaneId = (value) => typeof value === 'string' && /^%\d+$/.test(value);
5
+ export const isWindowId = (value) => typeof value === 'string' && /^@\d+$/.test(value);
6
+ export const isSessionId = (value) => typeof value === 'string' && /^\$\d+$/.test(value);
9
7
  // Letters, digits and hyphens only (1-16 chars). A positive allowlist sidesteps tmux's target
10
8
  // syntax entirely (no '.'/':' separators, no whitespace, no control chars) and is trivial to
11
9
  // mirror on the client. Only NEW session names are validated — binding an existing PC-made name
12
10
  // (which may contain spaces) checks existence first and never calls this.
13
- export const isValidSessionName = (s) =>
14
- typeof s === 'string' && /^[A-Za-z0-9-]{1,16}$/.test(s);
15
-
11
+ export const isValidSessionName = (value) => typeof value === 'string' && /^[A-Za-z0-9-]{1,16}$/.test(value);
16
12
  // Optional startup command run in a freshly-created window/session (e.g. "claude"). It's typed via
17
13
  // send-keys + Enter, so it must be a single line: reject control chars (newline/CR/tab included) and
18
14
  // cap the length. No shell-arg restriction — it runs in the new shell exactly as if typed, same trust
19
15
  // model as the existing sendText. Empty means "no command" and is handled by the caller, not here.
20
- export const isValidStartupCmd = (s) =>
21
- typeof s === 'string' && s.length > 0 && s.length <= 200 && [...s].every((c) => { const n = c.charCodeAt(0); return n >= 0x20 && n !== 0x7f; });
22
-
16
+ export const isValidStartupCmd = (value) => typeof value === 'string' && value.length > 0 && value.length <= 200
17
+ && [...value].every((character) => {
18
+ const code = character.charCodeAt(0);
19
+ return code >= 0x20 && code !== 0x7f;
20
+ });
23
21
  export function runTmux(args) {
24
- return new Promise((resolve, reject) => {
25
- execFile('tmux', args, { maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
26
- if (err) reject(new Error(stderr?.toString() || err.message));
27
- else resolve(stdout.toString());
22
+ return new Promise((resolve, reject) => {
23
+ execFile('tmux', args, { maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
24
+ if (err)
25
+ reject(new Error(stderr?.toString() || err.message));
26
+ else
27
+ resolve(stdout.toString());
28
+ });
28
29
  });
29
- });
30
30
  }
31
-
32
- const lines = (out) => out.split('\n').filter((l) => l.length > 0);
33
-
31
+ const lines = (output) => output.split('\n').filter((line) => line.length > 0);
32
+ const firstRow = (rows, label) => {
33
+ const row = rows[0];
34
+ if (!row)
35
+ throw new Error(`invalid ${label} format: expected row`);
36
+ return row;
37
+ };
34
38
  export async function listSessions() {
35
- try {
36
- const out = await runTmux(['list-sessions', '-F', tmuxFormat(['session_id', 'session_name'])]);
37
- return parseTmuxRows(out, 2, 'session').map(([id, name]) => ({ id, name }));
38
- } catch (e) {
39
- // tmux exits non-zero with "no server running" or "no sessions" when nothing is up yet.
40
- const msg = e.message || '';
41
- if (msg.includes('no server') || msg.includes('no sessions') || msg.includes('error connecting')) return [];
42
- throw e;
43
- }
44
- }
45
-
39
+ try {
40
+ const out = await runTmux(['list-sessions', '-F', tmuxFormat(['session_id', 'session_name'])]);
41
+ return parseTmuxRows(out, 2, 'session').map(([id, name]) => ({ id, name }));
42
+ }
43
+ catch (error) {
44
+ // tmux exits non-zero with "no server running" or "no sessions" when nothing is up yet.
45
+ const msg = error instanceof Error ? error.message : '';
46
+ if (msg.includes('no server') || msg.includes('no sessions') || msg.includes('error connecting'))
47
+ return [];
48
+ throw error;
49
+ }
50
+ }
46
51
  export async function listWindows(sessionId) {
47
- const out = await runTmux(['list-windows', '-t', sessionId, '-F', tmuxFormat([
48
- 'window_id', 'window_name', 'window_active', 'window_panes', 'window_width', 'window_height', 'pane_id',
49
- ])]);
50
- return parseTmuxRows(out, 7, 'window').map(([id, name, active, panes, width, height, activePaneId]) => {
51
- return { id, name, active: active === '1', panes: Number(panes), width: Number(width), height: Number(height), activePaneId };
52
- });
53
- }
54
-
52
+ const out = await runTmux(['list-windows', '-t', sessionId, '-F', tmuxFormat([
53
+ 'window_id', 'window_name', 'window_active', 'window_panes', 'window_width', 'window_height', 'pane_id',
54
+ ])]);
55
+ return parseTmuxRows(out, 7, 'window').map(([id, name, active, panes, width, height, activePaneId]) => {
56
+ return { id, name, active: active === '1', panes: Number(panes), width: Number(width), height: Number(height), activePaneId };
57
+ });
58
+ }
55
59
  export async function listPanes(windowId) {
56
- const out = await runTmux(['list-panes', '-t', windowId, '-F', tmuxFormat(['pane_id', 'pane_active', 'pane_width', 'pane_height', 'pane_current_command', 'pane_current_path', 'pane_left', 'pane_top', 'pane_tty'])]);
57
- return parseTmuxRows(out, 9, 'pane').map(([id, active, width, height, command, cwd, left, top, tty]) => {
58
- return { id, active: active === '1', width: Number(width), height: Number(height), command, cwd, left: Number(left), top: Number(top), tty };
59
- });
60
+ const out = await runTmux(['list-panes', '-t', windowId, '-F', tmuxFormat(['pane_id', 'pane_active', 'pane_width', 'pane_height', 'pane_current_command', 'pane_current_path', 'pane_left', 'pane_top', 'pane_tty'])]);
61
+ return parseTmuxRows(out, 9, 'pane').map(([id, active, width, height, command, cwd, left, top, tty]) => {
62
+ return { id, active: active === '1', width: Number(width), height: Number(height), command, cwd, left: Number(left), top: Number(top), tty };
63
+ });
60
64
  }
61
-
62
65
  // All live pane ids across every session — used to reconcile the in-memory Claude paneState against
63
66
  // reality. A hard-killed pane fires no hook, so its last state would otherwise linger as a ghost.
64
67
  // If a target is provided (window id or session id), list only panes in that target.
65
68
  export async function listPaneIds(target) {
66
- const args = ['list-panes', '-F', '#{pane_id}'];
67
- if (target) {
68
- args.push('-t', target);
69
- } else {
70
- args.push('-a');
71
- }
72
- return lines(await runTmux(args));
73
- }
74
-
69
+ const args = ['list-panes', '-F', '#{pane_id}'];
70
+ if (target) {
71
+ args.push('-t', target);
72
+ }
73
+ else {
74
+ args.push('-a');
75
+ }
76
+ return lines(await runTmux(args));
77
+ }
75
78
  // Every live pane across all sessions WITH its foreground command AND its tmux location, in ONE call.
76
79
  // getStates uses this to (a) catch "pane alive but Claude exited" (cmd flips from 'claude' back to the
77
80
  // shell — a hard kill / crash / Ctrl-C-out fires no SessionEnd yet the shell keeps the pane, so it
78
81
  // still EXISTS) and (b) resolve each recorded pane to its session/window for the inbox roster without a
79
82
  // per-pane display-message. The hook only records the pane id; location comes from here, always fresh.
80
83
  export async function listLivePanes() {
81
- return parseTmuxRows(await runTmux(['list-panes', '-a', '-F',
82
- tmuxFormat(['pane_id', 'pane_current_command', 'pane_tty', 'session_name', 'window_id', 'window_name'])]), 6, 'live pane')
83
- .map(([id, cmd, tty, session, window, windowName]) => {
84
- return { id, cmd, tty, session, window, windowName };
84
+ return parseTmuxRows(await runTmux(['list-panes', '-a', '-F',
85
+ tmuxFormat(['pane_id', 'pane_current_command', 'pane_tty', 'session_name', 'window_id', 'window_name'])]), 6, 'live pane')
86
+ .map(([id, cmd, tty, session, window, windowName]) => {
87
+ return { id, cmd, tty, session, window, windowName };
85
88
  });
86
89
  }
87
-
88
90
  // -N preserves trailing whitespace. Without it, capture-pane trims trailing cells at each line
89
91
  // end — including background-colored padding — which both drops the right-hand part of a
90
92
  // full-width highlight (Claude Code's sent-message bar) AND loses the SGR reset that closes the
91
93
  // background, so the highlight bleeds onto the rows below when re-rendered. With -N the capture
92
94
  // faithfully reproduces the pane, so the client can write it verbatim (see prepareSeed).
93
95
  export async function capturePane(paneId, linesBack) {
94
- return runTmux(['capture-pane', '-p', '-e', '-N', '-S', String(-Math.abs(linesBack)), '-t', paneId]);
96
+ return runTmux(['capture-pane', '-p', '-e', '-N', '-S', String(-Math.abs(linesBack)), '-t', paneId]);
95
97
  }
96
-
97
98
  // A single row capture re-emits that row's real starting attributes. The combined capture above
98
99
  // intentionally compresses SGR state across newlines, which makes an inherited background ambiguous.
99
100
  export async function capturePaneRow(paneId, row) {
100
- return runTmux([
101
- 'capture-pane', '-p', '-e', '-N',
102
- '-S', String(row), '-E', String(row),
103
- '-t', paneId,
104
- ]);
101
+ return runTmux([
102
+ 'capture-pane', '-p', '-e', '-N',
103
+ '-S', String(row), '-E', String(row),
104
+ '-t', paneId,
105
+ ]);
105
106
  }
106
-
107
107
  // Plain visible-screen capture — NO SGR escapes (unlike capturePane's `-e`), so the text parses cleanly.
108
108
  // Used to scrape a pending prompt/menu off the screen (see pendingPrompt.js).
109
109
  export async function capturePlain(paneId) {
110
- return runTmux(['capture-pane', '-p', '-t', paneId]);
110
+ return runTmux(['capture-pane', '-p', '-t', paneId]);
111
111
  }
112
-
113
112
  // Size AND cursor in one display-message (capture-pane carries neither the cursor position nor its
114
113
  // visibility — it snapshots cells only — so we read them here for the client to re-place xterm's own
115
114
  // cursor onto Claude's input cell). cursor_x/cursor_y are 0-based, relative to the visible screen;
@@ -119,16 +118,15 @@ export async function capturePlain(paneId) {
119
118
  // mouse reporting (any mode); mouse_sgr_flag = it negotiated the SGR (1006) encoding. Together they let the
120
119
  // client translate a swipe into wheel events the app scrolls on (sendWheel) instead of a dead swipe.
121
120
  export async function paneInfo(paneId) {
122
- const out = await runTmux(['display-message', '-p', '-t', paneId,
123
- tmuxFormat(['pane_width', 'pane_height', 'cursor_x', 'cursor_y', 'cursor_flag', 'alternate_on', 'mouse_any_flag', 'mouse_sgr_flag'])]);
124
- const [[width, height, cx, cy, cflag, alt, mAny, mSgr] = []] = parseTmuxRows(out, 8, 'pane info');
125
- return {
126
- width: Number(width), height: Number(height),
127
- cursorX: Number(cx), cursorY: Number(cy), cursorVisible: cflag === '1',
128
- altScreen: alt === '1', mouseAware: mAny === '1', mouseSgr: mSgr === '1',
129
- };
130
- }
131
-
121
+ const out = await runTmux(['display-message', '-p', '-t', paneId,
122
+ tmuxFormat(['pane_width', 'pane_height', 'cursor_x', 'cursor_y', 'cursor_flag', 'alternate_on', 'mouse_any_flag', 'mouse_sgr_flag'])]);
123
+ const [width, height, cx, cy, cflag, alt, mAny, mSgr] = firstRow(parseTmuxRows(out, 8, 'pane info'), 'pane info');
124
+ return {
125
+ width: Number(width), height: Number(height),
126
+ cursorX: Number(cx), cursorY: Number(cy), cursorVisible: cflag === '1',
127
+ altScreen: alt === '1', mouseAware: mAny === '1', mouseSgr: mSgr === '1',
128
+ };
129
+ }
132
130
  // Inject mouse-wheel scroll events so a full-screen app on the ALT screen scrolls itself — exactly what a
133
131
  // desktop wheel does. dir 'up' scrolls toward earlier content (SGR button 64), 'down' toward later (65);
134
132
  // `count` notches go out in ONE send-keys as a repeated escape run. Only meaningful when the app requested
@@ -137,195 +135,210 @@ export async function paneInfo(paneId) {
137
135
  // `col`/`row` are the 1-based pointer position the event reports at (the pane centre — irrelevant to a lone
138
136
  // full-screen app, but lets a split-aware app scroll the region under the finger).
139
137
  export async function sendWheel(paneId, dir, count, opts = {}) {
140
- await runTmux(['send-keys', '-t', paneId, '-l', '--', wheelSeq(dir, count, opts)]);
138
+ await runTmux(['send-keys', '-t', paneId, '-l', '--', wheelSeq(dir, count, opts)]);
141
139
  }
142
-
143
140
  // Pure: build the terminal byte run for `count` wheel notches (kept separate so the encoding is unit-
144
141
  // tested without spawning tmux). `count` is clamped to 1..60 (one flick shouldn't inject hundreds).
145
142
  export function wheelSeq(dir, count, { sgr = true, col = 1, row = 1 } = {}) {
146
- const n = Math.min(Math.max(Math.trunc(Number(count)) || 1, 1), 60);
147
- const btn = dir === 'up' ? 64 : 65;
148
- const c = Math.max(1, Math.trunc(col) || 1);
149
- const r = Math.max(1, Math.trunc(row) || 1);
150
- const unit = sgr
151
- ? `\x1b[<${btn};${c};${r}M`
152
- // legacy X10: ESC [ M <btn+32> <col+32> <row+32>, each coordinate byte capped at 223.
153
- : `\x1b[M${String.fromCharCode(btn + 32, Math.min(c, 223) + 32, Math.min(r, 223) + 32)}`;
154
- return unit.repeat(n);
155
- }
156
-
143
+ const n = Math.min(Math.max(Math.trunc(Number(count)) || 1, 1), 60);
144
+ const btn = dir === 'up' ? 64 : 65;
145
+ const c = Math.max(1, Math.trunc(col) || 1);
146
+ const r = Math.max(1, Math.trunc(row) || 1);
147
+ const unit = sgr
148
+ ? `\x1b[<${btn};${c};${r}M`
149
+ // legacy X10: ESC [ M <btn+32> <col+32> <row+32>, each coordinate byte capped at 223.
150
+ : `\x1b[M${String.fromCharCode(btn + 32, Math.min(c, 223) + 32, Math.min(r, 223) + 32)}`;
151
+ return unit.repeat(n);
152
+ }
157
153
  // Resolve a pane to its tmux session name + window for routing a notification back to it. The hook
158
154
  // gives us only $TMUX_PANE; this turns "%263" into the session/window the phone navigates by.
159
155
  export async function paneLocation(paneId) {
160
- const out = await runTmux(['display-message', '-p', '-t', paneId,
161
- tmuxFormat(['session_name', 'window_id', 'window_name'])]);
162
- const [[session, windowId, windowName] = []] = parseTmuxRows(out, 3, 'pane location');
163
- return { session, window: windowId, windowName };
156
+ const out = await runTmux(['display-message', '-p', '-t', paneId,
157
+ tmuxFormat(['session_name', 'window_id', 'window_name'])]);
158
+ const [session, windowId, windowName] = firstRow(parseTmuxRows(out, 3, 'pane location'), 'pane location');
159
+ return { session, window: windowId, windowName };
164
160
  }
165
-
166
161
  export async function paneSession(paneId) {
167
- const out = await runTmux(['display-message', '-p', '-t', paneId, '#{session_id}']);
168
- const id = out.trim();
169
- if (!isSessionId(id)) throw new Error('pane session not found');
170
- return id;
162
+ const out = await runTmux(['display-message', '-p', '-t', paneId, '#{session_id}']);
163
+ const id = out.trim();
164
+ if (!isSessionId(id))
165
+ throw new Error('pane session not found');
166
+ return id;
171
167
  }
172
-
173
168
  // Exit tmux copy/scroll mode if the pane is currently in it. Called before any user input so
174
169
  // text and keys reach the shell instead of being swallowed by tmux's mode key-bindings.
175
170
  export async function exitCopyModeIfActive(paneId) {
176
- const out = await runTmux(['display-message', '-p', '-t', paneId, '#{pane_in_mode}']);
177
- if (out.trim() === '1') await runTmux(['send-keys', '-t', paneId, 'Escape']);
178
- }
179
-
171
+ const out = await runTmux(['display-message', '-p', '-t', paneId, '#{pane_in_mode}']);
172
+ if (out.trim() === '1')
173
+ await runTmux(['send-keys', '-t', paneId, 'Escape']);
174
+ }
175
+ let inputBufferSerial = 0;
176
+ const nextInputBufferName = () => {
177
+ inputBufferSerial = (inputBufferSerial + 1) % Number.MAX_SAFE_INTEGER;
178
+ return `handmux-input-${process.pid}-${inputBufferSerial}`;
179
+ };
180
180
  export async function sendText(paneId, text) {
181
- await runTmux(['send-keys', '-t', paneId, '-l', '--', text]);
181
+ if (!text)
182
+ return;
183
+ const buffer = nextInputBufferName();
184
+ await runTmux(['set-buffer', '-b', buffer, '--', text]);
185
+ try {
186
+ // `-p` asks tmux to wrap the payload in bracketed-paste markers when the target application has
187
+ // enabled that terminal mode. The explicit end marker lets TUIs distinguish the pasted body from a
188
+ // following Enter without guessing from byte timing; shells/apps without the mode still receive the
189
+ // same literal text. A per-call named buffer prevents concurrent panes from overwriting each other.
190
+ await runTmux(['paste-buffer', '-p', '-d', '-b', buffer, '-t', paneId]);
191
+ }
192
+ catch (error) {
193
+ // `-d` removes the buffer on success. If the target disappeared first, avoid retaining the payload in
194
+ // tmux's global buffer list; cleanup failure must not hide the original send error.
195
+ try {
196
+ await runTmux(['delete-buffer', '-b', buffer]);
197
+ }
198
+ catch { /* best effort */ }
199
+ throw error;
200
+ }
182
201
  }
183
-
184
202
  export async function sendHexInput(paneId, hex) {
185
- const bytes = hex.match(/../g) || [];
186
- await runTmux(['send-keys', '-t', paneId, '-H', ...bytes]);
203
+ const bytes = hex.match(/../g) || [];
204
+ await runTmux(['send-keys', '-t', paneId, '-H', ...bytes]);
187
205
  }
188
-
189
206
  export async function sendEnter(paneId) {
190
- await runTmux(['send-keys', '-t', paneId, 'Enter']);
207
+ await runTmux(['send-keys', '-t', paneId, 'Enter']);
191
208
  }
192
-
193
209
  export async function sendKey(paneId, key) {
194
- await runTmux(['send-keys', '-t', paneId, key]);
210
+ await runTmux(['send-keys', '-t', paneId, key]);
195
211
  }
196
-
197
212
  // Force the window to an explicit width (and optionally height) so tmux reflows to it. This
198
213
  // sets the window-size option to `manual`, so it sticks (and applies to every client on this
199
214
  // window — including the PC) until restoreWindowSize is called. Pass rows = null to change
200
215
  // only the column count and leave the height untouched.
201
216
  export async function resizeWindow(windowId, cols, rows) {
202
- const args = ['resize-window', '-t', windowId, '-x', String(cols)];
203
- if (rows != null) args.push('-y', String(rows));
204
- await runTmux(args);
217
+ const args = ['resize-window', '-t', windowId, '-x', String(cols)];
218
+ if (rows != null)
219
+ args.push('-y', String(rows));
220
+ await runTmux(args);
205
221
  }
206
-
207
222
  // Hand sizing back to the attached clients (tmux default), so the PC's terminal dictates
208
223
  // the window size again instead of the phone-sized grid left by resizeWindow.
209
224
  export async function restoreWindowSize(windowId) {
210
- await runTmux(['set-window-option', '-t', windowId, 'window-size', 'latest']);
225
+ await runTmux(['set-window-option', '-t', windowId, 'window-size', 'latest']);
211
226
  }
212
-
213
227
  // Resize just one pane's width inside its window (siblings absorb the difference; the window
214
228
  // total is unchanged). Use this for a pane in a split — resizing the whole window would
215
229
  // shrink every pane. A lone pane can't be resized this way (it fills the window).
216
230
  export async function resizePane(paneId, cols) {
217
- await runTmux(['resize-pane', '-t', paneId, '-x', String(cols)]);
231
+ await runTmux(['resize-pane', '-t', paneId, '-x', String(cols)]);
218
232
  }
219
-
220
233
  // The window's exact pane arrangement, captured so "restore" can put a split back the way it
221
234
  // was after resizePane changed the ratio. resizePane doesn't touch window size, so window-size
222
235
  // latest alone can't undo it — select-layout with this string does.
223
236
  export async function getWindowLayout(windowId) {
224
- const out = await runTmux(['display-message', '-p', '-t', windowId, '#{window_layout}']);
225
- return out.trim();
237
+ const out = await runTmux(['display-message', '-p', '-t', windowId, '#{window_layout}']);
238
+ return out.trim();
226
239
  }
227
-
228
240
  export async function applyWindowLayout(windowId, layout) {
229
- await runTmux(['select-layout', '-t', windowId, layout]);
241
+ await runTmux(['select-layout', '-t', windowId, layout]);
230
242
  }
231
-
232
243
  // -d: detached (the node server has no tty). -c cwd (defaults to $HOME): start dir for the session.
233
244
  // -P -F prints the new session id so the caller can confirm/route. A duplicate name makes tmux
234
245
  // error (runTmux rejects) — the route pre-checks and returns a clean 409 before reaching here.
235
246
  // Self-guard the name even though the route validates first: this is an exported boundary to tmux,
236
247
  // and a name with target-syntax chars ('$', ':', …) would create a hard-to-address session.
237
248
  export async function newSession(name, cwd, cmd) {
238
- if (!isValidSessionName(name)) throw new Error(`invalid session name: ${JSON.stringify(name)}`);
239
- const out = await runTmux(['new-session', '-d', '-s', name, '-c', cwd || os.homedir(), '-P', '-F', '#{session_id}']);
240
- const id = out.trim(); // e.g. "$7"
241
- if (cmd) await runStartupCmd(id, cmd); // auto-run the startup command in the new session's first pane
242
- return id;
249
+ if (!isValidSessionName(name))
250
+ throw new Error(`invalid session name: ${JSON.stringify(name)}`);
251
+ const out = await runTmux(['new-session', '-d', '-s', name, '-c', cwd || os.homedir(), '-P', '-F', '#{session_id}']);
252
+ const id = out.trim(); // e.g. "$7"
253
+ if (cmd)
254
+ await runStartupCmd(id, cmd); // auto-run the startup command in the new session's first pane
255
+ return id;
243
256
  }
244
-
245
257
  // Type a startup command into a freshly-created window/session and press Enter — same path as a user
246
258
  // typing it. The target ($id / @id) resolves to the new shell's active pane. Runs inside the shell (we
247
259
  // don't pass it to new-window/new-session as the pane command) so the pane survives the command exiting.
248
260
  async function runStartupCmd(target, cmd) {
249
- await sendText(target, cmd);
250
- await sendEnter(target);
261
+ await sendText(target, cmd);
262
+ // tmux has handed the paste to the pane at this point, but the foreground shell still consumes PTY
263
+ // input asynchronously. Match the interactive /send pacing so a long startup command cannot absorb the
264
+ // following Enter as part of its paste.
265
+ await new Promise((resolve) => setTimeout(resolve, 120));
266
+ await sendEnter(target);
251
267
  }
252
-
253
268
  // Type a validated single-line command into an existing pane after its foreground TUI has returned to
254
269
  // the shell. This does not kill or replace anything; callers must first prove the previous process exited.
255
270
  export async function runPaneCommand(paneId, cmd) {
256
- if (!isPaneId(paneId)) throw new Error(`invalid pane id: ${JSON.stringify(paneId)}`);
257
- if (!isValidStartupCmd(cmd)) throw new Error('invalid startup command');
258
- await runStartupCmd(paneId, cmd);
271
+ if (!isPaneId(paneId))
272
+ throw new Error(`invalid pane id: ${JSON.stringify(paneId)}`);
273
+ if (!isValidStartupCmd(cmd))
274
+ throw new Error('invalid startup command');
275
+ await runStartupCmd(paneId, cmd);
259
276
  }
260
-
261
277
  // Read a pane's working directory, so a new window can open in the dir you're working in.
262
278
  export async function paneCurrentPath(paneId) {
263
- const out = await runTmux(['display-message', '-p', '-t', paneId, '#{pane_current_path}']);
264
- return out.trim();
279
+ const out = await runTmux(['display-message', '-p', '-t', paneId, '#{pane_current_path}']);
280
+ return out.trim();
265
281
  }
266
-
267
282
  // -d: don't steal the active window from the PC (the phone navigates to it itself). -c: start dir
268
283
  // (omitted when cwd is falsy → tmux uses the session default). -n: window name (omitted when falsy →
269
284
  // tmux auto-names after the running command). -P -F prints the new window id.
270
285
  export async function newWindow(sessionId, cwd, name, cmd) {
271
- const args = ['new-window', '-d', '-t', sessionId, '-P', '-F', '#{window_id}'];
272
- if (cwd) args.push('-c', cwd); // tmux accepts -c in any position; push mirrors resizeWindow's style
273
- if (name) args.push('-n', name);
274
- const id = (await runTmux(args)).trim(); // e.g. "@32"
275
- if (cmd) await runStartupCmd(id, cmd); // auto-run the startup command in the new window's pane
276
- return id;
277
- }
278
-
286
+ const args = ['new-window', '-d', '-t', sessionId, '-P', '-F', '#{window_id}'];
287
+ if (cwd)
288
+ args.push('-c', cwd); // tmux accepts -c in any position; push mirrors resizeWindow's style
289
+ if (name)
290
+ args.push('-n', name);
291
+ const id = (await runTmux(args)).trim(); // e.g. "@32"
292
+ if (cmd)
293
+ await runStartupCmd(id, cmd); // auto-run the startup command in the new window's pane
294
+ return id;
295
+ }
279
296
  // rename-session keeps the session's $id — only the name changes. Self-guard the name (exported
280
297
  // boundary to tmux; a name with target-syntax chars would create a hard-to-address session). A
281
298
  // duplicate name makes tmux error (runTmux rejects); the route pre-checks and returns a clean 409.
282
299
  export async function renameSession(id, name) {
283
- if (!isValidSessionName(name)) throw new Error(`invalid session name: ${JSON.stringify(name)}`);
284
- await runTmux(['rename-session', '-t', id, name]);
300
+ if (!isValidSessionName(name))
301
+ throw new Error(`invalid session name: ${JSON.stringify(name)}`);
302
+ await runTmux(['rename-session', '-t', id, name]);
285
303
  }
286
-
287
304
  // rename-window sets the name manually (and implicitly turns off that window's automatic-rename,
288
305
  // so the chosen name sticks instead of tracking the running command — the expected tmux behavior).
289
306
  export async function renameWindow(id, name) {
290
- if (!isValidSessionName(name)) throw new Error(`invalid window name: ${JSON.stringify(name)}`);
291
- await runTmux(['rename-window', '-t', id, name]);
307
+ if (!isValidSessionName(name))
308
+ throw new Error(`invalid window name: ${JSON.stringify(name)}`);
309
+ await runTmux(['rename-window', '-t', id, name]);
292
310
  }
293
-
294
311
  // The number of windows in the window's session. The delete guard refuses to kill the last one
295
312
  // (killing it would take the whole session with it).
296
313
  export async function sessionWindowCount(id) {
297
- const out = await runTmux(['display-message', '-p', '-t', id, '#{session_windows}']);
298
- return Number(out.trim());
314
+ const out = await runTmux(['display-message', '-p', '-t', id, '#{session_windows}']);
315
+ return Number(out.trim());
299
316
  }
300
-
301
317
  // Split a pane into two. -d: don't move the PC's active pane (the phone navigates to the new pane
302
318
  // itself, client-side). dir 'h' → left|right (`-h`), 'v' → top/bottom (`-v`). -c: the new pane's
303
319
  // start dir (the target pane's cwd), omitted when falsy. -P -F prints the new pane id.
304
320
  export async function splitPane(paneId, dir, cwd) {
305
- const flag = dir === 'v' ? '-v' : '-h';
306
- const args = ['split-window', '-d', flag, '-t', paneId, '-P', '-F', '#{pane_id}'];
307
- if (cwd) args.push('-c', cwd);
308
- return (await runTmux(args)).trim(); // e.g. "%91"
321
+ const flag = dir === 'v' ? '-v' : '-h';
322
+ const args = ['split-window', '-d', flag, '-t', paneId, '-P', '-F', '#{pane_id}'];
323
+ if (cwd)
324
+ args.push('-c', cwd);
325
+ return (await runTmux(args)).trim(); // e.g. "%91"
309
326
  }
310
-
311
327
  // Panes in the window that owns this pane — the kill guard refuses to kill the last one (killing it
312
328
  // would take the window, and if it's the last window, the whole session).
313
329
  export async function windowPaneCount(paneId) {
314
- const out = await runTmux(['display-message', '-p', '-t', paneId, '#{window_panes}']);
315
- return Number(out.trim());
330
+ const out = await runTmux(['display-message', '-p', '-t', paneId, '#{window_panes}']);
331
+ return Number(out.trim());
316
332
  }
317
-
318
333
  export async function killPane(paneId) {
319
- await runTmux(['kill-pane', '-t', paneId]);
334
+ await runTmux(['kill-pane', '-t', paneId]);
320
335
  }
321
-
322
336
  export async function killWindow(id) {
323
- await runTmux(['kill-window', '-t', id]);
337
+ await runTmux(['kill-window', '-t', id]);
324
338
  }
325
-
326
339
  // Swap two windows' positions (indices) within a session. -d keeps the active window unchanged so
327
340
  // reordering from the phone doesn't yank the PC's focus to the swapped window. The window ids are
328
341
  // unchanged — only their order in list-windows flips.
329
342
  export async function swapWindows(a, b) {
330
- await runTmux(['swap-window', '-d', '-s', a, '-t', b]);
343
+ await runTmux(['swap-window', '-d', '-s', a, '-t', b]);
331
344
  }
@@ -0,0 +1,47 @@
1
+ const Q_ESCAPED = new Set([
2
+ '|', '&', ';', '<', '>', '(', ')', '$', '`', '\\', '"', "'", '*', '?', '[', '#', ' ', '=', '%',
3
+ ]);
4
+ export function tmuxFormat(fields) {
5
+ if (!Array.isArray(fields) || fields.length === 0)
6
+ throw new Error('tmux format fields are required');
7
+ return fields.map((field) => {
8
+ if (typeof field !== 'string' || !/^@?[A-Za-z0-9_]+$/.test(field)) {
9
+ throw new Error(`invalid tmux format field: ${String(field)}`);
10
+ }
11
+ return `#{q:${field}}`;
12
+ }).join('|');
13
+ }
14
+ export function parseTmuxFields(line, columns, label = 'tmux') {
15
+ const value = String(line);
16
+ const fields = [];
17
+ let field = '';
18
+ for (let i = 0; i < value.length; i++) {
19
+ const char = value.charAt(i);
20
+ if (char === '|') {
21
+ fields.push(field);
22
+ field = '';
23
+ continue;
24
+ }
25
+ if (char !== '\\') {
26
+ field += char;
27
+ continue;
28
+ }
29
+ if (i + 1 >= value.length)
30
+ throw new Error(`invalid ${label} format: dangling escape`);
31
+ const escaped = value.charAt(++i);
32
+ if (!Q_ESCAPED.has(escaped))
33
+ throw new Error(`invalid ${label} format: unsupported escape`);
34
+ field += escaped;
35
+ }
36
+ fields.push(field);
37
+ if (fields.length !== columns)
38
+ throw new Error(`invalid ${label} format: expected ${columns} fields`);
39
+ return fields;
40
+ }
41
+ export function parseTmuxRows(output, columns, label = 'tmux') {
42
+ const value = String(output ?? '');
43
+ if (value === '' || value === '\n' || value === '\r\n')
44
+ return [];
45
+ return value.replace(/\r?\n$/, '').split(/\r?\n/)
46
+ .map((line) => parseTmuxFields(line, columns, label));
47
+ }