handmux 0.5.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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +303 -0
  3. package/README.zh-CN.md +285 -0
  4. package/bin/handmux.js +417 -0
  5. package/hooks/handmux-notify.sh +20 -0
  6. package/hooks/handmux-write.cjs +92 -0
  7. package/package.json +52 -0
  8. package/public/assets/index-BN-IwtP6.css +32 -0
  9. package/public/assets/index-BUQ0R83h.js +157 -0
  10. package/public/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  11. package/public/fonts/TWUnifont.woff2 +0 -0
  12. package/public/icons/apple-touch-icon.png +0 -0
  13. package/public/icons/badge-96.png +0 -0
  14. package/public/icons/icon-192.png +0 -0
  15. package/public/icons/icon-512.png +0 -0
  16. package/public/icons/logo.svg +32 -0
  17. package/public/index.html +105 -0
  18. package/public/manifest.webmanifest +37 -0
  19. package/public/offline.html +50 -0
  20. package/public/sw.js +117 -0
  21. package/src/.gitkeep +0 -0
  22. package/src/appName.js +23 -0
  23. package/src/asr/iflyConfig.js +10 -0
  24. package/src/asr/iflySign.js +16 -0
  25. package/src/auth.js +30 -0
  26. package/src/claudeEvents.js +212 -0
  27. package/src/cli/cfNamed.js +5 -0
  28. package/src/cli/claudeHooks.js +116 -0
  29. package/src/cli/cloudflareUrl.js +9 -0
  30. package/src/cli/cloudflared.js +53 -0
  31. package/src/cli/drivers.js +59 -0
  32. package/src/cli/options.js +169 -0
  33. package/src/cli/probe.js +16 -0
  34. package/src/cli/qr.js +34 -0
  35. package/src/cli/service.js +98 -0
  36. package/src/cli/setupWizard.js +248 -0
  37. package/src/cli/sshTunnel.js +12 -0
  38. package/src/cli/state.js +42 -0
  39. package/src/cli/supervisor.js +172 -0
  40. package/src/cli/tmuxConf.js +90 -0
  41. package/src/cli/tmuxVersion.js +49 -0
  42. package/src/cli/tunlite.js +22 -0
  43. package/src/config.js +6 -0
  44. package/src/docPath.js +46 -0
  45. package/src/docs.js +222 -0
  46. package/src/git.js +185 -0
  47. package/src/httpApi.js +546 -0
  48. package/src/previewServer.js +182 -0
  49. package/src/previews.js +118 -0
  50. package/src/push.js +121 -0
  51. package/src/server.js +97 -0
  52. package/src/staticCache.js +8 -0
  53. package/src/tmux/commands.js +223 -0
  54. package/src/trimCapture.js +28 -0
  55. package/src/uploadTypes.js +28 -0
  56. package/tmux/README.md +77 -0
  57. package/tmux/claude-tab-seed.py +67 -0
  58. package/tmux/claude-tab-seen.sh +14 -0
package/src/httpApi.js ADDED
@@ -0,0 +1,546 @@
1
+ import express from 'express';
2
+ import { expressAuth } from './auth.js';
3
+ import { isPaneId, isWindowId, isSessionId, isValidSessionName, isValidStartupCmd } from './tmux/commands.js';
4
+ import * as defaultCommands from './tmux/commands.js';
5
+ import { createHash } from 'node:crypto';
6
+ import { gzipSync } from 'node:zlib';
7
+ import { capTrailingBlankRows } from './trimCapture.js';
8
+ import { defaultDocs, MAX_TRANSFER_BYTES } from './docs.js';
9
+ import { defaultGit } from './git.js';
10
+ import * as push from './push.js';
11
+ import { buildIatSignedUrl } from './asr/iflySign.js';
12
+ import { asrConfig, isAsrConfigured } from './asr/iflyConfig.js';
13
+ import { createClaudeEvents } from './claudeEvents.js';
14
+ import busboy from 'busboy';
15
+ import { promises as fsp, createWriteStream } from 'node:fs';
16
+ import { join as joinPath, dirname, resolve as resolvePath } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { homedir } from 'node:os';
19
+ import { randomBytes } from 'node:crypto';
20
+ import { safeUploadName } from './docPath.js';
21
+ import { safePreviewName } from './previews.js';
22
+ import { isAllowedUploadExt, DEFAULT_UPLOAD_EXTS } from './uploadTypes.js';
23
+ import { hooksStatus, installHooks } from './cli/claudeHooks.js';
24
+ import { claudeStatePath } from './cli/state.js';
25
+
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+ const HOOKS_SRC = resolvePath(here, '../hooks'); // server/hooks (bundled scripts)
28
+
29
+ const ALLOWED_KEYS = new Set([
30
+ 'Up', 'Down', 'Left', 'Right', 'Space', 'Enter', 'Escape', 'Tab', 'BTab', 'BSpace',
31
+ 'C-c', 'C-d', 'C-z', 'C-l', 'C-r', 'C-o', 'C-e',
32
+ ]);
33
+
34
+ // Pause between typing the text and pressing Enter on a /send. A TUI like Claude Code needs a
35
+ // beat to ingest the pasted line; without it, the Enter can fold into the input as a newline
36
+ // instead of submitting. 120ms is imperceptible but enough to settle.
37
+ const SUBMIT_GAP_MS = 120;
38
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
39
+
40
+ export function createApiRouter({
41
+ token, commands = defaultCommands, docs = defaultDocs, git = defaultGit, events,
42
+ uploadExts = DEFAULT_UPLOAD_EXTS, maxUploadBytes = MAX_TRANSFER_BYTES,
43
+ asrEnv = process.env, previews, previewDomain = null,
44
+ home = homedir(), stateFile = process.env.CLAUDE_STATE_FILE || claudeStatePath(homedir()),
45
+ } = {}) {
46
+ const r = express.Router();
47
+ r.use(expressAuth(token));
48
+ r.use(express.json());
49
+ const claudeEvents = events || createClaudeEvents({ commands, push });
50
+
51
+ r.get('/sessions', async (req, res, next) => {
52
+ try { res.json(await commands.listSessions()); } catch (e) { next(e); }
53
+ });
54
+
55
+ r.post('/sessions', async (req, res, next) => {
56
+ const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
57
+ if (!isValidSessionName(name)) return res.status(400).json({ error: 'bad session name' });
58
+ const { cwd } = req.body || {};
59
+ const cmd = typeof req.body?.cmd === 'string' ? req.body.cmd.trim() : '';
60
+ if (cmd && !isValidStartupCmd(cmd)) return res.status(400).json({ error: 'bad startup command' });
61
+ try {
62
+ if ((await commands.listSessions()).some((s) => s.name === name)) {
63
+ return res.status(409).json({ error: 'exists' });
64
+ }
65
+ let startDir; // undefined → newSession uses $HOME (old behavior)
66
+ if (cwd != null) {
67
+ const out = await docs.resolveCwd(cwd);
68
+ if (out.error) return res.status(out.status).json({ error: out.error });
69
+ startDir = out.real;
70
+ }
71
+ const id = await commands.newSession(name, startDir, cmd || undefined);
72
+ res.status(201).json({ id, name });
73
+ } catch (e) { next(e); }
74
+ });
75
+
76
+ r.get('/windows', async (req, res, next) => {
77
+ if (!isSessionId(req.query.session)) return res.status(400).json({ error: 'bad session id' });
78
+ try { res.json(await commands.listWindows(req.query.session)); } catch (e) { next(e); }
79
+ });
80
+
81
+ r.post('/windows', async (req, res, next) => {
82
+ const { session, pane, name, cwd } = req.body || {};
83
+ if (!isSessionId(session)) return res.status(400).json({ error: 'bad session id' });
84
+ if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
85
+ // The window name is optional (blank → tmux auto-names); when given it shares the session name rule.
86
+ const wname = typeof name === 'string' ? name.trim() : '';
87
+ if (wname && !isValidSessionName(wname)) return res.status(400).json({ error: 'bad window name' });
88
+ const cmd = typeof req.body?.cmd === 'string' ? req.body.cmd.trim() : '';
89
+ if (cmd && !isValidStartupCmd(cmd)) return res.status(400).json({ error: 'bad startup command' });
90
+ try {
91
+ let startDir;
92
+ if (cwd != null) {
93
+ const out = await docs.resolveCwd(cwd);
94
+ if (out.error) return res.status(out.status).json({ error: out.error });
95
+ startDir = out.real;
96
+ } else {
97
+ startDir = await commands.paneCurrentPath(pane); // old behavior: inherit the pane's dir
98
+ }
99
+ const id = await commands.newWindow(session, startDir, wname || undefined, cmd || undefined);
100
+ res.status(201).json({ id });
101
+ } catch (e) { next(e); }
102
+ });
103
+
104
+ r.patch('/sessions', async (req, res, next) => {
105
+ const { id } = req.body || {};
106
+ const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
107
+ if (!isSessionId(id)) return res.status(400).json({ error: 'bad session id' });
108
+ if (!isValidSessionName(name)) return res.status(400).json({ error: 'bad session name' });
109
+ try {
110
+ // Block only a collision with a DIFFERENT session — renaming to the current name is a no-op,
111
+ // not a conflict (the user may have opened the modal and kept the name).
112
+ if ((await commands.listSessions()).some((s) => s.name === name && s.id !== id)) {
113
+ return res.status(409).json({ error: 'exists' });
114
+ }
115
+ await commands.renameSession(id, name);
116
+ res.json({ id, name });
117
+ } catch (e) { next(e); }
118
+ });
119
+
120
+ r.patch('/windows', async (req, res, next) => {
121
+ const { id } = req.body || {};
122
+ const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
123
+ if (!isWindowId(id)) return res.status(400).json({ error: 'bad window id' });
124
+ // Window names share the session-name rule. tmux allows duplicate window names, so no 409 check.
125
+ if (!isValidSessionName(name)) return res.status(400).json({ error: 'bad window name' });
126
+ try {
127
+ await commands.renameWindow(id, name);
128
+ res.json({ id, name });
129
+ } catch (e) { next(e); }
130
+ });
131
+
132
+ r.post('/windows/swap', async (req, res, next) => {
133
+ const { a, b } = req.body || {};
134
+ if (!isWindowId(a) || !isWindowId(b)) return res.status(400).json({ error: 'bad window id' });
135
+ if (a === b) return res.status(400).json({ error: 'same window' });
136
+ // swap-window is non-destructive and reversible, so unlike DELETE we don't re-verify the windows
137
+ // server-side — the client only ever swaps adjacent windows of the open session.
138
+ try {
139
+ await commands.swapWindows(a, b);
140
+ res.json({ ok: true });
141
+ } catch (e) { next(e); }
142
+ });
143
+
144
+ r.delete('/windows', async (req, res, next) => {
145
+ if (!isWindowId(req.query.window)) return res.status(400).json({ error: 'bad window id' });
146
+ try {
147
+ // Killing the only window takes the whole session down with it — that's allowed and intended.
148
+ // The client warns ("确认后将删除整个会话") before sending, so there's no last-window guard here.
149
+ await commands.killWindow(req.query.window);
150
+ res.status(204).end();
151
+ } catch (e) { next(e); }
152
+ });
153
+
154
+ r.get('/panes', async (req, res, next) => {
155
+ if (!isWindowId(req.query.window)) return res.status(400).json({ error: 'bad window id' });
156
+ try { res.json(await commands.listPanes(req.query.window)); } catch (e) { next(e); }
157
+ });
158
+
159
+ // A pane's current working directory — the file browser uses it to land on (and "jump to") the
160
+ // session's dir. Absolute path; the client folds it to a home-relative path and lets the existing
161
+ // /dir listing enforce the under-$HOME boundary (a cwd outside $HOME just fails to browse).
162
+ r.get('/pane-cwd', async (req, res, next) => {
163
+ if (!isPaneId(req.query.pane)) return res.status(400).json({ error: 'bad pane id' });
164
+ try { res.json({ cwd: await commands.paneCurrentPath(req.query.pane) }); } catch (e) { next(e); }
165
+ });
166
+
167
+ // --- git viewer (read-only) ----------------------------------------------------------------
168
+ // Each route calls the git data layer and maps its {error,status} to an HTTP status (same shape
169
+ // as the docs routes); the layer enforces the under-$HOME containment and validation.
170
+ const q = (v) => (typeof v === 'string' ? v : '');
171
+ r.get('/git/repos', async (req, res, next) => {
172
+ try {
173
+ const out = await git.detectRepos(q(req.query.dir));
174
+ if (out.error) return res.status(out.status).json({ error: out.error });
175
+ res.json({ repos: out.repos });
176
+ } catch (e) { next(e); }
177
+ });
178
+ r.get('/git/status', async (req, res, next) => {
179
+ try {
180
+ const out = await git.status(q(req.query.repo));
181
+ if (out.error) return res.status(out.status).json({ error: out.error });
182
+ res.json({ changes: out.changes });
183
+ } catch (e) { next(e); }
184
+ });
185
+ r.get('/git/log', async (req, res, next) => {
186
+ try {
187
+ const out = await git.log(q(req.query.repo), req.query.limit, req.query.ref ? q(req.query.ref) : undefined);
188
+ if (out.error) return res.status(out.status).json({ error: out.error });
189
+ res.json({ commits: out.commits });
190
+ } catch (e) { next(e); }
191
+ });
192
+ r.get('/git/branches', async (req, res, next) => {
193
+ try {
194
+ const out = await git.branches(q(req.query.repo));
195
+ if (out.error) return res.status(out.status).json({ error: out.error });
196
+ res.json({ branches: out.branches });
197
+ } catch (e) { next(e); }
198
+ });
199
+ r.get('/git/diff', async (req, res, next) => {
200
+ try {
201
+ const out = await git.diff(q(req.query.repo), {
202
+ path: q(req.query.path),
203
+ commit: req.query.commit ? q(req.query.commit) : undefined,
204
+ staged: req.query.staged === '1',
205
+ });
206
+ if (out.error) return res.status(out.status).json({ error: out.error });
207
+ res.json({ diff: out.diff, truncated: out.truncated });
208
+ } catch (e) { next(e); }
209
+ });
210
+ r.get('/git/commit', async (req, res, next) => {
211
+ try {
212
+ const out = await git.commit(q(req.query.repo), q(req.query.hash));
213
+ if (out.error) return res.status(out.status).json({ error: out.error });
214
+ res.json({ message: out.message, files: out.files });
215
+ } catch (e) { next(e); }
216
+ });
217
+
218
+ // Read a single doc (md/html) under $HOME. The docs layer realpaths and enforces containment;
219
+ // the route only maps its {error,status} to an HTTP status.
220
+ r.get('/file', async (req, res, next) => {
221
+ try {
222
+ const out = await docs.readDoc(typeof req.query.path === 'string' ? req.query.path : '');
223
+ if (out.error) return res.status(out.status).json({ error: out.error });
224
+ res.json({ name: out.name, type: out.type, content: out.content });
225
+ } catch (e) { next(e); }
226
+ });
227
+
228
+ // List a directory under $HOME (empty path → $HOME). Only subdirs + md/html files are returned.
229
+ r.get('/dir', async (req, res, next) => {
230
+ try {
231
+ const out = await docs.listDir(typeof req.query.path === 'string' ? req.query.path : '');
232
+ if (out.error) return res.status(out.status).json({ error: out.error });
233
+ res.json(out);
234
+ } catch (e) { next(e); }
235
+ });
236
+
237
+ // Create a directory `name` inside `dir` (both must be under $HOME). docs.makeDir enforces the
238
+ // boundary check and validates the name; the route maps {error,status} to an HTTP response.
239
+ r.post('/dir', async (req, res, next) => {
240
+ const { dir, name } = req.body || {};
241
+ if (typeof dir !== 'string' || typeof name !== 'string') return res.status(400).json({ error: 'bad request' });
242
+ try {
243
+ const out = await docs.makeDir(dir, name);
244
+ if (out.error) return res.status(out.status).json({ error: out.error });
245
+ res.status(201).json({ path: out.real });
246
+ } catch (e) { next(e); }
247
+ });
248
+
249
+ // Download ANY regular file under $HOME (no extension white-list). docs.statForDownload enforces
250
+ // the realpath+isUnder boundary and the 50MB cap; res.download streams it and forces
251
+ // Content-Disposition: attachment (so HTML/SVG can never render inline → no stored-XSS via open).
252
+ r.get('/download', async (req, res, next) => {
253
+ try {
254
+ const out = await docs.statForDownload(typeof req.query.path === 'string' ? req.query.path : '');
255
+ if (out.error) return res.status(out.status).json({ error: out.error });
256
+ res.download(out.real, out.name, (err) => { if (err && !res.headersSent) next(err); });
257
+ } catch (e) { next(e); }
258
+ });
259
+
260
+ // Upload a file into a directory under $HOME. Multipart streamed via busboy (the file never fully
261
+ // buffers in memory, and the size cap aborts mid-stream). Guards, in order: target dir must be a
262
+ // non-hidden subdir of home (resolveUploadDir); filename sanitised to a dotless basename; extension
263
+ // in the allow-list; no overwrite of an existing name. The client appends `dir` BEFORE the file
264
+ // part, so the field is known by the time the file event fires.
265
+ r.post('/upload', (req, res) => {
266
+ let bb;
267
+ // defParamCharset:'utf8' — browsers put a non-ASCII (e.g. Chinese) filename into the multipart
268
+ // Content-Disposition as raw UTF-8 bytes; busboy's default 'latin1' would decode it to mojibake.
269
+ try { bb = busboy({ headers: req.headers, defParamCharset: 'utf8', limits: { files: 1, fileSize: maxUploadBytes + 1 } }); }
270
+ catch { return res.status(400).json({ error: 'bad multipart request' }); }
271
+
272
+ let dir = '';
273
+ let stash = false; // stash=1 → upload into the per-cwd space under ~/.handmux/uploads; `dir` is the cwd
274
+ let sawFile = false;
275
+ let handled = false;
276
+ let ws = null;
277
+ let tmp = null;
278
+ const done = (status, body) => { if (!handled) { handled = true; res.status(status).json(body); } };
279
+ const cleanup = () => (tmp ? fsp.rm(tmp, { force: true }).catch(() => {}) : Promise.resolve());
280
+
281
+ bb.on('field', (name, val) => {
282
+ if (name === 'dir') dir = val;
283
+ else if (name === 'stash') stash = val === '1';
284
+ });
285
+
286
+ bb.on('file', async (_field, file, info) => {
287
+ sawFile = true;
288
+ try {
289
+ const name = safeUploadName(info.filename);
290
+ if (!name) { file.resume(); return done(400, { error: 'bad filename' }); }
291
+ if (!isAllowedUploadExt(name, uploadExts)) { file.resume(); return done(415, { error: 'type not allowed' }); }
292
+
293
+ const target = stash ? await docs.resolveStashDir(dir) : await docs.resolveUploadDir(dir);
294
+ if (target.error) { file.resume(); return done(target.status, { error: target.error }); }
295
+
296
+ const dest = joinPath(target.real, name);
297
+ try { await fsp.access(dest); file.resume(); return done(409, { error: 'exists' }); }
298
+ catch { /* name free → proceed */ }
299
+
300
+ tmp = joinPath(target.real, `.${name}.uploading-${randomBytes(6).toString('hex')}`);
301
+ ws = createWriteStream(tmp);
302
+ ws.on('error', () => { file.resume(); cleanup().finally(() => done(500, { error: 'write failed' })); });
303
+ ws.on('finish', async () => {
304
+ // busboy sets file.truncated synchronously when it emits 'limit'; it's reliably true here
305
+ // if the stream exceeded the cap. (We size the limit at cap+1 so a file of EXACTLY
306
+ // maxUploadBytes is allowed; only strictly-larger trips it.)
307
+ if (file.truncated) { await cleanup(); return done(413, { error: 'too large' }); }
308
+ try {
309
+ // link (NOT rename): if the name appeared meanwhile (a concurrent upload won the race)
310
+ // link throws EEXIST → the loser gets 409. So we NEVER silently overwrite another file.
311
+ try { await fsp.link(tmp, dest); }
312
+ catch (e) { if (e.code === 'EEXIST') { await cleanup(); return done(409, { error: 'exists' }); } throw e; }
313
+ await cleanup(); // link made dest a second name for the data; drop the temp name
314
+ const st = await fsp.stat(dest);
315
+ done(201, { name, size: st.size, path: dest }); // absolute path: the dock pastes it into the box
316
+ } catch { await cleanup(); done(500, { error: 'finalize failed' }); }
317
+ });
318
+ file.pipe(ws);
319
+ } catch {
320
+ // resolveUploadDir / fs errors etc. — never let the async handler reject (busboy won't catch
321
+ // it → unhandledRejection + hung request).
322
+ file.resume();
323
+ await cleanup();
324
+ done(500, { error: 'upload failed' });
325
+ }
326
+ });
327
+
328
+ bb.on('error', () => { cleanup().finally(() => done(400, { error: 'parse error' })); });
329
+ bb.on('close', () => { if (!sawFile) done(400, { error: 'no file' }); });
330
+ // Client aborted mid-upload (mobile networks drop constantly). req.pipe(bb) does NOT forward the
331
+ // source's destroy to busboy, so ws/file/bb emit nothing — we'd leak the half-written temp file
332
+ // and its fd on every dropped upload. Clean up ourselves on abort.
333
+ req.on('aborted', () => { if (handled) return; handled = true; if (ws) ws.destroy(); cleanup(); });
334
+ req.pipe(bb);
335
+ });
336
+
337
+ r.get('/history', async (req, res, next) => {
338
+ if (!isPaneId(req.query.pane)) return res.status(400).json({ error: 'bad pane id' });
339
+ const lines = Math.min(Math.max(Number(req.query.lines) || 1000, 1), 5000);
340
+ try {
341
+ // Cap the empty grid below the cursor (a fresh shell is "prompt + a wall of blank rows") so the
342
+ // phone's bottom-anchored render shows content instead of blank — and so the hash/body/render
343
+ // all key off the same trimmed capture. See trimCapture.js.
344
+ const raw = await commands.capturePane(req.query.pane, lines);
345
+ const ansi = capTrailingBlankRows(raw);
346
+ const { width, height, cursorX, cursorY, cursorVisible } = await commands.paneInfo(req.query.pane);
347
+ // The cursor's row counted from the BOTTOM of the (trimmed) capture. The live screen is the
348
+ // capture's last `height` rows, so the cursor sits `height-1-cursorY` rows above the bottom —
349
+ // less however many trailing blank rows capTrailingBlankRows dropped (all of them below the
350
+ // cursor). The client re-places xterm's cursor this many rows up from the seed's last row.
351
+ const rowsOf = (s) => (s.endsWith('\n') ? s.slice(0, -1) : s).split('\n').length;
352
+ const cur = {
353
+ row: Math.max(0, (height - 1 - cursorY) - (rowsOf(raw) - rowsOf(ansi))),
354
+ col: cursorX, vis: cursorVisible,
355
+ };
356
+ // Short content hash over size + cursor + ansi. The client echoes its last hash as ?since=… ;
357
+ // an unchanged screen returns 204 (empty) so an idle pane stops re-sending the whole capture.
358
+ // The cursor is folded in so a bare left/right (which moves the cursor but not the text) still
359
+ // yields a fresh frame — otherwise the move would 204 and the cursor would never visibly track.
360
+ const hash = createHash('sha1')
361
+ .update(`${width}x${height}\n${cur.col},${cursorY},${cur.vis ? 1 : 0}\n${ansi}`)
362
+ .digest('hex').slice(0, 16);
363
+ if (req.query.since === hash) return res.status(204).end();
364
+ const json = JSON.stringify({ ansi, width, height, hash, cur });
365
+ res.set('Content-Type', 'application/json');
366
+ res.set('Vary', 'Accept-Encoding'); // both 200 branches vary on encoding (correct for any caching proxy)
367
+ // Capture text is mostly SGR codes + spaces — gzip crushes it ~10x. (204s are empty, never gzipped.)
368
+ if (/\bgzip\b/.test(req.headers['accept-encoding'] || '')) {
369
+ res.set('Content-Encoding', 'gzip');
370
+ // gzipSync is fine here — captures are KBs; the event-loop stall is sub-ms.
371
+ res.end(gzipSync(json));
372
+ } else {
373
+ res.end(json);
374
+ }
375
+ } catch (e) { next(e); }
376
+ });
377
+
378
+ r.post('/send', async (req, res, next) => {
379
+ const { pane, text, enter } = req.body || {};
380
+ if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
381
+ const body = typeof text === 'string' ? text : '';
382
+ try {
383
+ await commands.sendText(pane, body);
384
+ if (enter) {
385
+ if (body) await delay(SUBMIT_GAP_MS); // a bare Enter has nothing to settle — send at once
386
+ await commands.sendEnter(pane);
387
+ }
388
+ res.json({ ok: true });
389
+ } catch (e) { next(e); }
390
+ });
391
+
392
+ // Resize the tmux window so it reflows to the phone (auto:false), or hand sizing back to
393
+ // attached clients (auto:true). NOTE: this mutates the shared window — the PC's view of it
394
+ // changes too.
395
+ r.get('/layout', async (req, res, next) => {
396
+ if (!isWindowId(req.query.window)) return res.status(400).json({ error: 'bad window id' });
397
+ try { res.json({ layout: await commands.getWindowLayout(req.query.window) }); }
398
+ catch (e) { next(e); }
399
+ });
400
+
401
+ r.post('/resize', async (req, res, next) => {
402
+ const { window, pane, cols, rows, auto, layout } = req.body || {};
403
+ const c = Math.min(Math.max(Number(cols) || 0, 20), 500);
404
+ try {
405
+ if (auto) {
406
+ if (!isWindowId(window)) return res.status(400).json({ error: 'bad window id' });
407
+ // restore the split arrangement (resizePane) then hand window sizing back (resizeWindow)
408
+ if (typeof layout === 'string' && layout) await commands.applyWindowLayout(window, layout);
409
+ await commands.restoreWindowSize(window);
410
+ } else if (isPaneId(pane)) {
411
+ await commands.resizePane(pane, c); // a pane in a split — resize only it
412
+ } else if (isWindowId(window)) {
413
+ const rw = rows != null ? Math.min(Math.max(Number(rows) || 0, 5), 500) : null;
414
+ await commands.resizeWindow(window, c, rw); // a lone pane fills the window
415
+ } else {
416
+ return res.status(400).json({ error: 'bad resize target' });
417
+ }
418
+ res.json({ ok: true });
419
+ } catch (e) { next(e); }
420
+ });
421
+
422
+ r.post('/keys', async (req, res, next) => {
423
+ const { pane, keys } = req.body || {};
424
+ if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
425
+ if (!Array.isArray(keys) || keys.some((k) => !ALLOWED_KEYS.has(k))) {
426
+ return res.status(400).json({ error: 'disallowed key' });
427
+ }
428
+ try {
429
+ for (const k of keys) await commands.sendKey(pane, k);
430
+ res.json({ ok: true });
431
+ } catch (e) { next(e); }
432
+ });
433
+
434
+ // --- Capabilities probe ---------------------------------------------------------------------
435
+ // Optional integrations are configured per-install (open-source installs ship without keys), so the
436
+ // client asks what's actually available and hides controls that can't work — e.g. the mic when no
437
+ // ASR engine is configured. Add more flags here as optional integrations land.
438
+ r.get('/config', (req, res) => {
439
+ res.json({ asr: isAsrConfigured(asrEnv), claudeHooks: hooksStatus(home) });
440
+ });
441
+
442
+ // One-tap enable from the phone: install the Claude Code hooks on the host (token-gated, like every API
443
+ // here). Opt-in — the inbox only offers this when status is 'absent'. Never creates ~/.claude.
444
+ r.post('/hooks/install', (req, res) => {
445
+ try {
446
+ const { status } = installHooks(home, { srcDir: HOOKS_SRC, stateFile });
447
+ res.json({ ok: status === 'installed', status });
448
+ } catch (e) { res.status(500).json({ ok: false, error: String(e) }); }
449
+ });
450
+
451
+ // --- Voice input: iFlytek IAT signed-URL handoff -------------------------------------------
452
+ // The browser connects to iFlytek directly; we only mint a short-lived signed wss URL so the
453
+ // apiSecret never reaches the phone. 503 if creds aren't configured (front-end hides the mic).
454
+ r.get('/asr/sign', (req, res) => {
455
+ if (!isAsrConfigured(asrEnv)) return res.status(503).json({ error: 'asr not configured' });
456
+ const { appId, apiKey, apiSecret } = asrConfig(asrEnv);
457
+ res.json(buildIatSignedUrl({ appId, apiKey, apiSecret, date: new Date().toUTCString() }));
458
+ });
459
+
460
+ // --- Web Push (minimal slice) ---------------------------------------------------------------
461
+ // The client needs the VAPID public key to subscribe; 503 if the server has no keys configured.
462
+ r.get('/push/vapid', (req, res) => {
463
+ if (!push.isConfigured()) return res.status(503).json({ error: 'push not configured' });
464
+ res.json({ key: push.publicKey() });
465
+ });
466
+
467
+ // Store a browser PushSubscription, then immediately fire a welcome push back to it — so enabling
468
+ // the toggle proves the whole pipe (subscribe → push service → SW → notification) end to end.
469
+ r.post('/push/subscribe', async (req, res, next) => {
470
+ const sub = req.body?.subscription;
471
+ const boundSessions = Array.isArray(req.body?.boundSessions) ? req.body.boundSessions : [];
472
+ if (!sub || typeof sub.endpoint !== 'string') return res.status(400).json({ error: 'bad subscription' });
473
+ try {
474
+ push.addSubscription(sub, boundSessions);
475
+ await push.sendToOne(sub, { title: '通知已开启 ✅', body: '会话「需要你」或「已完成」时提醒你', tag: 'handmux-welcome' }, { topic: 'handmux', urgency: 'high' });
476
+ res.json({ ok: true, count: push.count() });
477
+ } catch (e) { next(e); }
478
+ });
479
+
480
+ r.post('/push/unsubscribe', (req, res) => {
481
+ const endpoint = req.body?.endpoint;
482
+ if (typeof endpoint === 'string') push.removeSubscription(endpoint);
483
+ res.json({ ok: true });
484
+ });
485
+
486
+ // Manual "send me a test" — pushes to every stored subscription.
487
+ r.post('/push/test', async (req, res, next) => {
488
+ try {
489
+ const out = await push.sendToAll(
490
+ { title: 'handmux 测试', body: '这是一条测试通知 — 点我回到 app', tag: 'handmux-test' },
491
+ { topic: 'handmux', urgency: 'high' },
492
+ );
493
+ res.json(out);
494
+ } catch (e) { next(e); }
495
+ });
496
+
497
+ // Client reports which sessions this device cares about; updates the stored subscription.
498
+ r.post('/push/bound', (req, res) => {
499
+ const endpoint = req.body?.endpoint;
500
+ const boundSessions = Array.isArray(req.body?.boundSessions) ? req.body.boundSessions : [];
501
+ if (typeof endpoint === 'string') push.updateBound(endpoint, boundSessions);
502
+ res.json({ ok: true });
503
+ });
504
+
505
+ // ?sessions=a,b scopes the roster to the session NAMES this device subscribed to (per-device inbox
506
+ // isolation). Omitted → null → all (back-compat); present-but-empty → [] → nothing.
507
+ r.get('/states', async (req, res, next) => {
508
+ const q = req.query.sessions;
509
+ const allowed = q === undefined ? null : String(q).split(',').map((s) => s.trim()).filter(Boolean);
510
+ try { res.json(await claudeEvents.getStates(allowed)); } catch (e) { next(e); }
511
+ });
512
+
513
+ // --- Preview registry (static dir OR dynamic port) -----------------------------------------
514
+ // POST {name,dir} registers a static dir served at /preview/<name>/; POST {name,port} registers a
515
+ // dynamic reverse-proxy reachable at https://<name>.<DOMAIN>/ (only when previewDomain is set). The
516
+ // url carries ?token= so the browser's first navigation sets the preview cookie.
517
+ r.post('/previews', async (req, res, next) => {
518
+ if (!previews) return res.status(503).json({ error: 'previews disabled' });
519
+ const { name, dir, port } = req.body || {};
520
+ if (typeof name !== 'string' || !name) return res.status(400).json({ error: 'bad request' });
521
+ const hasPort = port !== undefined && port !== null && port !== '';
522
+ if (!hasPort && (typeof dir !== 'string' || !dir)) return res.status(400).json({ error: 'bad request' });
523
+ try {
524
+ const out = await previews.register(hasPort ? { name, port } : { name, dir });
525
+ if (out.error) return res.status(out.status).json({ error: out.error });
526
+ const url = out.kind === 'dynamic'
527
+ ? `https://${encodeURIComponent(out.name)}.${previewDomain}/?token=${encodeURIComponent(token)}`
528
+ : `/preview/${encodeURIComponent(out.name)}/?token=${encodeURIComponent(token)}`;
529
+ res.json({ name: out.name, kind: out.kind, url, expiresAt: out.expiresAt });
530
+ } catch (e) { next(e); }
531
+ });
532
+
533
+ r.get('/previews', (req, res) => {
534
+ if (!previews) return res.status(503).json({ error: 'previews disabled' });
535
+ res.json({ previews: previews.list(), dynamicEnabled: !!previewDomain, domain: previewDomain });
536
+ });
537
+
538
+ r.delete('/previews/:name', (req, res) => {
539
+ if (!previews) return res.status(503).json({ error: 'previews disabled' });
540
+ if (!safePreviewName(req.params.name)) return res.status(400).json({ error: 'bad name' });
541
+ previews.remove(req.params.name);
542
+ res.status(204).end();
543
+ });
544
+
545
+ return r;
546
+ }