handmux 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  3. package/bin/handmux.js +3 -689
  4. package/dist/bin/handmux.js +850 -0
  5. package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
  6. package/{hooks → dist/hooks}/handmux-write.cjs +24 -3
  7. package/dist/package.json +126 -0
  8. package/dist/public/assets/index-CicdY9E9.css +32 -0
  9. package/dist/public/assets/index-D4VP5t8h.js +337 -0
  10. package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
  11. package/{public → dist/public}/index.html +5 -4
  12. package/{src → dist/src}/agents/claude.js +105 -83
  13. package/dist/src/agents/codex.js +217 -0
  14. package/{src → dist/src}/agents/index.js +7 -7
  15. package/dist/src/agents/processIdentity.js +80 -0
  16. package/dist/src/agents/scanUtils.js +262 -0
  17. package/dist/src/apiErrors.js +77 -0
  18. package/{src → dist/src}/appName.js +12 -13
  19. package/dist/src/asr/iflyConfig.js +7 -0
  20. package/dist/src/asr/iflySign.js +14 -0
  21. package/dist/src/auth.js +27 -0
  22. package/dist/src/browser/bootstrap.js +72 -0
  23. package/dist/src/browser/cookieProfiles.js +413 -0
  24. package/dist/src/browser/coordinator.js +51 -0
  25. package/dist/src/browser/hammerheadCompat.js +104 -0
  26. package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
  27. package/dist/src/browser/manager.js +560 -0
  28. package/dist/src/browser/originLabel.js +15 -0
  29. package/dist/src/browser/profilePersistence.js +235 -0
  30. package/dist/src/browser/publicProxy.js +176 -0
  31. package/dist/src/browser/routes.js +159 -0
  32. package/dist/src/browser/sessionStore.js +72 -0
  33. package/dist/src/browser/siteVersion.js +121 -0
  34. package/dist/src/browser/targetPolicy.js +163 -0
  35. package/{src → dist/src}/browser/worker.js +14 -18
  36. package/dist/src/browser/workerClient.js +286 -0
  37. package/dist/src/browser/workerServer.js +91 -0
  38. package/dist/src/captureBackground.js +95 -0
  39. package/dist/src/claudeEvents.js +499 -0
  40. package/{src → dist/src}/cli/claudeHooks.js +146 -108
  41. package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
  42. package/dist/src/cli/cloudflared.js +106 -0
  43. package/dist/src/cli/codexManaged.js +77 -0
  44. package/{src → dist/src}/cli/cpolarUrl.js +8 -9
  45. package/dist/src/cli/drivers.js +98 -0
  46. package/dist/src/cli/hookScaffold.js +36 -0
  47. package/dist/src/cli/i18n/en.js +366 -0
  48. package/{src → dist/src}/cli/i18n/index.js +27 -25
  49. package/dist/src/cli/i18n/zh.js +365 -0
  50. package/dist/src/cli/legacyCodexHooks.js +52 -0
  51. package/{src → dist/src}/cli/natappUrl.js +2 -3
  52. package/dist/src/cli/options.js +245 -0
  53. package/dist/src/cli/probe.js +16 -0
  54. package/{src → dist/src}/cli/prompt.js +4 -6
  55. package/dist/src/cli/pushCmd.js +102 -0
  56. package/{src → dist/src}/cli/qr.js +19 -19
  57. package/dist/src/cli/service.js +144 -0
  58. package/dist/src/cli/setupModel.js +194 -0
  59. package/dist/src/cli/setupWizard.js +647 -0
  60. package/dist/src/cli/shortcutEditor.js +351 -0
  61. package/dist/src/cli/sshTunnel.js +18 -0
  62. package/dist/src/cli/state.js +114 -0
  63. package/{src → dist/src}/cli/statusLine.js +63 -46
  64. package/dist/src/cli/supervisor.js +278 -0
  65. package/dist/src/cli/supervisorHealth.js +21 -0
  66. package/dist/src/cli/supervisorLaunch.js +148 -0
  67. package/dist/src/cli/supervisorProcesses.js +62 -0
  68. package/dist/src/cli/supervisorState.js +62 -0
  69. package/dist/src/cli/tmuxVersion.js +53 -0
  70. package/{src → dist/src}/cli/tunlite.js +7 -10
  71. package/dist/src/cli/tunnelClients.js +72 -0
  72. package/{src → dist/src}/cli/updateCheck.js +98 -69
  73. package/{src → dist/src}/cli/urlHost.js +8 -4
  74. package/dist/src/cli/workspaceCmd.js +347 -0
  75. package/dist/src/codexAppServer.js +2299 -0
  76. package/dist/src/codexConversationProjection.js +199 -0
  77. package/dist/src/codexDiff.js +118 -0
  78. package/dist/src/codexMessageIdentity.js +18 -0
  79. package/dist/src/codexPlan.js +30 -0
  80. package/dist/src/codexQueueProtocol.js +96 -0
  81. package/dist/src/codexStreamProtocol.js +207 -0
  82. package/dist/src/codexToolProtocol.js +68 -0
  83. package/dist/src/codexTranscriptParse.js +724 -0
  84. package/dist/src/codexUsageSnapshot.js +205 -0
  85. package/dist/src/config.js +8 -0
  86. package/{src → dist/src}/docPath.js +22 -24
  87. package/dist/src/docs.js +337 -0
  88. package/dist/src/git.js +272 -0
  89. package/dist/src/healthProtocol.js +97 -0
  90. package/dist/src/httpApi.js +68 -0
  91. package/dist/src/jsonStore.js +15 -0
  92. package/{src → dist/src}/keyNames.js +3 -3
  93. package/dist/src/notifications.js +111 -0
  94. package/dist/src/orphans.js +157 -0
  95. package/dist/src/pendingPrompt.js +194 -0
  96. package/dist/src/previewServer.js +202 -0
  97. package/dist/src/previews.js +179 -0
  98. package/dist/src/privateStateStore.js +89 -0
  99. package/dist/src/push.js +224 -0
  100. package/dist/src/routes/codex.js +606 -0
  101. package/dist/src/routes/files.js +230 -0
  102. package/dist/src/routes/git.js +78 -0
  103. package/dist/src/routes/health.js +16 -0
  104. package/dist/src/routes/notifications.js +16 -0
  105. package/dist/src/routes/previews.js +43 -0
  106. package/dist/src/routes/push.js +145 -0
  107. package/dist/src/routes/sessions.js +232 -0
  108. package/dist/src/routes/system.js +156 -0
  109. package/dist/src/routes/terminal.js +238 -0
  110. package/dist/src/routes/transcript.js +197 -0
  111. package/dist/src/routes/workspace.js +267 -0
  112. package/{src → dist/src}/server.js +100 -74
  113. package/dist/src/shortcutConfig.js +83 -0
  114. package/{src → dist/src}/staticCache.js +5 -3
  115. package/{src → dist/src}/staticCompression.js +5 -6
  116. package/dist/src/terminalStream.js +441 -0
  117. package/{src → dist/src}/tmux/commands.js +156 -171
  118. package/dist/src/tmux/format.js +47 -0
  119. package/dist/src/transcriptParse.js +240 -0
  120. package/dist/src/transcriptReader.js +113 -0
  121. package/{src → dist/src}/trimCapture.js +11 -11
  122. package/dist/src/uploadTypes.js +29 -0
  123. package/dist/src/urlPolicy.js +18 -0
  124. package/dist/src/usage.js +260 -0
  125. package/dist/src/workspace/agentRunner.js +204 -0
  126. package/dist/src/workspace/atomicJson.js +31 -0
  127. package/dist/src/workspace/capture.js +139 -0
  128. package/dist/src/workspace/checkpointer.js +228 -0
  129. package/dist/src/workspace/environment.js +71 -0
  130. package/dist/src/workspace/lock.js +137 -0
  131. package/dist/src/workspace/mapping.js +128 -0
  132. package/dist/src/workspace/operations.js +271 -0
  133. package/dist/src/workspace/paths.js +15 -0
  134. package/dist/src/workspace/planner.js +292 -0
  135. package/dist/src/workspace/restore.js +314 -0
  136. package/dist/src/workspace/runtime.js +318 -0
  137. package/dist/src/workspace/schema.js +156 -0
  138. package/dist/src/workspace/store.js +464 -0
  139. package/dist/src/workspace/tmuxAdapter.js +694 -0
  140. package/dist/src/workspaceProtocol.js +158 -0
  141. package/package.json +27 -15
  142. package/public/assets/index-C3N7OkyP.css +0 -32
  143. package/public/assets/index-D1DWcC5u.js +0 -337
  144. package/src/.gitkeep +0 -0
  145. package/src/agents/codex.js +0 -168
  146. package/src/agents/processIdentity.js +0 -72
  147. package/src/agents/scanUtils.js +0 -225
  148. package/src/asr/iflyConfig.js +0 -10
  149. package/src/asr/iflySign.js +0 -16
  150. package/src/auth.js +0 -30
  151. package/src/browser/bootstrap.js +0 -68
  152. package/src/browser/cookieProfiles.js +0 -403
  153. package/src/browser/coordinator.js +0 -47
  154. package/src/browser/hammerheadCompat.js +0 -86
  155. package/src/browser/hammerheadRedirectCompat.js +0 -38
  156. package/src/browser/manager.js +0 -556
  157. package/src/browser/originLabel.js +0 -17
  158. package/src/browser/profilePersistence.js +0 -241
  159. package/src/browser/publicProxy.js +0 -175
  160. package/src/browser/routes.js +0 -154
  161. package/src/browser/sessionStore.js +0 -78
  162. package/src/browser/siteVersion.js +0 -124
  163. package/src/browser/targetPolicy.js +0 -137
  164. package/src/browser/workerClient.js +0 -253
  165. package/src/browser/workerServer.js +0 -98
  166. package/src/captureBackground.js +0 -93
  167. package/src/claudeEvents.js +0 -399
  168. package/src/cli/cloudflared.js +0 -99
  169. package/src/cli/codexManaged.js +0 -77
  170. package/src/cli/drivers.js +0 -97
  171. package/src/cli/hookScaffold.js +0 -33
  172. package/src/cli/i18n/en.js +0 -385
  173. package/src/cli/i18n/zh.js +0 -384
  174. package/src/cli/legacyCodexHooks.js +0 -49
  175. package/src/cli/options.js +0 -211
  176. package/src/cli/probe.js +0 -16
  177. package/src/cli/pushCmd.js +0 -60
  178. package/src/cli/service.js +0 -126
  179. package/src/cli/setupModel.js +0 -154
  180. package/src/cli/setupWizard.js +0 -506
  181. package/src/cli/shortcutEditor.js +0 -288
  182. package/src/cli/sshTunnel.js +0 -12
  183. package/src/cli/state.js +0 -90
  184. package/src/cli/supervisor.js +0 -183
  185. package/src/cli/supervisorProcesses.js +0 -57
  186. package/src/cli/tmuxVersion.js +0 -49
  187. package/src/cli/tunnelClients.js +0 -62
  188. package/src/cli/workspaceCmd.js +0 -300
  189. package/src/codexAppServer.js +0 -1620
  190. package/src/codexDiff.js +0 -108
  191. package/src/codexTranscriptParse.js +0 -413
  192. package/src/codexUsageSnapshot.cjs +0 -129
  193. package/src/config.js +0 -9
  194. package/src/docs.js +0 -228
  195. package/src/git.js +0 -208
  196. package/src/httpApi.js +0 -72
  197. package/src/jsonStore.js +0 -24
  198. package/src/notifications.js +0 -68
  199. package/src/orphans.js +0 -152
  200. package/src/pendingPrompt.js +0 -163
  201. package/src/previewServer.js +0 -168
  202. package/src/previews.js +0 -116
  203. package/src/push.js +0 -183
  204. package/src/routes/codex.js +0 -473
  205. package/src/routes/files.js +0 -172
  206. package/src/routes/git.js +0 -57
  207. package/src/routes/notifications.js +0 -19
  208. package/src/routes/previews.js +0 -35
  209. package/src/routes/push.js +0 -119
  210. package/src/routes/sessions.js +0 -168
  211. package/src/routes/system.js +0 -132
  212. package/src/routes/terminal.js +0 -210
  213. package/src/routes/transcript.js +0 -171
  214. package/src/routes/workspace.js +0 -267
  215. package/src/shortcutConfig.js +0 -75
  216. package/src/terminalStream.js +0 -391
  217. package/src/tmux/format.js +0 -45
  218. package/src/transcriptParse.js +0 -180
  219. package/src/transcriptReader.js +0 -101
  220. package/src/uploadTypes.js +0 -30
  221. package/src/urlPolicy.js +0 -14
  222. package/src/usage.js +0 -260
  223. package/src/workspace/agentRunner.js +0 -188
  224. package/src/workspace/atomicJson.js +0 -32
  225. package/src/workspace/capture.js +0 -139
  226. package/src/workspace/checkpointer.js +0 -187
  227. package/src/workspace/environment.js +0 -75
  228. package/src/workspace/lock.js +0 -128
  229. package/src/workspace/mapping.js +0 -124
  230. package/src/workspace/operations.js +0 -252
  231. package/src/workspace/paths.js +0 -16
  232. package/src/workspace/planner.js +0 -247
  233. package/src/workspace/restore.js +0 -279
  234. package/src/workspace/runtime.js +0 -314
  235. package/src/workspace/schema.js +0 -136
  236. package/src/workspace/store.js +0 -382
  237. package/src/workspace/tmuxAdapter.js +0 -554
  238. /package/{hooks → dist/hooks}/handmux-notify.sh +0 -0
  239. /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  240. /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
  241. /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
  242. /package/{public → dist/public}/icons/badge-96.png +0 -0
  243. /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
  244. /package/{public → dist/public}/icons/icon-192.png +0 -0
  245. /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
  246. /package/{public → dist/public}/icons/icon-512.png +0 -0
  247. /package/{public → dist/public}/icons/logo.svg +0 -0
  248. /package/{public → dist/public}/manifest.webmanifest +0 -0
  249. /package/{public → dist/public}/offline.html +0 -0
  250. /package/{public → dist/public}/sw.js +0 -0
  251. /package/{src → dist/src}/browser/protocol.js +0 -0
  252. /package/{src → dist/src}/cli/cfNamed.js +0 -0
@@ -0,0 +1,262 @@
1
+ // Agent-agnostic scan/parse helpers shared by every agent driver and the orphan engine. This is the LEAF
2
+ // layer: it imports nothing from the agent registry, so drivers (claude.js / codex.js) and the engine
3
+ // (orphans.js) can all depend on it without an import cycle.
4
+ //
5
+ // Everything here is about turning `ps`/`tmux`/`lsof` output and session jsonl files into structured data;
6
+ // none of it knows which coding agent it's looking at — the per-agent specifics (which process name, where
7
+ // sessions live, how to resume) live in the driver descriptors that USE these helpers.
8
+ import { execFile } from 'node:child_process';
9
+ import { promises as fsp } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { parseTmuxRows } from '../tmux/format.js';
12
+ const isRecord = (value) => value !== null && typeof value === 'object';
13
+ // Tolerant promisified execFile: resolves '' on any error (no server, missing binary, non-zero exit).
14
+ // Detection is best-effort and must never throw the whole request.
15
+ export function defaultRun(cmd, args) {
16
+ return new Promise((resolve) => {
17
+ execFile(cmd, args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
18
+ resolve(err ? '' : String(stdout));
19
+ });
20
+ });
21
+ }
22
+ // A coding-agent session id is a UUID (Claude's jsonl filename; Codex's rollout-file trailing id). Validate
23
+ // strictly: takeover types `<bin> resume <id>` into a shell via send-keys, so a non-UUID id would be a
24
+ // shell-injection vector.
25
+ export const isSessionUuid = (value) => typeof value === 'string'
26
+ && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
27
+ // Strip /dev/ and fold "no controlling terminal" markers (macOS '??', Linux '?') to '' so ps ttys and
28
+ // tmux pane_ttys compare equal: ps 'ttys010' / tmux '/dev/ttys010' → 'ttys010'; ps 'pts/3' / tmux
29
+ // '/dev/pts/3' → 'pts/3'.
30
+ export function normTty(value) {
31
+ const s = String(value || '').trim();
32
+ if (!s || s === '??' || s === '?' || s === '-')
33
+ return '';
34
+ return s.replace(/^\/dev\//, '');
35
+ }
36
+ // ps `etime` (elapsed since start) → milliseconds. Formats (macOS + Linux, no spaces): "MM:SS",
37
+ // "HH:MM:SS", "DD-HH:MM:SS". Used only to derive a startedAt for display/recognition (the "A加成"),
38
+ // NOT for session attribution — a resumed session's process starts long after its jsonl's first event.
39
+ export function etimeToMs(etime) {
40
+ const s = String(etime).trim();
41
+ if (!s)
42
+ return 0;
43
+ let days = 0;
44
+ let rest = s;
45
+ const dash = s.indexOf('-');
46
+ if (dash >= 0) {
47
+ days = Number(s.slice(0, dash)) || 0;
48
+ rest = s.slice(dash + 1);
49
+ }
50
+ let sec = 0;
51
+ for (const p of rest.split(':'))
52
+ sec = sec * 60 + (Number(p) || 0);
53
+ return (days * 86400 + sec) * 1000;
54
+ }
55
+ // Parse `ps -Ao pid=,ppid=,stat=,etime=,tty=,args=` → LIVE agent processes only, each tagged with the id of
56
+ // the FIRST driver whose `procMatch` regex matches its argv. `agents` is the driver list; a proc matching
57
+ // none is dropped. args (last column) may contain spaces; etime has none. STOPPED (STAT 'T', a Ctrl-Z-
58
+ // suspended job-control stack — verified real: one terminal can hold 8 suspended `claude`s) and ZOMBIE
59
+ // ('Z') processes are dropped: they aren't active sessions to steer, and a suspended original can't write
60
+ // its jsonl so there's nothing to race.
61
+ export function parseAgentProcs(psOut, agents) {
62
+ const out = [];
63
+ for (const line of String(psOut).split('\n')) {
64
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)$/);
65
+ if (!m)
66
+ continue;
67
+ const [, pid, ppid, stat, etime, tty, rawArgs] = m;
68
+ if (!pid || !ppid || !stat || !etime || !tty || rawArgs === undefined)
69
+ continue;
70
+ if (stat.startsWith('T') || stat.startsWith('Z'))
71
+ continue;
72
+ const args = rawArgs.trim();
73
+ const agent = agents.find((a) => a.procMatch.test(args));
74
+ if (!agent)
75
+ continue;
76
+ out.push({ pid: Number(pid), ppid: Number(ppid), etimeMs: etimeToMs(etime), tty: normTty(tty), args, agent: agent.id });
77
+ }
78
+ return out;
79
+ }
80
+ // Parse q-escaped `tmux list-panes` rows → the set of pane ttys and pane (shell) pids.
81
+ export function parsePaneMembership(tmuxOut) {
82
+ const ttys = new Set();
83
+ const pids = new Set();
84
+ for (const [tty, pid] of parseTmuxRows(tmuxOut, 2, 'pane membership')) {
85
+ const nt = normTty(tty);
86
+ if (nt)
87
+ ttys.add(nt);
88
+ const n = Number(pid);
89
+ if (n)
90
+ pids.add(n);
91
+ }
92
+ return { ttys, pids };
93
+ }
94
+ // Orphan = an agent proc WITH a real controlling tty that is neither one of tmux's pane ttys nor a child of
95
+ // a pane's shell. The tty requirement drops background/headless runs (SDK/`-p`/`exec` piped, tty '') —
96
+ // those aren't interactive sessions a user would "take over".
97
+ export function findOrphans(procs, membership) {
98
+ return procs.filter((p) => p.tty && !membership.ttys.has(p.tty) && !membership.pids.has(p.ppid));
99
+ }
100
+ // A real cwd → its ~/.claude/projects directory name. Claude replaces every non-alphanumeric char with
101
+ // '-' (verified: '/home/user/handmux' → '-home-user-handmux'; both '/'
102
+ // and '_' fold to '-'). The mapping is LOSSY (not reversible), so we only ever encode forward, then
103
+ // confirm each candidate jsonl's recorded `cwd` matches before trusting it.
104
+ export function encodeProjectDir(cwd) {
105
+ return String(cwd).replace(/[^A-Za-z0-9]/g, '-');
106
+ }
107
+ // Read the last `bytes` of a file (for the trailing conversation). The first line of the chunk may be
108
+ // truncated mid-JSON — callers skip lines that don't parse.
109
+ export async function readTail(file, bytes = 65536) {
110
+ const fh = await fsp.open(file, 'r');
111
+ try {
112
+ const { size } = await fh.stat();
113
+ const start = Math.max(0, size - bytes);
114
+ const len = size - start;
115
+ if (len <= 0)
116
+ return '';
117
+ const buf = Buffer.alloc(len);
118
+ await fh.read(buf, 0, len, start);
119
+ return buf.toString('utf8');
120
+ }
121
+ finally {
122
+ await fh.close();
123
+ }
124
+ }
125
+ // Read the first `bytes` of a file (the session header carries `cwd` early).
126
+ export async function readHead(file, bytes = 65536) {
127
+ const fh = await fsp.open(file, 'r');
128
+ try {
129
+ const buf = Buffer.alloc(bytes);
130
+ const { bytesRead } = await fh.read(buf, 0, bytes, 0);
131
+ return buf.slice(0, bytesRead).toString('utf8');
132
+ }
133
+ finally {
134
+ await fh.close();
135
+ }
136
+ }
137
+ // First `"cwd":"..."` anywhere in a chunk (both Claude and Codex record the session cwd early in the file).
138
+ export function firstCwd(headText) {
139
+ const m = String(headText).match(/"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/);
140
+ const encoded = m?.[1];
141
+ if (encoded === undefined)
142
+ return '';
143
+ try {
144
+ const parsed = JSON.parse(`"${encoded}"`);
145
+ return typeof parsed === 'string' ? parsed : encoded;
146
+ }
147
+ catch {
148
+ return encoded;
149
+ }
150
+ }
151
+ // Pull the last user-typed message text out of a Claude jsonl tail, for a recognizable one-line label.
152
+ // Entries are line-delimited JSON; conversational turns are type 'user' with message.role 'user', whose
153
+ // content is either a string or an array of blocks ({type:'text',text}). Meta rows (last-prompt/ai-title/
154
+ // mode/attachment/summary) are ignored. Scans newest-first.
155
+ export function lastUserSnippet(tailText, max = 80) {
156
+ const rows = String(tailText).split('\n');
157
+ for (let i = rows.length - 1; i >= 0; i--) {
158
+ const line = rows[i]?.trim() ?? '';
159
+ if (!line || line[0] !== '{')
160
+ continue;
161
+ let data;
162
+ try {
163
+ data = JSON.parse(line);
164
+ }
165
+ catch {
166
+ continue;
167
+ }
168
+ if (!isRecord(data) || data.type !== 'user' || !isRecord(data.message) || data.message.role !== 'user')
169
+ continue;
170
+ const c = data.message.content;
171
+ let text = '';
172
+ if (typeof c === 'string')
173
+ text = c;
174
+ else if (Array.isArray(c))
175
+ text = c
176
+ .filter((block) => isRecord(block) && block.type === 'text')
177
+ .map((block) => typeof block.text === 'string' ? block.text : '')
178
+ .join(' ');
179
+ text = text.replace(/\s+/g, ' ').trim();
180
+ if (text)
181
+ return text.length > max ? `${text.slice(0, max)}…` : text;
182
+ }
183
+ return '';
184
+ }
185
+ // Resolve a cwd to the newest jsonl in its ENCODED project dir whose recorded cwd matches (guards against
186
+ // the lossy encoding colliding two real paths). This is Claude's layout (~/.claude/projects/<enc-cwd>/
187
+ // <uuid>.jsonl); Codex has its own resolver. `snippet` extracts the last user turn (parser injected so
188
+ // each agent's jsonl shape is handled). Returns sessionId, a busy/idle guess (mtime recency), the snippet,
189
+ // and the last-activity timestamp; {} when the dir is absent or nothing matches.
190
+ export async function resolveEncodedDirSession(projectsDir, cwd, { busyMs = 8000, now = Date.now, snippet = lastUserSnippet } = {}) {
191
+ const dir = path.join(projectsDir, encodeProjectDir(cwd));
192
+ let names;
193
+ try {
194
+ names = (await fsp.readdir(dir)).filter((n) => n.endsWith('.jsonl'));
195
+ }
196
+ catch {
197
+ return {};
198
+ }
199
+ const stats = [];
200
+ for (const n of names) {
201
+ try {
202
+ stats.push({ n, mtime: (await fsp.stat(path.join(dir, n))).mtimeMs });
203
+ }
204
+ catch { /* gone */ }
205
+ }
206
+ stats.sort((a, b) => b.mtime - a.mtime);
207
+ for (const { n, mtime } of stats) {
208
+ const file = path.join(dir, n);
209
+ let head;
210
+ try {
211
+ head = await readHead(file);
212
+ }
213
+ catch {
214
+ continue;
215
+ }
216
+ if (firstCwd(head) !== cwd)
217
+ continue; // different real path collided onto the same dir → skip
218
+ let snip = '';
219
+ try {
220
+ snip = snippet(await readTail(file));
221
+ }
222
+ catch { /* best effort */ }
223
+ return {
224
+ sessionId: n.replace(/\.jsonl$/, ''),
225
+ state: now() - mtime < busyMs ? 'busy' : 'idle',
226
+ snippet: snip,
227
+ lastActivity: Math.round(mtime),
228
+ };
229
+ }
230
+ return {};
231
+ }
232
+ // A tmux session name derived from a cwd basename, kept within isValidSessionName ([A-Za-z0-9-], ≤16):
233
+ // `<prefix>-<alnum label, ≤8>-<n>`. n disambiguates against existing sessions.
234
+ export function takeoverSessionName(cwdLabel, n, prefix = 'cc') {
235
+ const base = String(cwdLabel || '').replace(/[^A-Za-z0-9]/g, '').slice(0, 8) || prefix;
236
+ return `${prefix}-${base}-${n}`.slice(0, 16);
237
+ }
238
+ // Coerce a user-typed takeover name into a valid tmux session name (isValidSessionName: [A-Za-z0-9-], ≤16):
239
+ // non-alnum runs → a single '-', trimmed of edge hyphens, capped at 16. Returns '' if nothing usable is
240
+ // left (caller then falls back to the generated name).
241
+ export function sanitizeSessionName(s) {
242
+ return String(s || '').replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 16).replace(/-+$/, '');
243
+ }
244
+ // First name in `<base>, base-2, base-3, …` (each capped at 16) that isn't already taken. null if none free.
245
+ export function freeSessionName(base, taken) {
246
+ if (!taken.has(base))
247
+ return base;
248
+ for (let i = 2; i < 1000; i++) {
249
+ const cand = `${base}-${i}`.slice(0, 16);
250
+ if (!taken.has(cand))
251
+ return cand;
252
+ }
253
+ return null;
254
+ }
255
+ export const isShell = (command) => /^-?(zsh|bash|sh|fish|dash|tcsh|csh|ksh)$/.test(String(command || ''));
256
+ export async function lsofCwd(run, pid) {
257
+ const out = await run('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn']);
258
+ for (const line of out.split('\n'))
259
+ if (line[0] === 'n')
260
+ return line.slice(1).trim();
261
+ return '';
262
+ }
@@ -0,0 +1,77 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export class PublicApiError extends Error {
3
+ status;
4
+ code;
5
+ constructor(status, code, message) {
6
+ super(message);
7
+ this.name = 'PublicApiError';
8
+ this.status = status;
9
+ this.code = code;
10
+ }
11
+ }
12
+ function requestIdFor(locals, idFactory) {
13
+ if (typeof locals.requestId === 'string' && locals.requestId)
14
+ return locals.requestId;
15
+ const requestId = idFactory();
16
+ locals.requestId = requestId;
17
+ return requestId;
18
+ }
19
+ function errorFields(error) {
20
+ if (!(error instanceof Error))
21
+ return { value: String(error) };
22
+ return { name: error.name, message: error.message, stack: error.stack };
23
+ }
24
+ function isInvalidJson(error) {
25
+ if (!(error instanceof SyntaxError) || !error || typeof error !== 'object')
26
+ return false;
27
+ const candidate = error;
28
+ return candidate.status === 400 && candidate.type === 'entity.parse.failed';
29
+ }
30
+ export function apiRequestContext({ idFactory = randomUUID } = {}) {
31
+ return (_req, res, next) => {
32
+ const requestId = requestIdFor(res.locals, idFactory);
33
+ res.setHeader('X-Request-Id', requestId);
34
+ next();
35
+ };
36
+ }
37
+ export function apiNotFound({ idFactory = randomUUID } = {}) {
38
+ return (_req, res) => {
39
+ const requestId = requestIdFor(res.locals, idFactory);
40
+ res.setHeader('X-Request-Id', requestId);
41
+ const body = { error: 'not found', code: 'not_found', requestId };
42
+ res.status(404).json(body);
43
+ };
44
+ }
45
+ export function apiErrorBoundary({ idFactory = randomUUID, log = console, } = {}) {
46
+ return (error, req, res, next) => {
47
+ if (res.headersSent) {
48
+ next(error);
49
+ return;
50
+ }
51
+ const requestId = requestIdFor(res.locals, idFactory);
52
+ res.setHeader('X-Request-Id', requestId);
53
+ let status = 500;
54
+ let code = 'internal_error';
55
+ let message = 'internal server error';
56
+ if (error instanceof PublicApiError) {
57
+ status = error.status;
58
+ code = error.code;
59
+ message = error.message;
60
+ }
61
+ else if (isInvalidJson(error)) {
62
+ status = 400;
63
+ code = 'invalid_json';
64
+ message = 'invalid json';
65
+ }
66
+ if (status >= 500) {
67
+ log.error('[handmux] api error', {
68
+ requestId,
69
+ method: req.method,
70
+ path: req.originalUrl,
71
+ error: errorFields(error),
72
+ });
73
+ }
74
+ const body = { error: message, code, requestId };
75
+ res.status(status).json(body);
76
+ };
77
+ }
@@ -1,23 +1,22 @@
1
1
  // Runtime app-name injection. The web bundle ships prebuilt, so a user's custom name (set at
2
2
  // `handmux start --name`) can't be baked at build time — instead the server rewrites the name into
3
3
  // the shell HTML and the PWA manifest as it serves them. Pure string/object transforms so they're
4
- // unit-testable; the server side-effects (read file / send) live in server.js.
5
-
6
- const escAttr = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
7
- .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
8
-
4
+ // unit-testable; the server side-effects (read file / send) live in server.ts.
5
+ const escAttr = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
6
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
9
7
  // Replace the browser-tab title and the iOS home-screen label in index.html. Matches by tag/attr,
10
8
  // not by the default text, so it survives a changed default. No-op when a tag is absent.
11
9
  export function applyAppName(html, name) {
12
- if (!name) return html;
13
- const e = escAttr(name);
14
- return html
15
- .replace(/<title>[^<]*<\/title>/, `<title>${e}</title>`)
16
- .replace(/(<meta name="apple-mobile-web-app-title" content=")[^"]*(")/, `$1${e}$2`);
10
+ if (!name)
11
+ return html;
12
+ const e = escAttr(name);
13
+ return html
14
+ .replace(/<title>[^<]*<\/title>/, `<title>${e}</title>`)
15
+ .replace(/(<meta name="apple-mobile-web-app-title" content=")[^"]*(")/, `$1${e}$2`);
17
16
  }
18
-
19
17
  // Override the PWA install name (Android uses short_name; both set so home-screen + app list agree).
20
18
  export function applyManifestName(manifest, name) {
21
- if (!name) return manifest;
22
- return { ...manifest, name, short_name: name };
19
+ if (!name)
20
+ return manifest;
21
+ return { ...manifest, name, short_name: name };
23
22
  }
@@ -0,0 +1,7 @@
1
+ export function asrConfig(env = process.env) {
2
+ return { appId: env.XFYUN_APPID || '', apiKey: env.XFYUN_APIKEY || '', apiSecret: env.XFYUN_APISECRET || '' };
3
+ }
4
+ export function isAsrConfigured(env = process.env) {
5
+ const c = asrConfig(env);
6
+ return !!(c.appId && c.apiKey && c.apiSecret);
7
+ }
@@ -0,0 +1,14 @@
1
+ import { createHmac } from 'node:crypto';
2
+ // Build a fully-signed iFlytek IAT v2 WebSocket URL. The secret (apiSecret) is used only here on the
3
+ // server — the browser receives only the resulting URL (HMAC output, not the key). `host`/`date` are
4
+ // injectable so this is deterministic and unit-testable; production passes host='iat-api.xfyun.cn'
5
+ // and date=new Date().toUTCString() (RFC1123 GMT, iFlytek allows ±300s skew).
6
+ export function buildIatSignedUrl({ appId, apiKey, apiSecret, host = 'iat-api.xfyun.cn', date, }) {
7
+ const requestLine = 'GET /v2/iat HTTP/1.1';
8
+ const signatureOrigin = `host: ${host}\ndate: ${date}\n${requestLine}`;
9
+ const signature = createHmac('sha256', apiSecret).update(signatureOrigin).digest('base64');
10
+ const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`;
11
+ const authorization = Buffer.from(authorizationOrigin, 'utf8').toString('base64');
12
+ const qs = new URLSearchParams({ authorization, date, host });
13
+ return { url: `wss://${host}/v2/iat?${qs.toString()}`, appId };
14
+ }
@@ -0,0 +1,27 @@
1
+ import crypto from 'node:crypto';
2
+ export function loadToken(env = process.env) {
3
+ let token = env.HANDMUX_TOKEN;
4
+ if (!token) {
5
+ token = crypto.randomBytes(24).toString('base64url');
6
+ console.log(`[handmux] no HANDMUX_TOKEN set; generated token: ${token}`);
7
+ }
8
+ return token;
9
+ }
10
+ const sha = (value) => crypto.createHash('sha256').update(String(value)).digest();
11
+ export function tokenEquals(a, b) {
12
+ return crypto.timingSafeEqual(sha(a), sha(b));
13
+ }
14
+ export function bearerFrom(header) {
15
+ if (!header)
16
+ return null;
17
+ const m = /^Bearer (.+)$/.exec(header);
18
+ return m?.[1] ?? null;
19
+ }
20
+ export function expressAuth(token) {
21
+ return (req, res, next) => {
22
+ const provided = bearerFrom(req.get('authorization'));
23
+ if (provided && tokenEquals(provided, token))
24
+ return next();
25
+ res.status(401).json({ error: 'unauthorized' });
26
+ };
27
+ }
@@ -0,0 +1,72 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ const BOOTSTRAP_PREFIX = '/_browser-bootstrap/';
3
+ function normalizedOrigin(raw) {
4
+ const url = new URL(raw);
5
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
6
+ throw new Error('browser bootstrap origin must use http or https');
7
+ return url.origin;
8
+ }
9
+ export function isBrowserBootstrapPath(pathname) {
10
+ return String(pathname || '').startsWith(BOOTSTRAP_PREFIX);
11
+ }
12
+ export function createBrowserBootstrapStore({ randomToken = () => randomBytes(24).toString('base64url'), now = Date.now, ttlMs = 60_000, } = {}) {
13
+ const tickets = new Map();
14
+ const prune = () => {
15
+ const current = now();
16
+ for (const [token, entry] of tickets)
17
+ if (entry.expiresAt <= current)
18
+ tickets.delete(token);
19
+ };
20
+ return {
21
+ issue({ url, origin, deviceId, preserveMethod = false, redirectStatus = 302, }) {
22
+ prune();
23
+ const expectedOrigin = normalizedOrigin(origin);
24
+ const target = new URL(url);
25
+ if (target.origin !== expectedOrigin || !target.pathname.split('/')[1]?.startsWith('_browser-')) {
26
+ throw new Error('browser bootstrap target must be a session on its public origin');
27
+ }
28
+ const token = randomToken();
29
+ tickets.set(token, {
30
+ url: target.toString(),
31
+ origin: expectedOrigin,
32
+ deviceId,
33
+ preserveMethod,
34
+ redirectStatus,
35
+ expiresAt: now() + ttlMs,
36
+ });
37
+ return new URL(`${BOOTSTRAP_PREFIX}${encodeURIComponent(token)}`, expectedOrigin).toString();
38
+ },
39
+ consume(pathname, origin) {
40
+ prune();
41
+ if (!isBrowserBootstrapPath(pathname))
42
+ return null;
43
+ let token;
44
+ try {
45
+ token = decodeURIComponent(String(pathname).slice(BOOTSTRAP_PREFIX.length));
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ const entry = tickets.get(token);
51
+ let requestedOrigin;
52
+ try {
53
+ requestedOrigin = normalizedOrigin(origin);
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ if (!entry || entry.origin !== requestedOrigin)
59
+ return null;
60
+ tickets.delete(token);
61
+ if (entry.preserveMethod) {
62
+ return {
63
+ url: entry.url,
64
+ deviceId: entry.deviceId,
65
+ preserveMethod: true,
66
+ redirectStatus: entry.redirectStatus,
67
+ };
68
+ }
69
+ return { url: entry.url, deviceId: entry.deviceId };
70
+ },
71
+ };
72
+ }