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,230 @@
1
+ // File browsing under $HOME: read a doc, list/create a dir, download any file, and upload. The docs data
2
+ // layer realpaths + enforces the under-$HOME containment; these routes only map {error,status} to HTTP
3
+ // and own the multipart upload plumbing. Mounted under /api by createApiRouter.
4
+ import express from 'express';
5
+ import busboy from 'busboy';
6
+ import { promises as fsp, createWriteStream } from 'node:fs';
7
+ import { join as joinPath } from 'node:path';
8
+ import { randomBytes } from 'node:crypto';
9
+ import { safeUploadName } from '../docPath.js';
10
+ import { isAllowedUploadExt } from '../uploadTypes.js';
11
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
12
+ function errorCode(error) {
13
+ if (typeof error !== 'object' || error === null || !('code' in error))
14
+ return undefined;
15
+ return typeof error.code === 'string' ? error.code : undefined;
16
+ }
17
+ export function fileRoutes({ docs, uploadExts, maxUploadBytes }) {
18
+ const r = express.Router();
19
+ // Read a single text file under $HOME. The docs layer realpaths, enforces containment and rejects
20
+ // binary content; known Markdown/HTML extensions keep their richer renderer.
21
+ // the route only maps its {error,status} to an HTTP status.
22
+ r.get('/file', async (req, res, next) => {
23
+ try {
24
+ // Conditional read: `?mtime=<ms>` (the client's last-known mtime) lets the server answer
25
+ // `{ notModified: true }` without re-reading/re-sending an unchanged doc.
26
+ const m = Number(req.query.mtime);
27
+ const knownMtime = Number.isFinite(m) ? m : null;
28
+ const out = await docs.readDoc(typeof req.query.path === 'string' ? req.query.path : '', knownMtime);
29
+ if ('error' in out)
30
+ return res.status(out.status).json({ error: out.error });
31
+ if (out.notModified)
32
+ return res.json({ name: out.name, type: out.type, mtimeMs: out.mtimeMs, notModified: true });
33
+ return res.json({ name: out.name, type: out.type, content: out.content, mtimeMs: out.mtimeMs });
34
+ }
35
+ catch (e) {
36
+ return next(e);
37
+ }
38
+ });
39
+ // List a directory under $HOME (empty path → $HOME). Only subdirs + md/html files are returned.
40
+ r.get('/dir', async (req, res, next) => {
41
+ try {
42
+ const out = await docs.listDir(typeof req.query.path === 'string' ? req.query.path : '');
43
+ if ('error' in out)
44
+ return res.status(out.status).json({ error: out.error });
45
+ return res.json(out);
46
+ }
47
+ catch (e) {
48
+ return next(e);
49
+ }
50
+ });
51
+ // Create a directory `name` inside `dir` (both must be under $HOME). docs.makeDir enforces the
52
+ // boundary check and validates the name; the route maps {error,status} to an HTTP response.
53
+ r.post('/dir', async (req, res, next) => {
54
+ const body = req.body;
55
+ const dir = isRecord(body) ? body.dir : undefined;
56
+ const name = isRecord(body) ? body.name : undefined;
57
+ if (typeof dir !== 'string' || typeof name !== 'string')
58
+ return res.status(400).json({ error: 'bad request' });
59
+ try {
60
+ const out = await docs.makeDir(dir, name);
61
+ if ('error' in out)
62
+ return res.status(out.status).json({ error: out.error });
63
+ return res.status(201).json({ path: out.real });
64
+ }
65
+ catch (e) {
66
+ return next(e);
67
+ }
68
+ });
69
+ // Download ANY regular file under $HOME (no extension white-list). docs.statForDownload enforces
70
+ // the realpath+isUnder boundary and the 50MB cap; res.download streams it and forces
71
+ // Content-Disposition: attachment (so HTML/SVG can never render inline → no stored-XSS via open).
72
+ r.get('/download', async (req, res, next) => {
73
+ try {
74
+ const out = await docs.statForDownload(typeof req.query.path === 'string' ? req.query.path : '');
75
+ if ('error' in out)
76
+ return res.status(out.status).json({ error: out.error });
77
+ // Conditional download for the inline image viewer: `?mtime=<ms>` (the client's last-known mtime)
78
+ // → 304 when unchanged, so a large image isn't re-streamed just to re-view it. `X-Mtime` on the
79
+ // 200 lets the client store the current mtime for the next check. Plain downloads pass no mtime.
80
+ const m = Number(req.query.mtime);
81
+ if (Number.isFinite(m) && m === out.mtimeMs)
82
+ return res.status(304).end();
83
+ res.setHeader('X-Mtime', String(out.mtimeMs));
84
+ return res.download(out.real, out.name, (err) => { if (err && !res.headersSent)
85
+ next(err); });
86
+ }
87
+ catch (e) {
88
+ return next(e);
89
+ }
90
+ });
91
+ // First free filename in `dir`: the name as-is, else Finder-style "base (1).ext", "base (2).ext", …
92
+ // (the suffix goes before the extension). Bounded; a pathological fallback keeps it from looping.
93
+ async function freeUploadName(dir, name) {
94
+ const dot = name.lastIndexOf('.');
95
+ const base = dot > 0 ? name.slice(0, dot) : name;
96
+ const ext = dot > 0 ? name.slice(dot) : '';
97
+ let cand = name;
98
+ for (let n = 1; n <= 999; n++) {
99
+ try {
100
+ await fsp.access(joinPath(dir, cand));
101
+ } // exists → try the next suffix
102
+ catch {
103
+ return cand;
104
+ } // ENOENT → free
105
+ cand = `${base} (${n})${ext}`;
106
+ }
107
+ return `${base} (${randomBytes(3).toString('hex')})${ext}`;
108
+ }
109
+ // Upload a file into a directory under $HOME. Multipart streamed via busboy (the file never fully
110
+ // buffers in memory, and the size cap aborts mid-stream). Guards, in order: target dir must be a
111
+ // non-hidden subdir of home (resolveUploadDir); filename sanitised to a dotless basename; extension
112
+ // in the allow-list. A name clash auto-suffixes (never overwrites). The client appends `dir` BEFORE
113
+ // the file part, so the field is known by the time the file event fires.
114
+ r.post('/upload', (req, res) => {
115
+ let bb;
116
+ // defParamCharset:'utf8' — browsers put a non-ASCII (e.g. Chinese) filename into the multipart
117
+ // Content-Disposition as raw UTF-8 bytes; busboy's default 'latin1' would decode it to mojibake.
118
+ try {
119
+ bb = busboy({ headers: req.headers, defParamCharset: 'utf8', limits: { files: 1, fileSize: maxUploadBytes + 1 } });
120
+ }
121
+ catch {
122
+ return res.status(400).json({ error: 'bad multipart request' });
123
+ }
124
+ let dir = '';
125
+ let stash = false; // stash=1 → upload into the per-cwd space under ~/.handmux/uploads; `dir` is the cwd
126
+ let sawFile = false;
127
+ let handled = false;
128
+ let ws = null;
129
+ let tmp = null;
130
+ const done = (status, body) => {
131
+ if (!handled) {
132
+ handled = true;
133
+ res.status(status).json(body);
134
+ }
135
+ };
136
+ const cleanup = () => (tmp ? fsp.rm(tmp, { force: true }).catch(() => { }) : Promise.resolve());
137
+ bb.on('field', (name, val) => {
138
+ if (name === 'dir')
139
+ dir = val;
140
+ else if (name === 'stash')
141
+ stash = val === '1';
142
+ });
143
+ bb.on('file', async (_field, file, info) => {
144
+ sawFile = true;
145
+ try {
146
+ const name = safeUploadName(info.filename);
147
+ if (!name) {
148
+ file.resume();
149
+ return done(400, { error: 'bad filename' });
150
+ }
151
+ if (!isAllowedUploadExt(name, uploadExts)) {
152
+ file.resume();
153
+ return done(415, { error: 'type not allowed' });
154
+ }
155
+ const target = stash ? await docs.resolveStashDir(dir) : await docs.resolveUploadDir(dir);
156
+ if ('error' in target) {
157
+ file.resume();
158
+ return done(target.status, { error: target.error });
159
+ }
160
+ // Finder-style de-dup: never 409 / overwrite on a name clash — pick the first free "base (n).ext".
161
+ // Resolved up front for the response; re-resolved at link time if the race is lost (see below).
162
+ const origName = name;
163
+ let finalName = await freeUploadName(target.real, origName);
164
+ let dest = joinPath(target.real, finalName);
165
+ const tempPath = joinPath(target.real, `.${finalName}.uploading-${randomBytes(6).toString('hex')}`);
166
+ tmp = tempPath;
167
+ const writeStream = createWriteStream(tempPath);
168
+ ws = writeStream;
169
+ writeStream.on('error', () => { file.resume(); cleanup().finally(() => done(500, { error: 'write failed' })); });
170
+ writeStream.on('finish', async () => {
171
+ // busboy sets file.truncated synchronously when it emits 'limit'; it's reliably true here
172
+ // if the stream exceeded the cap. (We size the limit at cap+1 so a file of EXACTLY
173
+ // maxUploadBytes is allowed; only strictly-larger trips it.)
174
+ if (file.truncated) {
175
+ await cleanup();
176
+ return done(413, { error: 'too large' });
177
+ }
178
+ try {
179
+ // link (NOT rename): if the name appeared meanwhile (a concurrent upload won the race) link
180
+ // throws EEXIST → we pick the NEXT free suffix and retry, so we still never overwrite another
181
+ // file, and a clash auto-suffixes rather than 409s.
182
+ let linked = false;
183
+ for (let attempt = 0; attempt < 6 && !linked; attempt++) {
184
+ try {
185
+ await fsp.link(tempPath, dest);
186
+ linked = true;
187
+ }
188
+ catch (e) {
189
+ if (errorCode(e) !== 'EEXIST')
190
+ throw e;
191
+ finalName = await freeUploadName(target.real, origName);
192
+ dest = joinPath(target.real, finalName);
193
+ }
194
+ }
195
+ if (!linked) {
196
+ await cleanup();
197
+ return done(409, { error: 'exists' });
198
+ }
199
+ await cleanup(); // link made dest a second name for the data; drop the temp name
200
+ const st = await fsp.stat(dest);
201
+ done(201, { name: finalName, size: st.size, path: dest }); // absolute path: the dock pastes it in
202
+ }
203
+ catch {
204
+ await cleanup();
205
+ done(500, { error: 'finalize failed' });
206
+ }
207
+ });
208
+ return void file.pipe(writeStream);
209
+ }
210
+ catch {
211
+ // resolveUploadDir / fs errors etc. — never let the async handler reject (busboy won't catch
212
+ // it → unhandledRejection + hung request).
213
+ file.resume();
214
+ await cleanup();
215
+ return done(500, { error: 'upload failed' });
216
+ }
217
+ });
218
+ bb.on('error', () => { cleanup().finally(() => done(400, { error: 'parse error' })); });
219
+ bb.on('close', () => { if (!sawFile)
220
+ done(400, { error: 'no file' }); });
221
+ // Client aborted mid-upload (mobile networks drop constantly). req.pipe(bb) does NOT forward the
222
+ // source's destroy to busboy, so ws/file/bb emit nothing — we'd leak the half-written temp file
223
+ // and its fd on every dropped upload. Clean up ourselves on abort.
224
+ req.on('aborted', () => { if (handled)
225
+ return; handled = true; if (ws)
226
+ ws.destroy(); cleanup(); });
227
+ return req.pipe(bb);
228
+ });
229
+ return r;
230
+ }
@@ -0,0 +1,78 @@
1
+ // git viewer (read-only). Each route calls the git data layer and maps its {error,status} to an HTTP
2
+ // status (same shape as the docs routes); the layer enforces the under-$HOME containment and validation.
3
+ import express from 'express';
4
+ export function gitRoutes({ git }) {
5
+ const r = express.Router();
6
+ const q = (value) => (typeof value === 'string' ? value : '');
7
+ r.get('/git/repos', async (req, res, next) => {
8
+ try {
9
+ const out = await git.detectRepos(q(req.query.dir));
10
+ if ('error' in out)
11
+ return res.status(out.status).json({ error: out.error });
12
+ return res.json({ repos: out.repos });
13
+ }
14
+ catch (e) {
15
+ return next(e);
16
+ }
17
+ });
18
+ r.get('/git/status', async (req, res, next) => {
19
+ try {
20
+ const out = await git.status(q(req.query.repo));
21
+ if ('error' in out)
22
+ return res.status(out.status).json({ error: out.error });
23
+ return res.json({ changes: out.changes });
24
+ }
25
+ catch (e) {
26
+ return next(e);
27
+ }
28
+ });
29
+ r.get('/git/log', async (req, res, next) => {
30
+ try {
31
+ const out = await git.log(q(req.query.repo), req.query.limit, req.query.ref ? q(req.query.ref) : undefined);
32
+ if ('error' in out)
33
+ return res.status(out.status).json({ error: out.error });
34
+ return res.json({ commits: out.commits });
35
+ }
36
+ catch (e) {
37
+ return next(e);
38
+ }
39
+ });
40
+ r.get('/git/branches', async (req, res, next) => {
41
+ try {
42
+ const out = await git.branches(q(req.query.repo));
43
+ if ('error' in out)
44
+ return res.status(out.status).json({ error: out.error });
45
+ return res.json({ branches: out.branches });
46
+ }
47
+ catch (e) {
48
+ return next(e);
49
+ }
50
+ });
51
+ r.get('/git/diff', async (req, res, next) => {
52
+ try {
53
+ const out = await git.diff(q(req.query.repo), {
54
+ path: q(req.query.path),
55
+ commit: req.query.commit ? q(req.query.commit) : undefined,
56
+ staged: req.query.staged === '1',
57
+ });
58
+ if ('error' in out)
59
+ return res.status(out.status).json({ error: out.error });
60
+ return res.json({ diff: out.diff, truncated: out.truncated });
61
+ }
62
+ catch (e) {
63
+ return next(e);
64
+ }
65
+ });
66
+ r.get('/git/commit', async (req, res, next) => {
67
+ try {
68
+ const out = await git.commit(q(req.query.repo), q(req.query.hash));
69
+ if ('error' in out)
70
+ return res.status(out.status).json({ error: out.error });
71
+ return res.json({ message: out.message, files: out.files });
72
+ }
73
+ catch (e) {
74
+ return next(e);
75
+ }
76
+ });
77
+ return r;
78
+ }
@@ -0,0 +1,16 @@
1
+ import express from 'express';
2
+ export function healthRoutes({ health, refresh = () => { }, }) {
3
+ const router = express.Router();
4
+ router.get('/health/live', (_req, res) => res.json(health.live()));
5
+ router.get('/health/ready', async (_req, res, next) => {
6
+ try {
7
+ await refresh();
8
+ const snapshot = health.snapshot();
9
+ return res.status(snapshot.ready ? 200 : 503).json(snapshot);
10
+ }
11
+ catch (error) {
12
+ return next(error);
13
+ }
14
+ });
15
+ return router;
16
+ }
@@ -0,0 +1,16 @@
1
+ // Read/delete for the manual-push inbox. Recording happens in routes/push.js (send-local). This
2
+ // module is single-purpose: list history (newest first) and delete one entry by id.
3
+ import express from 'express';
4
+ export function notificationRoutes({ notifications }) {
5
+ const r = express.Router();
6
+ r.get('/notifications', (req, res) => {
7
+ const device = req.query.device;
8
+ res.json({ items: typeof device === 'string' && device ? notifications.list(device) : [] });
9
+ });
10
+ r.delete('/notifications/:id', (req, res) => {
11
+ const device = req.query.device;
12
+ const id = req.params.id;
13
+ res.json({ ok: typeof device === 'string' && device && id ? notifications.remove(device, id) : false });
14
+ });
15
+ return r;
16
+ }
@@ -0,0 +1,43 @@
1
+ // Preview registry routes: register a static directory, list, and remove. Each registration returns a
2
+ // runtime-only capability URL; the main Handmux token is never exposed to preview content.
3
+ import express from 'express';
4
+ import { safePreviewName } from '../previews.js';
5
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ export function previewRoutes({ previews }) {
7
+ const r = express.Router();
8
+ // POST {name,dir} registers a static directory served at /preview/<name>/.
9
+ r.post('/previews', async (req, res, next) => {
10
+ if (!previews)
11
+ return res.status(503).json({ error: 'previews disabled' });
12
+ const body = req.body;
13
+ const name = isRecord(body) ? body.name : undefined;
14
+ const dir = isRecord(body) ? body.dir : undefined;
15
+ if (typeof name !== 'string' || !name || typeof dir !== 'string' || !dir)
16
+ return res.status(400).json({ error: 'bad request' });
17
+ try {
18
+ const out = await previews.register({ name, dir });
19
+ if ('error' in out)
20
+ return res.status(out.status).json({ error: out.error });
21
+ const url = `/preview/${encodeURIComponent(out.name)}/${encodeURIComponent(out.accessToken)}/`;
22
+ return res.json({ name: out.name, kind: out.kind, url, expiresAt: out.expiresAt });
23
+ }
24
+ catch (e) {
25
+ return next(e);
26
+ }
27
+ });
28
+ r.get('/previews', (_req, res) => {
29
+ if (!previews)
30
+ return res.status(503).json({ error: 'previews disabled' });
31
+ return res.json({ previews: previews.list() });
32
+ });
33
+ r.delete('/previews/:name', (req, res) => {
34
+ if (!previews)
35
+ return res.status(503).json({ error: 'previews disabled' });
36
+ const name = req.params.name;
37
+ if (!name || !safePreviewName(name))
38
+ return res.status(400).json({ error: 'bad name' });
39
+ previews.remove(name);
40
+ return res.status(204).end();
41
+ });
42
+ return r;
43
+ }
@@ -0,0 +1,145 @@
1
+ // Web Push routes: hand the client the VAPID key, store/update/remove a browser PushSubscription, and
2
+ // the local script-push send entry. The push module owns the delivery contract (TTL/topic/prune).
3
+ import express from 'express';
4
+ import { sanitizeNotificationUrl } from '../urlPolicy.js';
5
+ import { parsePushSubscription } from '../push.js';
6
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
7
+ const strings = (value) => Array.isArray(value)
8
+ ? value.filter((item) => typeof item === 'string' && item.length > 0)
9
+ : [];
10
+ const errorMessage = (error) => error instanceof Error ? error.message : String(error);
11
+ export function pushRoutes({ push, notifications }) {
12
+ const r = express.Router();
13
+ // The client needs the VAPID public key to subscribe; 503 if the server has no keys configured.
14
+ r.get('/push/vapid', (_req, res) => {
15
+ if (!push.isConfigured())
16
+ return res.status(503).json({ error: 'push not configured' });
17
+ return res.json({ key: push.publicKey() });
18
+ });
19
+ // Store a browser PushSubscription, then immediately fire a welcome push back to it — so enabling
20
+ // the toggle proves the whole pipe (subscribe → push service → SW → notification) end to end.
21
+ r.post('/push/subscribe', async (req, res, next) => {
22
+ const body = req.body;
23
+ const sub = parsePushSubscription(isRecord(body) ? body.subscription : undefined);
24
+ const preferredPushKey = isRecord(body) ? body.pushKey : undefined;
25
+ const boundSessions = strings(isRecord(body) ? body.boundSessions : undefined);
26
+ if (!sub)
27
+ return res.status(400).json({ error: 'bad subscription' });
28
+ try {
29
+ const pushKey = push.addSubscription(sub, boundSessions, preferredPushKey);
30
+ if (!pushKey)
31
+ return res.status(400).json({ error: 'bad subscription' });
32
+ const delivery = await push.sendToOne(sub, { title: '通知已开启 ✅', body: '会话「需要你」或「已完成」时提醒你', tag: 'handmux-welcome' }, { topic: 'handmux', urgency: 'high' });
33
+ // `deliver` deliberately contains failures so an automatic pane push can never break the polling
34
+ // loop. Enabling notifications is different: its welcome push is the end-to-end health check, so a
35
+ // rejected/dead subscription must not be reported to the phone as "enabled".
36
+ if (delivery.sent !== 1) {
37
+ const expired = delivery.gone > 0;
38
+ return res.status(expired ? 410 : 502).json({
39
+ error: expired ? 'push subscription expired' : 'push delivery rejected',
40
+ });
41
+ }
42
+ return res.json({ ok: true, count: push.count(), pushKey });
43
+ }
44
+ catch (e) {
45
+ return next(e);
46
+ }
47
+ });
48
+ r.post('/push/unsubscribe', (req, res) => {
49
+ const body = req.body;
50
+ const endpoint = isRecord(body) ? body.endpoint : undefined;
51
+ const pushKey = typeof endpoint === 'string' ? push.removeSubscription(endpoint) : null;
52
+ return res.json({ ok: true, pushKey });
53
+ });
54
+ // Manual "send me a test" — pushes to every stored subscription.
55
+ r.post('/push/test', async (_req, res, next) => {
56
+ try {
57
+ const out = await push.sendToAll({ title: 'handmux 测试', body: '这是一条测试通知 — 点我回到 app', tag: 'handmux-test' }, { topic: 'handmux', urgency: 'high' });
58
+ const { deliveries: _deliveries, ...summary } = out;
59
+ return res.json(summary);
60
+ }
61
+ catch (e) {
62
+ return next(e);
63
+ }
64
+ });
65
+ // Client reports which sessions this device cares about; updates the stored subscription.
66
+ r.post('/push/bound', (req, res) => {
67
+ const requestBody = req.body;
68
+ const endpoint = isRecord(requestBody) ? requestBody.endpoint : undefined;
69
+ const boundSessions = strings(isRecord(requestBody) ? requestBody.boundSessions : undefined);
70
+ if (typeof endpoint === 'string')
71
+ push.updateBound(endpoint, boundSessions);
72
+ return res.json({ ok: true });
73
+ });
74
+ // Local script push (`handmux push`): loopback + server token. Scope is mutually exclusive —
75
+ // devices (by pushKey) > sessions > all. This is the ONLY push-send entry; no public/remote variant.
76
+ r.post('/push/send-local', async (req, res, next) => {
77
+ const requestBody = req.body;
78
+ const data = isRecord(requestBody) ? requestBody : {};
79
+ const { title, body, tag, url } = data;
80
+ if (typeof title !== 'string' || !title.trim())
81
+ return res.status(400).json({ error: 'title required' });
82
+ if (typeof body !== 'string' || !body.trim())
83
+ return res.status(400).json({ error: 'body required' });
84
+ const safeUrl = url == null ? null : sanitizeNotificationUrl(url);
85
+ if (url != null && !safeUrl)
86
+ return res.status(400).json({ error: 'url must be http(s) or relative' });
87
+ const sessions = strings(data.sessions);
88
+ const devices = strings(data.devices);
89
+ const hasSessions = sessions.length > 0;
90
+ const hasDevices = devices.length > 0;
91
+ if (hasSessions && hasDevices)
92
+ return res.status(400).json({ error: 'use --session or --device, not both' });
93
+ const payload = { title, body };
94
+ if (typeof tag === 'string' && tag)
95
+ payload.tag = tag;
96
+ const opts = { urgency: 'normal', ttl: 1800 };
97
+ if (typeof payload.tag === 'string')
98
+ opts.topic = payload.tag;
99
+ // Record FIRST so the notification tap can deep-link to this exact message's detail page. `--url`
100
+ // is stored on the record (surfaced in the detail), NOT used as the tap target.
101
+ let targetKeys = [];
102
+ let rec = null;
103
+ if (notifications) {
104
+ targetKeys = push.resolveTargetKeys({ devices: hasDevices ? devices : null, sessions: hasSessions ? sessions : null });
105
+ rec = notifications.record(targetKeys, {
106
+ title, body, tag: payload.tag, url: safeUrl, delivery: { status: 'pending' },
107
+ });
108
+ payload.data = { inboxId: rec.id };
109
+ }
110
+ try {
111
+ const out = hasDevices ? await push.sendToDevices(devices, payload, opts)
112
+ : hasSessions ? await push.sendToSessions(sessions, payload, opts)
113
+ : await push.sendToAll(payload, opts);
114
+ if (notifications && rec) {
115
+ for (const delivery of out.deliveries || []) {
116
+ if (!delivery.pushKey)
117
+ continue;
118
+ try {
119
+ notifications.updateDelivery(delivery.pushKey, rec.id, delivery);
120
+ }
121
+ catch (error) {
122
+ // Delivery already happened. Do not turn a status-metadata write failure into an HTTP error:
123
+ // CLI automation would retry and send the real notification twice. The pending record remains
124
+ // honest (unknown final state) and the server log retains the storage failure for diagnosis.
125
+ console.warn(`[handmux] notification delivery status update failed: ${errorMessage(error)}`);
126
+ }
127
+ }
128
+ }
129
+ const { deliveries: _deliveries, ...summary } = out;
130
+ return res.json(summary);
131
+ }
132
+ catch (e) {
133
+ return next(e);
134
+ }
135
+ });
136
+ // This device's addressing key (server-token auth) — the script push sheet reads it to show `--device`.
137
+ r.post('/push/key', (req, res) => {
138
+ const requestBody = req.body;
139
+ const endpoint = isRecord(requestBody) ? requestBody.endpoint : undefined;
140
+ if (typeof endpoint !== 'string')
141
+ return res.status(400).json({ error: 'endpoint required' });
142
+ return res.json({ pushKey: push.getPushKey(endpoint) });
143
+ });
144
+ return r;
145
+ }