handmux 0.14.0 → 0.16.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.
- package/bin/handmux.js +24 -3
- package/package.json +4 -4
- package/public/assets/index-Cf-WEkt5.js +330 -0
- package/public/assets/index-D5UQBvph.css +32 -0
- package/public/index.html +2 -2
- package/src/claudeEvents.js +70 -3
- package/src/cli/claudeHooks.js +3 -17
- package/src/cli/codexHooks.js +5 -17
- package/src/cli/hookScaffold.js +35 -0
- package/src/cli/i18n/en.js +2 -0
- package/src/cli/i18n/zh.js +2 -0
- package/src/cli/setupModel.js +145 -0
- package/src/cli/setupWizard.js +15 -140
- package/src/cli/state.js +7 -1
- package/src/cli/statusLine.js +1 -5
- package/src/cli/updateCheck.js +7 -1
- package/src/docPath.js +4 -2
- package/src/httpApi.js +26 -710
- package/src/jsonStore.js +24 -0
- package/src/previews.js +18 -22
- package/src/push.js +2 -6
- package/src/routes/files.js +172 -0
- package/src/routes/git.js +57 -0
- package/src/routes/previews.js +40 -0
- package/src/routes/push.js +82 -0
- package/src/routes/sessions.js +151 -0
- package/src/routes/system.js +130 -0
- package/src/routes/terminal.js +167 -0
- package/src/tmux/commands.js +33 -5
- package/public/assets/index-BIOe84T4.css +0 -32
- package/public/assets/index-CL5hFo5W.js +0 -162
package/src/httpApi.js
CHANGED
|
@@ -1,78 +1,27 @@
|
|
|
1
|
+
// API composition root. Applies auth + JSON parsing, then mounts the per-domain route modules under
|
|
2
|
+
// /api. Each module (routes/*.js) owns one domain and receives only the deps it needs; the closure deps
|
|
3
|
+
// (commands, docs, git, previews, token, home, …) pass straight through. Behaviour is unchanged from when
|
|
4
|
+
// every route lived here — this file is just the wiring.
|
|
1
5
|
import express from 'express';
|
|
2
6
|
import { expressAuth } from './auth.js';
|
|
3
|
-
import { isPaneId, isWindowId, isSessionId, isValidSessionName, isValidStartupCmd } from './tmux/commands.js';
|
|
4
7
|
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
8
|
import { defaultDocs, MAX_TRANSFER_BYTES } from './docs.js';
|
|
9
9
|
import { defaultGit } from './git.js';
|
|
10
10
|
import * as push from './push.js';
|
|
11
|
-
import { buildIatSignedUrl } from './asr/iflySign.js';
|
|
12
|
-
import { asrConfig, isAsrConfigured } from './asr/iflyConfig.js';
|
|
13
11
|
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
12
|
import { homedir } from 'node:os';
|
|
19
|
-
import {
|
|
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 { codexHooksStatus, installCodexHooks } from './cli/codexHooks.js';
|
|
13
|
+
import { DEFAULT_UPLOAD_EXTS } from './uploadTypes.js';
|
|
25
14
|
import { claudeStatePath } from './cli/state.js';
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
15
|
+
import { sessionRoutes } from './routes/sessions.js';
|
|
16
|
+
import { terminalRoutes } from './routes/terminal.js';
|
|
17
|
+
import { gitRoutes } from './routes/git.js';
|
|
18
|
+
import { fileRoutes } from './routes/files.js';
|
|
19
|
+
import { pushRoutes } from './routes/push.js';
|
|
20
|
+
import { systemRoutes } from './routes/system.js';
|
|
21
|
+
import { previewRoutes } from './routes/previews.js';
|
|
30
22
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
// The installed CLI version (server/package.json) — read once. The phone compares this against the cached
|
|
35
|
-
// npm "latest" to surface an update hint ("run `handmux update` on your computer"); see the /version route.
|
|
36
|
-
const PKG_VERSION = (() => {
|
|
37
|
-
try { return JSON.parse(readFileSync(resolvePath(here, '../package.json'), 'utf8')).version || null; }
|
|
38
|
-
catch { return null; }
|
|
39
|
-
})();
|
|
40
|
-
|
|
41
|
-
// Summarize inbox-hook state across every coding agent for the phone: 'installed' if any agent is wired,
|
|
42
|
-
// 'absent' if an agent is present but none wired (→ offer the one-tap enable), 'no-claude' if there's no
|
|
43
|
-
// agent at all (→ hide the prompt).
|
|
44
|
-
function combinedHooksStatus(home) {
|
|
45
|
-
const c = hooksStatus(home); // 'no-claude' | 'installed' | 'absent'
|
|
46
|
-
const x = codexHooksStatus(home); // 'no-codex' | 'installed' | 'absent'
|
|
47
|
-
if (c === 'installed' || x === 'installed') return 'installed';
|
|
48
|
-
if (c !== 'no-claude' || x !== 'no-codex') return 'absent';
|
|
49
|
-
return 'no-claude';
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Keys the mobile keyboard may send via /keys. A controlled vocabulary of named tmux keys, PLUS
|
|
53
|
-
// live-modifier combinations (Ctrl/Alt + a single letter or digit) so the keyboard's Ctrl modifier
|
|
54
|
-
// can compose any readline/tmux binding (C-r, C-w, C-a, the tmux prefix, …) without enumerating each
|
|
55
|
-
// one here. tmux send-keys key names are themselves a closed set, so this stays a strict allowlist
|
|
56
|
-
// (never a passthrough): a key either names an approved token or matches the modifier shape, or it's
|
|
57
|
-
// rejected. The old fixed C-c/C-d/C-z/C-l/C-r/C-o/C-e all still match `C-[a-z0-9]`.
|
|
58
|
-
const NAMED = 'Up|Down|Left|Right|Space|Enter|Escape|Tab|BTab|BSpace|Home|End|PageUp|PageDown';
|
|
59
|
-
// A named tmux key with any (optional) modifier prefixes, in the canonical C- M- S- order buildChord emits.
|
|
60
|
-
// Covers the plain keys (Up, Tab, …) AND modifier combos on them — C-Up, M-Up, S-Left, C-Tab, C-S-Up, … —
|
|
61
|
-
// so the 按键 editor can bind e.g. Ctrl+Arrow or Ctrl+Tab, not just Ctrl + a letter.
|
|
62
|
-
const NAMED_KEY = new RegExp(`^(?:C-)?(?:M-)?(?:S-)?(?:${NAMED})$`);
|
|
63
|
-
// Ctrl and/or Alt + one letter or digit — C-r, C-a, M-b, C-M-k. Must carry a modifier (never a bare char;
|
|
64
|
-
// Shift+letter folds to the uppercase character, so no S- here).
|
|
65
|
-
const CHAR_KEY = /^(?:C-)?(?:M-)?[a-z0-9]$/;
|
|
66
|
-
export function isAllowedKey(k) {
|
|
67
|
-
if (typeof k !== 'string') return false;
|
|
68
|
-
return NAMED_KEY.test(k) || (CHAR_KEY.test(k) && k.includes('-'));
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Pause between typing the text and pressing Enter on a /send. A TUI like Claude Code needs a
|
|
72
|
-
// beat to ingest the pasted line; without it, the Enter can fold into the input as a newline
|
|
73
|
-
// instead of submitting. 120ms is imperceptible but enough to settle.
|
|
74
|
-
const SUBMIT_GAP_MS = 120;
|
|
75
|
-
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
23
|
+
// Re-exported for tests (test/keys.test.js) and any caller that imported it by this path historically.
|
|
24
|
+
export { isAllowedKey } from './routes/terminal.js';
|
|
76
25
|
|
|
77
26
|
export function createApiRouter({
|
|
78
27
|
token, commands = defaultCommands, docs = defaultDocs, git = defaultGit, events,
|
|
@@ -85,651 +34,18 @@ export function createApiRouter({
|
|
|
85
34
|
r.use(express.json());
|
|
86
35
|
const claudeEvents = events || createClaudeEvents({ commands, push });
|
|
87
36
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return res.status(409).json({ error: 'exists' });
|
|
101
|
-
}
|
|
102
|
-
let startDir; // undefined → newSession uses $HOME (old behavior)
|
|
103
|
-
if (cwd != null) {
|
|
104
|
-
const out = await docs.resolveCwd(cwd);
|
|
105
|
-
if (out.error) return res.status(out.status).json({ error: out.error });
|
|
106
|
-
startDir = out.real;
|
|
107
|
-
}
|
|
108
|
-
const id = await commands.newSession(name, startDir, cmd || undefined);
|
|
109
|
-
res.status(201).json({ id, name });
|
|
110
|
-
} catch (e) { next(e); }
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
r.get('/windows', async (req, res, next) => {
|
|
114
|
-
if (!isSessionId(req.query.session)) return res.status(400).json({ error: 'bad session id' });
|
|
115
|
-
try { res.json(await commands.listWindows(req.query.session)); } catch (e) { next(e); }
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
r.post('/windows', async (req, res, next) => {
|
|
119
|
-
const { session, pane, name, cwd } = req.body || {};
|
|
120
|
-
if (!isSessionId(session)) return res.status(400).json({ error: 'bad session id' });
|
|
121
|
-
if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
|
|
122
|
-
// The window name is optional (blank → tmux auto-names); when given it shares the session name rule.
|
|
123
|
-
const wname = typeof name === 'string' ? name.trim() : '';
|
|
124
|
-
if (wname && !isValidSessionName(wname)) return res.status(400).json({ error: 'bad window name' });
|
|
125
|
-
const cmd = typeof req.body?.cmd === 'string' ? req.body.cmd.trim() : '';
|
|
126
|
-
if (cmd && !isValidStartupCmd(cmd)) return res.status(400).json({ error: 'bad startup command' });
|
|
127
|
-
try {
|
|
128
|
-
let startDir;
|
|
129
|
-
if (cwd != null) {
|
|
130
|
-
const out = await docs.resolveCwd(cwd);
|
|
131
|
-
if (out.error) return res.status(out.status).json({ error: out.error });
|
|
132
|
-
startDir = out.real;
|
|
133
|
-
} else {
|
|
134
|
-
startDir = await commands.paneCurrentPath(pane); // old behavior: inherit the pane's dir
|
|
135
|
-
}
|
|
136
|
-
const id = await commands.newWindow(session, startDir, wname || undefined, cmd || undefined);
|
|
137
|
-
res.status(201).json({ id });
|
|
138
|
-
} catch (e) { next(e); }
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
r.patch('/sessions', async (req, res, next) => {
|
|
142
|
-
const { id } = req.body || {};
|
|
143
|
-
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
|
|
144
|
-
if (!isSessionId(id)) return res.status(400).json({ error: 'bad session id' });
|
|
145
|
-
if (!isValidSessionName(name)) return res.status(400).json({ error: 'bad session name' });
|
|
146
|
-
try {
|
|
147
|
-
// Block only a collision with a DIFFERENT session — renaming to the current name is a no-op,
|
|
148
|
-
// not a conflict (the user may have opened the modal and kept the name).
|
|
149
|
-
if ((await commands.listSessions()).some((s) => s.name === name && s.id !== id)) {
|
|
150
|
-
return res.status(409).json({ error: 'exists' });
|
|
151
|
-
}
|
|
152
|
-
await commands.renameSession(id, name);
|
|
153
|
-
res.json({ id, name });
|
|
154
|
-
} catch (e) { next(e); }
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
r.patch('/windows', async (req, res, next) => {
|
|
158
|
-
const { id } = req.body || {};
|
|
159
|
-
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
|
|
160
|
-
if (!isWindowId(id)) return res.status(400).json({ error: 'bad window id' });
|
|
161
|
-
// Window names share the session-name rule. tmux allows duplicate window names, so no 409 check.
|
|
162
|
-
if (!isValidSessionName(name)) return res.status(400).json({ error: 'bad window name' });
|
|
163
|
-
try {
|
|
164
|
-
await commands.renameWindow(id, name);
|
|
165
|
-
res.json({ id, name });
|
|
166
|
-
} catch (e) { next(e); }
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
r.post('/windows/swap', async (req, res, next) => {
|
|
170
|
-
const { a, b } = req.body || {};
|
|
171
|
-
if (!isWindowId(a) || !isWindowId(b)) return res.status(400).json({ error: 'bad window id' });
|
|
172
|
-
if (a === b) return res.status(400).json({ error: 'same window' });
|
|
173
|
-
// swap-window is non-destructive and reversible, so unlike DELETE we don't re-verify the windows
|
|
174
|
-
// server-side — the client only ever swaps adjacent windows of the open session.
|
|
175
|
-
try {
|
|
176
|
-
await commands.swapWindows(a, b);
|
|
177
|
-
res.json({ ok: true });
|
|
178
|
-
} catch (e) { next(e); }
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
r.delete('/windows', async (req, res, next) => {
|
|
182
|
-
if (!isWindowId(req.query.window)) return res.status(400).json({ error: 'bad window id' });
|
|
183
|
-
try {
|
|
184
|
-
// Killing the only window takes the whole session down with it — that's allowed and intended.
|
|
185
|
-
// The client warns ("确认后将删除整个会话") before sending, so there's no last-window guard here.
|
|
186
|
-
await commands.killWindow(req.query.window);
|
|
187
|
-
res.status(204).end();
|
|
188
|
-
} catch (e) { next(e); }
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
r.get('/panes', async (req, res, next) => {
|
|
192
|
-
if (!isWindowId(req.query.window)) return res.status(400).json({ error: 'bad window id' });
|
|
193
|
-
try { res.json(await commands.listPanes(req.query.window)); } catch (e) { next(e); }
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
// A pane's current working directory — the file browser uses it to land on (and "jump to") the
|
|
197
|
-
// session's dir. Absolute path; the client folds it to a home-relative path and lets the existing
|
|
198
|
-
// /dir listing enforce the under-$HOME boundary (a cwd outside $HOME just fails to browse).
|
|
199
|
-
r.get('/pane-cwd', async (req, res, next) => {
|
|
200
|
-
if (!isPaneId(req.query.pane)) return res.status(400).json({ error: 'bad pane id' });
|
|
201
|
-
try { res.json({ cwd: await commands.paneCurrentPath(req.query.pane) }); } catch (e) { next(e); }
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
// --- git viewer (read-only) ----------------------------------------------------------------
|
|
205
|
-
// Each route calls the git data layer and maps its {error,status} to an HTTP status (same shape
|
|
206
|
-
// as the docs routes); the layer enforces the under-$HOME containment and validation.
|
|
207
|
-
const q = (v) => (typeof v === 'string' ? v : '');
|
|
208
|
-
r.get('/git/repos', async (req, res, next) => {
|
|
209
|
-
try {
|
|
210
|
-
const out = await git.detectRepos(q(req.query.dir));
|
|
211
|
-
if (out.error) return res.status(out.status).json({ error: out.error });
|
|
212
|
-
res.json({ repos: out.repos });
|
|
213
|
-
} catch (e) { next(e); }
|
|
214
|
-
});
|
|
215
|
-
r.get('/git/status', async (req, res, next) => {
|
|
216
|
-
try {
|
|
217
|
-
const out = await git.status(q(req.query.repo));
|
|
218
|
-
if (out.error) return res.status(out.status).json({ error: out.error });
|
|
219
|
-
res.json({ changes: out.changes });
|
|
220
|
-
} catch (e) { next(e); }
|
|
221
|
-
});
|
|
222
|
-
r.get('/git/log', async (req, res, next) => {
|
|
223
|
-
try {
|
|
224
|
-
const out = await git.log(q(req.query.repo), req.query.limit, req.query.ref ? q(req.query.ref) : undefined);
|
|
225
|
-
if (out.error) return res.status(out.status).json({ error: out.error });
|
|
226
|
-
res.json({ commits: out.commits });
|
|
227
|
-
} catch (e) { next(e); }
|
|
228
|
-
});
|
|
229
|
-
r.get('/git/branches', async (req, res, next) => {
|
|
230
|
-
try {
|
|
231
|
-
const out = await git.branches(q(req.query.repo));
|
|
232
|
-
if (out.error) return res.status(out.status).json({ error: out.error });
|
|
233
|
-
res.json({ branches: out.branches });
|
|
234
|
-
} catch (e) { next(e); }
|
|
235
|
-
});
|
|
236
|
-
r.get('/git/diff', async (req, res, next) => {
|
|
237
|
-
try {
|
|
238
|
-
const out = await git.diff(q(req.query.repo), {
|
|
239
|
-
path: q(req.query.path),
|
|
240
|
-
commit: req.query.commit ? q(req.query.commit) : undefined,
|
|
241
|
-
staged: req.query.staged === '1',
|
|
242
|
-
});
|
|
243
|
-
if (out.error) return res.status(out.status).json({ error: out.error });
|
|
244
|
-
res.json({ diff: out.diff, truncated: out.truncated });
|
|
245
|
-
} catch (e) { next(e); }
|
|
246
|
-
});
|
|
247
|
-
r.get('/git/commit', async (req, res, next) => {
|
|
248
|
-
try {
|
|
249
|
-
const out = await git.commit(q(req.query.repo), q(req.query.hash));
|
|
250
|
-
if (out.error) return res.status(out.status).json({ error: out.error });
|
|
251
|
-
res.json({ message: out.message, files: out.files });
|
|
252
|
-
} catch (e) { next(e); }
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
// Read a single doc (md/html) under $HOME. The docs layer realpaths and enforces containment;
|
|
256
|
-
// the route only maps its {error,status} to an HTTP status.
|
|
257
|
-
r.get('/file', async (req, res, next) => {
|
|
258
|
-
try {
|
|
259
|
-
// Conditional read: `?mtime=<ms>` (the client's last-known mtime) lets the server answer
|
|
260
|
-
// `{ notModified: true }` without re-reading/re-sending an unchanged doc.
|
|
261
|
-
const m = Number(req.query.mtime);
|
|
262
|
-
const knownMtime = Number.isFinite(m) ? m : null;
|
|
263
|
-
const out = await docs.readDoc(typeof req.query.path === 'string' ? req.query.path : '', knownMtime);
|
|
264
|
-
if (out.error) return res.status(out.status).json({ error: out.error });
|
|
265
|
-
if (out.notModified) return res.json({ name: out.name, type: out.type, mtimeMs: out.mtimeMs, notModified: true });
|
|
266
|
-
res.json({ name: out.name, type: out.type, content: out.content, mtimeMs: out.mtimeMs });
|
|
267
|
-
} catch (e) { next(e); }
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
// List a directory under $HOME (empty path → $HOME). Only subdirs + md/html files are returned.
|
|
271
|
-
r.get('/dir', async (req, res, next) => {
|
|
272
|
-
try {
|
|
273
|
-
const out = await docs.listDir(typeof req.query.path === 'string' ? req.query.path : '');
|
|
274
|
-
if (out.error) return res.status(out.status).json({ error: out.error });
|
|
275
|
-
res.json(out);
|
|
276
|
-
} catch (e) { next(e); }
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
// Create a directory `name` inside `dir` (both must be under $HOME). docs.makeDir enforces the
|
|
280
|
-
// boundary check and validates the name; the route maps {error,status} to an HTTP response.
|
|
281
|
-
r.post('/dir', async (req, res, next) => {
|
|
282
|
-
const { dir, name } = req.body || {};
|
|
283
|
-
if (typeof dir !== 'string' || typeof name !== 'string') return res.status(400).json({ error: 'bad request' });
|
|
284
|
-
try {
|
|
285
|
-
const out = await docs.makeDir(dir, name);
|
|
286
|
-
if (out.error) return res.status(out.status).json({ error: out.error });
|
|
287
|
-
res.status(201).json({ path: out.real });
|
|
288
|
-
} catch (e) { next(e); }
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
// Download ANY regular file under $HOME (no extension white-list). docs.statForDownload enforces
|
|
292
|
-
// the realpath+isUnder boundary and the 50MB cap; res.download streams it and forces
|
|
293
|
-
// Content-Disposition: attachment (so HTML/SVG can never render inline → no stored-XSS via open).
|
|
294
|
-
r.get('/download', async (req, res, next) => {
|
|
295
|
-
try {
|
|
296
|
-
const out = await docs.statForDownload(typeof req.query.path === 'string' ? req.query.path : '');
|
|
297
|
-
if (out.error) return res.status(out.status).json({ error: out.error });
|
|
298
|
-
// Conditional download for the inline image viewer: `?mtime=<ms>` (the client's last-known mtime)
|
|
299
|
-
// → 304 when unchanged, so a large image isn't re-streamed just to re-view it. `X-Mtime` on the
|
|
300
|
-
// 200 lets the client store the current mtime for the next check. Plain downloads pass no mtime.
|
|
301
|
-
const m = Number(req.query.mtime);
|
|
302
|
-
if (Number.isFinite(m) && m === out.mtimeMs) return res.status(304).end();
|
|
303
|
-
res.setHeader('X-Mtime', String(out.mtimeMs));
|
|
304
|
-
res.download(out.real, out.name, (err) => { if (err && !res.headersSent) next(err); });
|
|
305
|
-
} catch (e) { next(e); }
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
// First free filename in `dir`: the name as-is, else Finder-style "base (1).ext", "base (2).ext", …
|
|
309
|
-
// (the suffix goes before the extension). Bounded; a pathological fallback keeps it from looping.
|
|
310
|
-
async function freeUploadName(dir, name) {
|
|
311
|
-
const dot = name.lastIndexOf('.');
|
|
312
|
-
const base = dot > 0 ? name.slice(0, dot) : name;
|
|
313
|
-
const ext = dot > 0 ? name.slice(dot) : '';
|
|
314
|
-
let cand = name;
|
|
315
|
-
for (let n = 1; n <= 999; n++) {
|
|
316
|
-
try { await fsp.access(joinPath(dir, cand)); } // exists → try the next suffix
|
|
317
|
-
catch { return cand; } // ENOENT → free
|
|
318
|
-
cand = `${base} (${n})${ext}`;
|
|
319
|
-
}
|
|
320
|
-
return `${base} (${randomBytes(3).toString('hex')})${ext}`;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Upload a file into a directory under $HOME. Multipart streamed via busboy (the file never fully
|
|
324
|
-
// buffers in memory, and the size cap aborts mid-stream). Guards, in order: target dir must be a
|
|
325
|
-
// non-hidden subdir of home (resolveUploadDir); filename sanitised to a dotless basename; extension
|
|
326
|
-
// in the allow-list. A name clash auto-suffixes (never overwrites). The client appends `dir` BEFORE
|
|
327
|
-
// the file part, so the field is known by the time the file event fires.
|
|
328
|
-
r.post('/upload', (req, res) => {
|
|
329
|
-
let bb;
|
|
330
|
-
// defParamCharset:'utf8' — browsers put a non-ASCII (e.g. Chinese) filename into the multipart
|
|
331
|
-
// Content-Disposition as raw UTF-8 bytes; busboy's default 'latin1' would decode it to mojibake.
|
|
332
|
-
try { bb = busboy({ headers: req.headers, defParamCharset: 'utf8', limits: { files: 1, fileSize: maxUploadBytes + 1 } }); }
|
|
333
|
-
catch { return res.status(400).json({ error: 'bad multipart request' }); }
|
|
334
|
-
|
|
335
|
-
let dir = '';
|
|
336
|
-
let stash = false; // stash=1 → upload into the per-cwd space under ~/.handmux/uploads; `dir` is the cwd
|
|
337
|
-
let sawFile = false;
|
|
338
|
-
let handled = false;
|
|
339
|
-
let ws = null;
|
|
340
|
-
let tmp = null;
|
|
341
|
-
const done = (status, body) => { if (!handled) { handled = true; res.status(status).json(body); } };
|
|
342
|
-
const cleanup = () => (tmp ? fsp.rm(tmp, { force: true }).catch(() => {}) : Promise.resolve());
|
|
343
|
-
|
|
344
|
-
bb.on('field', (name, val) => {
|
|
345
|
-
if (name === 'dir') dir = val;
|
|
346
|
-
else if (name === 'stash') stash = val === '1';
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
bb.on('file', async (_field, file, info) => {
|
|
350
|
-
sawFile = true;
|
|
351
|
-
try {
|
|
352
|
-
const name = safeUploadName(info.filename);
|
|
353
|
-
if (!name) { file.resume(); return done(400, { error: 'bad filename' }); }
|
|
354
|
-
if (!isAllowedUploadExt(name, uploadExts)) { file.resume(); return done(415, { error: 'type not allowed' }); }
|
|
355
|
-
|
|
356
|
-
const target = stash ? await docs.resolveStashDir(dir) : await docs.resolveUploadDir(dir);
|
|
357
|
-
if (target.error) { file.resume(); return done(target.status, { error: target.error }); }
|
|
358
|
-
|
|
359
|
-
// Finder-style de-dup: never 409 / overwrite on a name clash — pick the first free "base (n).ext".
|
|
360
|
-
// Resolved up front for the response; re-resolved at link time if the race is lost (see below).
|
|
361
|
-
const origName = name;
|
|
362
|
-
let finalName = await freeUploadName(target.real, origName);
|
|
363
|
-
let dest = joinPath(target.real, finalName);
|
|
364
|
-
|
|
365
|
-
tmp = joinPath(target.real, `.${finalName}.uploading-${randomBytes(6).toString('hex')}`);
|
|
366
|
-
ws = createWriteStream(tmp);
|
|
367
|
-
ws.on('error', () => { file.resume(); cleanup().finally(() => done(500, { error: 'write failed' })); });
|
|
368
|
-
ws.on('finish', async () => {
|
|
369
|
-
// busboy sets file.truncated synchronously when it emits 'limit'; it's reliably true here
|
|
370
|
-
// if the stream exceeded the cap. (We size the limit at cap+1 so a file of EXACTLY
|
|
371
|
-
// maxUploadBytes is allowed; only strictly-larger trips it.)
|
|
372
|
-
if (file.truncated) { await cleanup(); return done(413, { error: 'too large' }); }
|
|
373
|
-
try {
|
|
374
|
-
// link (NOT rename): if the name appeared meanwhile (a concurrent upload won the race) link
|
|
375
|
-
// throws EEXIST → we pick the NEXT free suffix and retry, so we still never overwrite another
|
|
376
|
-
// file, and a clash auto-suffixes rather than 409s.
|
|
377
|
-
let linked = false;
|
|
378
|
-
for (let attempt = 0; attempt < 6 && !linked; attempt++) {
|
|
379
|
-
try { await fsp.link(tmp, dest); linked = true; }
|
|
380
|
-
catch (e) {
|
|
381
|
-
if (e.code !== 'EEXIST') throw e;
|
|
382
|
-
finalName = await freeUploadName(target.real, origName);
|
|
383
|
-
dest = joinPath(target.real, finalName);
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
if (!linked) { await cleanup(); return done(409, { error: 'exists' }); }
|
|
387
|
-
await cleanup(); // link made dest a second name for the data; drop the temp name
|
|
388
|
-
const st = await fsp.stat(dest);
|
|
389
|
-
done(201, { name: finalName, size: st.size, path: dest }); // absolute path: the dock pastes it in
|
|
390
|
-
} catch { await cleanup(); done(500, { error: 'finalize failed' }); }
|
|
391
|
-
});
|
|
392
|
-
file.pipe(ws);
|
|
393
|
-
} catch {
|
|
394
|
-
// resolveUploadDir / fs errors etc. — never let the async handler reject (busboy won't catch
|
|
395
|
-
// it → unhandledRejection + hung request).
|
|
396
|
-
file.resume();
|
|
397
|
-
await cleanup();
|
|
398
|
-
done(500, { error: 'upload failed' });
|
|
399
|
-
}
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
bb.on('error', () => { cleanup().finally(() => done(400, { error: 'parse error' })); });
|
|
403
|
-
bb.on('close', () => { if (!sawFile) done(400, { error: 'no file' }); });
|
|
404
|
-
// Client aborted mid-upload (mobile networks drop constantly). req.pipe(bb) does NOT forward the
|
|
405
|
-
// source's destroy to busboy, so ws/file/bb emit nothing — we'd leak the half-written temp file
|
|
406
|
-
// and its fd on every dropped upload. Clean up ourselves on abort.
|
|
407
|
-
req.on('aborted', () => { if (handled) return; handled = true; if (ws) ws.destroy(); cleanup(); });
|
|
408
|
-
req.pipe(bb);
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
r.get('/history', async (req, res, next) => {
|
|
412
|
-
if (!isPaneId(req.query.pane)) return res.status(400).json({ error: 'bad pane id' });
|
|
413
|
-
const lines = Math.min(Math.max(Number(req.query.lines) || 1000, 1), 5000);
|
|
414
|
-
try {
|
|
415
|
-
// Read the pane's state FIRST — whether it's on the alternate screen decides how we capture it.
|
|
416
|
-
const { width, height, cursorX, cursorY, cursorVisible, altScreen, mouseAware } = await commands.paneInfo(req.query.pane);
|
|
417
|
-
// An ALT-screen pane (a full-screen app: vim/less/htop) is a fixed height×width screen with NO
|
|
418
|
-
// scrollback. Asking tmux for history (-S -lines) there bleeds the MAIN screen's scrollback in
|
|
419
|
-
// ABOVE the app — you'd scroll up into terminal history that isn't the app's — and capping its
|
|
420
|
-
// trailing blank rows mangles the fixed screen (those blanks are real cells). So capture an alt
|
|
421
|
-
// pane as exactly its visible screen (lines=0) and skip the blank-trim. A normal pane still pulls
|
|
422
|
-
// `lines` of scrollback and caps the empty grid below the cursor (fresh shell = "prompt + a wall of
|
|
423
|
-
// blank rows") so the phone's bottom-anchored render shows content, not blank. See trimCapture.js.
|
|
424
|
-
const raw = await commands.capturePane(req.query.pane, altScreen ? 0 : lines);
|
|
425
|
-
const ansi = altScreen ? raw : capTrailingBlankRows(raw);
|
|
426
|
-
// The cursor's row counted from the BOTTOM of the (trimmed) capture. The live screen is the
|
|
427
|
-
// capture's last `height` rows, so the cursor sits `height-1-cursorY` rows above the bottom —
|
|
428
|
-
// less however many trailing blank rows capTrailingBlankRows dropped (all of them below the
|
|
429
|
-
// cursor). The client re-places xterm's cursor this many rows up from the seed's last row.
|
|
430
|
-
const rowsOf = (s) => (s.endsWith('\n') ? s.slice(0, -1) : s).split('\n').length;
|
|
431
|
-
const cur = {
|
|
432
|
-
row: Math.max(0, (height - 1 - cursorY) - (rowsOf(raw) - rowsOf(ansi))),
|
|
433
|
-
col: cursorX, vis: cursorVisible,
|
|
434
|
-
};
|
|
435
|
-
// Short content hash over size + cursor + ansi. The client echoes its last hash as ?since=… ;
|
|
436
|
-
// an unchanged screen returns 204 (empty) so an idle pane stops re-sending the whole capture.
|
|
437
|
-
// The cursor is folded in so a bare left/right (which moves the cursor but not the text) still
|
|
438
|
-
// yields a fresh frame — otherwise the move would 204 and the cursor would never visibly track.
|
|
439
|
-
// mouseAware is folded in too: toggling an app's mouse mode (e.g. `:set mouse=a` in vim) changes
|
|
440
|
-
// whether a swipe should wheel-scroll or just hint, and the client only re-reads it on a fresh frame.
|
|
441
|
-
const hash = createHash('sha1')
|
|
442
|
-
.update(`${width}x${height}\n${cur.col},${cursorY},${cur.vis ? 1 : 0}\n${altScreen ? 1 : 0}${mouseAware ? 'm' : ''}\n${ansi}`)
|
|
443
|
-
.digest('hex').slice(0, 16);
|
|
444
|
-
if (req.query.since === hash) return res.status(204).end();
|
|
445
|
-
const json = JSON.stringify({ ansi, width, height, hash, cur, alt: altScreen, mouseAware });
|
|
446
|
-
res.set('Content-Type', 'application/json');
|
|
447
|
-
res.set('Vary', 'Accept-Encoding'); // both 200 branches vary on encoding (correct for any caching proxy)
|
|
448
|
-
// Capture text is mostly SGR codes + spaces — gzip crushes it ~10x. (204s are empty, never gzipped.)
|
|
449
|
-
if (/\bgzip\b/.test(req.headers['accept-encoding'] || '')) {
|
|
450
|
-
res.set('Content-Encoding', 'gzip');
|
|
451
|
-
// gzipSync is fine here — captures are KBs; the event-loop stall is sub-ms.
|
|
452
|
-
res.end(gzipSync(json));
|
|
453
|
-
} else {
|
|
454
|
-
res.end(json);
|
|
455
|
-
}
|
|
456
|
-
} catch (e) { next(e); }
|
|
457
|
-
});
|
|
458
|
-
|
|
459
|
-
r.post('/send', async (req, res, next) => {
|
|
460
|
-
const { pane, text, enter } = req.body || {};
|
|
461
|
-
if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
|
|
462
|
-
const body = typeof text === 'string' ? text : '';
|
|
463
|
-
try {
|
|
464
|
-
await commands.exitCopyModeIfActive(pane);
|
|
465
|
-
await commands.sendText(pane, body);
|
|
466
|
-
if (enter) {
|
|
467
|
-
if (body) await delay(SUBMIT_GAP_MS); // a bare Enter has nothing to settle — send at once
|
|
468
|
-
await commands.sendEnter(pane);
|
|
469
|
-
}
|
|
470
|
-
res.json({ ok: true });
|
|
471
|
-
} catch (e) { next(e); }
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
// Resize the tmux window so it reflows to the phone (auto:false), or hand sizing back to
|
|
475
|
-
// attached clients (auto:true). NOTE: this mutates the shared window — the PC's view of it
|
|
476
|
-
// changes too.
|
|
477
|
-
r.get('/layout', async (req, res, next) => {
|
|
478
|
-
if (!isWindowId(req.query.window)) return res.status(400).json({ error: 'bad window id' });
|
|
479
|
-
try { res.json({ layout: await commands.getWindowLayout(req.query.window) }); }
|
|
480
|
-
catch (e) { next(e); }
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
r.post('/resize', async (req, res, next) => {
|
|
484
|
-
const { window, pane, cols, rows, auto, layout } = req.body || {};
|
|
485
|
-
const c = Math.min(Math.max(Number(cols) || 0, 20), 500);
|
|
486
|
-
try {
|
|
487
|
-
if (auto) {
|
|
488
|
-
if (!isWindowId(window)) return res.status(400).json({ error: 'bad window id' });
|
|
489
|
-
// restore the split arrangement (resizePane) then hand window sizing back (resizeWindow)
|
|
490
|
-
if (typeof layout === 'string' && layout) await commands.applyWindowLayout(window, layout);
|
|
491
|
-
await commands.restoreWindowSize(window);
|
|
492
|
-
} else if (isPaneId(pane)) {
|
|
493
|
-
await commands.resizePane(pane, c); // a pane in a split — resize only it
|
|
494
|
-
} else if (isWindowId(window)) {
|
|
495
|
-
const rw = rows != null ? Math.min(Math.max(Number(rows) || 0, 5), 500) : null;
|
|
496
|
-
await commands.resizeWindow(window, c, rw); // a lone pane fills the window
|
|
497
|
-
} else {
|
|
498
|
-
return res.status(400).json({ error: 'bad resize target' });
|
|
499
|
-
}
|
|
500
|
-
res.json({ ok: true });
|
|
501
|
-
} catch (e) { next(e); }
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
r.post('/keys', async (req, res, next) => {
|
|
505
|
-
const { pane, keys } = req.body || {};
|
|
506
|
-
if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
|
|
507
|
-
if (!Array.isArray(keys) || keys.some((k) => !isAllowedKey(k))) {
|
|
508
|
-
return res.status(400).json({ error: 'disallowed key' });
|
|
509
|
-
}
|
|
510
|
-
try {
|
|
511
|
-
await commands.exitCopyModeIfActive(pane);
|
|
512
|
-
for (const k of keys) await commands.sendKey(pane, k);
|
|
513
|
-
res.json({ ok: true });
|
|
514
|
-
} catch (e) { next(e); }
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
// Swipe-to-scroll for full-screen (ALT-screen) apps: the client can't scroll the alt buffer itself
|
|
518
|
-
// (no scrollback), so it forwards the finger travel here and we inject wheel events the app scrolls on.
|
|
519
|
-
// SAFETY: we re-read the pane's mouse state at inject time and REFUSE unless it's actually reporting
|
|
520
|
-
// mouse — otherwise the raw escape bytes would print into the shell as literal `<65;…M` garbage. The
|
|
521
|
-
// client's cached mouseAware is only a UX hint; this check is the real guard (mouse mode can toggle
|
|
522
|
-
// between the poll and the gesture). Positioned at the pane centre so a split-aware app scrolls the
|
|
523
|
-
// region the finger is over; a lone full-screen app ignores the position.
|
|
524
|
-
r.post('/scroll', async (req, res, next) => {
|
|
525
|
-
const { pane, dir, lines } = req.body || {};
|
|
526
|
-
if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
|
|
527
|
-
if (dir !== 'up' && dir !== 'down') return res.status(400).json({ error: 'bad dir' });
|
|
528
|
-
try {
|
|
529
|
-
const { altScreen, mouseAware, mouseSgr, width, height } = await commands.paneInfo(pane);
|
|
530
|
-
if (!altScreen || !mouseAware) return res.json({ ok: false, reason: 'no-mouse' });
|
|
531
|
-
await commands.sendWheel(pane, dir, lines, {
|
|
532
|
-
sgr: mouseSgr,
|
|
533
|
-
col: Math.max(1, Math.floor((width || 2) / 2)),
|
|
534
|
-
row: Math.max(1, Math.floor((height || 2) / 2)),
|
|
535
|
-
});
|
|
536
|
-
res.json({ ok: true });
|
|
537
|
-
} catch (e) { next(e); }
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
// --- Capabilities probe ---------------------------------------------------------------------
|
|
541
|
-
// Optional integrations are configured per-install (open-source installs ship without keys), so the
|
|
542
|
-
// client asks what's actually available and hides controls that can't work — e.g. the mic when no
|
|
543
|
-
// ASR engine is configured. Add more flags here as optional integrations land.
|
|
544
|
-
// `claudeHooks` (name kept for web back-compat) now summarizes EVERY coding agent: 'installed' if any is
|
|
545
|
-
// wired, 'absent' if an agent is present but none wired (→ offer enable), 'no-claude' if no agent at all.
|
|
546
|
-
r.get('/config', (req, res) => {
|
|
547
|
-
res.json({ asr: isAsrConfigured(asrEnv), claudeHooks: combinedHooksStatus(home) });
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
// Update hint for the phone: is the globally-installed CLI behind the latest npm release? `current` is
|
|
551
|
-
// this server's version; `latest` comes from the same cache the CLI maintains (~/.handmux/update-check.json).
|
|
552
|
-
// We never block on the network here — if the cache is stale we kick a best-effort async refresh (throttled
|
|
553
|
-
// to once an hour, like the CLI) and return the currently-known value. The upgrade itself is a computer-side
|
|
554
|
-
// `handmux update`; the phone only shows the notice.
|
|
555
|
-
r.get('/version', (req, res) => {
|
|
556
|
-
const cache = readCache(home);
|
|
557
|
-
if (shouldRefresh(cache)) refreshLatestAsync(home);
|
|
558
|
-
const latest = cache?.latest ?? null;
|
|
559
|
-
const updateAvailable = !!(latest && PKG_VERSION && isNewer(latest, PKG_VERSION));
|
|
560
|
-
// `whatsNew` is the concise per-version highlights the newer package carries (via npm). Trim to the
|
|
561
|
-
// versions the user would actually GAIN by upgrading (strictly newer than what's installed here).
|
|
562
|
-
const whatsNew = (updateAvailable && Array.isArray(cache?.whatsNew))
|
|
563
|
-
? cache.whatsNew.filter((e) => e && e.version && isNewer(e.version, PKG_VERSION))
|
|
564
|
-
: [];
|
|
565
|
-
res.json({ current: PKG_VERSION, latest, updateAvailable, whatsNew });
|
|
566
|
-
});
|
|
567
|
-
|
|
568
|
-
// One-tap enable from the phone: install the hooks for every present agent (Claude Code, Codex) on the
|
|
569
|
-
// host (token-gated, like every API here). Opt-in — the inbox only offers this when status is 'absent'.
|
|
570
|
-
// Never creates ~/.claude or ~/.codex; a user's own Codex `notify` is left untouched (see codexHooks.js).
|
|
571
|
-
r.post('/hooks/install', (req, res) => {
|
|
572
|
-
try {
|
|
573
|
-
let installed = 0;
|
|
574
|
-
if (hooksStatus(home) !== 'no-claude') { installHooks(home, { srcDir: HOOKS_SRC, stateFile }); installed++; }
|
|
575
|
-
if (codexHooksStatus(home) !== 'no-codex') { installCodexHooks(home, { srcDir: HOOKS_SRC, stateFile }); installed++; }
|
|
576
|
-
res.json({ ok: installed > 0, status: combinedHooksStatus(home) });
|
|
577
|
-
} catch (e) { res.status(500).json({ ok: false, error: String(e) }); }
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
// --- Voice input: iFlytek IAT signed-URL handoff -------------------------------------------
|
|
581
|
-
// The browser connects to iFlytek directly; we only mint a short-lived signed wss URL so the
|
|
582
|
-
// apiSecret never reaches the phone. 503 if creds aren't configured (front-end hides the mic).
|
|
583
|
-
r.get('/asr/sign', (req, res) => {
|
|
584
|
-
if (!isAsrConfigured(asrEnv)) return res.status(503).json({ error: 'asr not configured' });
|
|
585
|
-
const { appId, apiKey, apiSecret } = asrConfig(asrEnv);
|
|
586
|
-
res.json(buildIatSignedUrl({ appId, apiKey, apiSecret, date: new Date().toUTCString() }));
|
|
587
|
-
});
|
|
588
|
-
|
|
589
|
-
// --- Web Push (minimal slice) ---------------------------------------------------------------
|
|
590
|
-
// The client needs the VAPID public key to subscribe; 503 if the server has no keys configured.
|
|
591
|
-
r.get('/push/vapid', (req, res) => {
|
|
592
|
-
if (!push.isConfigured()) return res.status(503).json({ error: 'push not configured' });
|
|
593
|
-
res.json({ key: push.publicKey() });
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
// Store a browser PushSubscription, then immediately fire a welcome push back to it — so enabling
|
|
597
|
-
// the toggle proves the whole pipe (subscribe → push service → SW → notification) end to end.
|
|
598
|
-
r.post('/push/subscribe', async (req, res, next) => {
|
|
599
|
-
const sub = req.body?.subscription;
|
|
600
|
-
const boundSessions = Array.isArray(req.body?.boundSessions) ? req.body.boundSessions : [];
|
|
601
|
-
if (!sub || typeof sub.endpoint !== 'string') return res.status(400).json({ error: 'bad subscription' });
|
|
602
|
-
try {
|
|
603
|
-
push.addSubscription(sub, boundSessions);
|
|
604
|
-
await push.sendToOne(sub, { title: '通知已开启 ✅', body: '会话「需要你」或「已完成」时提醒你', tag: 'handmux-welcome' }, { topic: 'handmux', urgency: 'high' });
|
|
605
|
-
res.json({ ok: true, count: push.count(), pushKey: push.getPushKey(sub.endpoint) });
|
|
606
|
-
} catch (e) { next(e); }
|
|
607
|
-
});
|
|
608
|
-
|
|
609
|
-
r.post('/push/unsubscribe', (req, res) => {
|
|
610
|
-
const endpoint = req.body?.endpoint;
|
|
611
|
-
if (typeof endpoint === 'string') push.removeSubscription(endpoint);
|
|
612
|
-
res.json({ ok: true });
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
// Manual "send me a test" — pushes to every stored subscription.
|
|
616
|
-
r.post('/push/test', async (req, res, next) => {
|
|
617
|
-
try {
|
|
618
|
-
const out = await push.sendToAll(
|
|
619
|
-
{ title: 'handmux 测试', body: '这是一条测试通知 — 点我回到 app', tag: 'handmux-test' },
|
|
620
|
-
{ topic: 'handmux', urgency: 'high' },
|
|
621
|
-
);
|
|
622
|
-
res.json(out);
|
|
623
|
-
} catch (e) { next(e); }
|
|
624
|
-
});
|
|
625
|
-
|
|
626
|
-
// Client reports which sessions this device cares about; updates the stored subscription.
|
|
627
|
-
r.post('/push/bound', (req, res) => {
|
|
628
|
-
const endpoint = req.body?.endpoint;
|
|
629
|
-
const boundSessions = Array.isArray(req.body?.boundSessions) ? req.body.boundSessions : [];
|
|
630
|
-
if (typeof endpoint === 'string') push.updateBound(endpoint, boundSessions);
|
|
631
|
-
res.json({ ok: true });
|
|
632
|
-
});
|
|
633
|
-
|
|
634
|
-
// Local script push (`handmux push`): loopback + server token. Scope is mutually exclusive —
|
|
635
|
-
// devices (by pushKey) > sessions > all. This is the ONLY push-send entry; no public/remote variant.
|
|
636
|
-
r.post('/push/send-local', async (req, res, next) => {
|
|
637
|
-
const { sessions, devices, title, body, tag, url } = req.body || {};
|
|
638
|
-
if (typeof title !== 'string' || !title.trim()) return res.status(400).json({ error: 'title required' });
|
|
639
|
-
if (typeof body !== 'string' || !body.trim()) return res.status(400).json({ error: 'body required' });
|
|
640
|
-
const hasSessions = Array.isArray(sessions) && sessions.length > 0;
|
|
641
|
-
const hasDevices = Array.isArray(devices) && devices.length > 0;
|
|
642
|
-
if (hasSessions && hasDevices) return res.status(400).json({ error: 'use --session or --device, not both' });
|
|
643
|
-
const payload = { title, body };
|
|
644
|
-
if (typeof tag === 'string' && tag) payload.tag = tag;
|
|
645
|
-
if (typeof url === 'string' && url) payload.data = { url };
|
|
646
|
-
const opts = { urgency: 'normal', ttl: 1800 };
|
|
647
|
-
if (payload.tag) opts.topic = payload.tag;
|
|
648
|
-
try {
|
|
649
|
-
const out = hasDevices ? await push.sendToDevices(devices, payload, opts)
|
|
650
|
-
: hasSessions ? await push.sendToSessions(sessions, payload, opts)
|
|
651
|
-
: await push.sendToAll(payload, opts);
|
|
652
|
-
res.json(out);
|
|
653
|
-
} catch (e) { next(e); }
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
// This device's addressing key (server-token auth) — the script push sheet reads it to show `--device`.
|
|
657
|
-
r.post('/push/key', (req, res) => {
|
|
658
|
-
const endpoint = req.body?.endpoint;
|
|
659
|
-
if (typeof endpoint !== 'string') return res.status(400).json({ error: 'endpoint required' });
|
|
660
|
-
res.json({ pushKey: push.getPushKey(endpoint) });
|
|
661
|
-
});
|
|
662
|
-
|
|
663
|
-
// ?sessions=a,b scopes the roster to the session NAMES this device subscribed to (per-device inbox
|
|
664
|
-
// isolation). Omitted → null → all (back-compat); present-but-empty → [] → nothing.
|
|
665
|
-
r.get('/states', async (req, res, next) => {
|
|
666
|
-
const q = req.query.sessions;
|
|
667
|
-
const allowed = q === undefined ? null : String(q).split(',').map((s) => s.trim()).filter(Boolean);
|
|
668
|
-
try { res.json(await claudeEvents.getStates(allowed)); } catch (e) { next(e); }
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
// Agent usage/quota for the Usage page. Disk-only, no credentials: Claude's 5h/weekly % from the
|
|
672
|
-
// statusLine snapshot (if the capturer is opted in), Codex's rate_limits + tokens from its newest
|
|
673
|
-
// rollout. Either side is null when unavailable. Cached briefly (see usage.js); never throws.
|
|
674
|
-
r.get('/usage', (req, res, next) => {
|
|
675
|
-
try { res.json(getUsageCached(home)); } catch (e) { next(e); }
|
|
676
|
-
});
|
|
677
|
-
|
|
678
|
-
// Orphan Claude sessions: `claude` processes running on this host but NOT inside a tmux pane, so
|
|
679
|
-
// handmux can't steer them. Surfaced at the bottom of the Inbox with a "takeover" (spawn
|
|
680
|
-
// `claude --resume` in tmux). Best-effort process scan (see orphans.js); never throws.
|
|
681
|
-
r.get('/orphans', async (req, res, next) => {
|
|
682
|
-
try { res.json(await scanOrphans({ projectsDir: defaultProjectsDir(home) })); } catch (e) { next(e); }
|
|
683
|
-
});
|
|
684
|
-
|
|
685
|
-
// Take over an orphan: spawn `claude --resume <sessionId>` in tmux and (default) SIGTERM the original.
|
|
686
|
-
// pid/sessionId are re-verified against a fresh scan server-side; sessionId must be a UUID (it's typed
|
|
687
|
-
// into a shell). target.mode 'new' (fresh session) or 'window' (into an existing session id).
|
|
688
|
-
r.post('/orphans/takeover', async (req, res, next) => {
|
|
689
|
-
const { pid, sessionId, kill, target } = req.body || {};
|
|
690
|
-
const t = target && target.mode === 'window' && isSessionId(target.session)
|
|
691
|
-
? { mode: 'window', session: target.session } : { mode: 'new' };
|
|
692
|
-
try {
|
|
693
|
-
const out = await takeoverOrphan(
|
|
694
|
-
{ commands, scanOpts: { projectsDir: defaultProjectsDir(home) } },
|
|
695
|
-
{ pid, sessionId, target: t, kill: kill !== false },
|
|
696
|
-
);
|
|
697
|
-
if (out.error) return res.status(out.status).json({ error: out.error });
|
|
698
|
-
res.json(out);
|
|
699
|
-
} catch (e) { next(e); }
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
// --- Preview registry (static dir OR dynamic port) -----------------------------------------
|
|
703
|
-
// POST {name,dir} registers a static dir served at /preview/<name>/; POST {name,port} registers a
|
|
704
|
-
// dynamic reverse-proxy reachable at https://<name>.<DOMAIN>/ (only when previewDomain is set). The
|
|
705
|
-
// url carries ?token= so the browser's first navigation sets the preview cookie.
|
|
706
|
-
r.post('/previews', async (req, res, next) => {
|
|
707
|
-
if (!previews) return res.status(503).json({ error: 'previews disabled' });
|
|
708
|
-
const { name, dir, port } = req.body || {};
|
|
709
|
-
if (typeof name !== 'string' || !name) return res.status(400).json({ error: 'bad request' });
|
|
710
|
-
const hasPort = port !== undefined && port !== null && port !== '';
|
|
711
|
-
if (!hasPort && (typeof dir !== 'string' || !dir)) return res.status(400).json({ error: 'bad request' });
|
|
712
|
-
try {
|
|
713
|
-
const out = await previews.register(hasPort ? { name, port } : { name, dir });
|
|
714
|
-
if (out.error) return res.status(out.status).json({ error: out.error });
|
|
715
|
-
const url = out.kind === 'dynamic'
|
|
716
|
-
? `https://${encodeURIComponent(out.name)}.${previewDomain}/?token=${encodeURIComponent(token)}`
|
|
717
|
-
: `/preview/${encodeURIComponent(out.name)}/?token=${encodeURIComponent(token)}`;
|
|
718
|
-
res.json({ name: out.name, kind: out.kind, url, expiresAt: out.expiresAt });
|
|
719
|
-
} catch (e) { next(e); }
|
|
720
|
-
});
|
|
721
|
-
|
|
722
|
-
r.get('/previews', (req, res) => {
|
|
723
|
-
if (!previews) return res.status(503).json({ error: 'previews disabled' });
|
|
724
|
-
res.json({ previews: previews.list(), dynamicEnabled: !!previewDomain, domain: previewDomain });
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
r.delete('/previews/:name', (req, res) => {
|
|
728
|
-
if (!previews) return res.status(503).json({ error: 'previews disabled' });
|
|
729
|
-
if (!safePreviewName(req.params.name)) return res.status(400).json({ error: 'bad name' });
|
|
730
|
-
previews.remove(req.params.name);
|
|
731
|
-
res.status(204).end();
|
|
732
|
-
});
|
|
37
|
+
const deps = {
|
|
38
|
+
token, commands, docs, git, push, claudeEvents,
|
|
39
|
+
uploadExts, maxUploadBytes, asrEnv, previews, previewDomain, home, stateFile,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
r.use(sessionRoutes(deps));
|
|
43
|
+
r.use(terminalRoutes(deps));
|
|
44
|
+
r.use(gitRoutes(deps));
|
|
45
|
+
r.use(fileRoutes(deps));
|
|
46
|
+
r.use(pushRoutes(deps));
|
|
47
|
+
r.use(systemRoutes(deps));
|
|
48
|
+
r.use(previewRoutes(deps));
|
|
733
49
|
|
|
734
50
|
return r;
|
|
735
51
|
}
|