handmux 0.6.0 → 0.8.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/public/index.html CHANGED
@@ -42,8 +42,8 @@
42
42
  @keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
43
43
  @media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
44
44
  </style>
45
- <script type="module" crossorigin src="/assets/index-DlO8ul0J.js"></script>
46
- <link rel="stylesheet" crossorigin href="/assets/index-_nvN0yNe.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-_nvN0yNe.css"></noscript>
45
+ <script type="module" crossorigin src="/assets/index-aNwLKI4h.js"></script>
46
+ <link rel="stylesheet" crossorigin href="/assets/index-CKVeisd1.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-CKVeisd1.css"></noscript>
47
47
  </head>
48
48
  <body>
49
49
  <div id="boot-splash" aria-hidden="true">
@@ -62,7 +62,6 @@ function readStateFile(file) {
62
62
  // change (the watcher, for push). No persisted state of our own — the file IS the persistence.
63
63
  export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE, now = () => Date.now() } = {}) {
64
64
  const lastPushed = {}; // pane → 'needs' | 'done' | null (in-process push-transition dedup, by display view)
65
- const dotClearedFor = {}; // pane → ts of the stuck 进行中 we already cleared @claude_dot for (clear once)
66
65
  // The dedup above is in-process ONLY: a restart (e.g. ./deploy.sh) wipes it while the hook's state
67
66
  // file on disk keeps every pane's latest 需要你/已完成. Without priming, the first read after boot
68
67
  // would see an empty dedup and re-push every resting pane — a flood of "historical" notifications on
@@ -136,14 +135,8 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
136
135
  // (2) roster — drop ended / dead / claude-exited panes; resolve location from the live tmux row.
137
136
  if (!c || c.kind === 'end' || gone) continue;
138
137
  // Expire a 进行中 latched past the TTL (an ESC-interrupt / walk-away that never got a Stop): drop it
139
- // from the roster and clear the PC @claude_dot once, so the stuck blue dot goes away. See WORKING_TTL_MS.
140
- if (c.kind === 'working' && now() - (rec.ts || 0) > WORKING_TTL_MS) {
141
- if (live && typeof commands.runTmux === 'function' && dotClearedFor[pane] !== rec.ts) {
142
- dotClearedFor[pane] = rec.ts;
143
- try { await commands.runTmux(['set-option', '-w', '-t', pane, '@claude_dot', '']); } catch { /* best effort */ }
144
- }
145
- continue;
146
- }
138
+ // from the roster so the stuck working pane goes away. See WORKING_TTL_MS.
139
+ if (c.kind === 'working' && now() - (rec.ts || 0) > WORKING_TTL_MS) continue;
147
140
  const loc = lp ? { session: lp.session, window: lp.window, windowName: lp.windowName } : {};
148
141
  if (allow && !allow.has(loc.session)) continue;
149
142
  out[pane] = { ...loc, kind: c.kind, msg: c.msg || '', ts: rec.ts || 0, agent: agent.id };
@@ -80,11 +80,11 @@ export default {
80
80
  'hooks.removed': '✓ Agent hooks removed.',
81
81
  'hooks.usage': 'usage: handmux hooks install|uninstall',
82
82
 
83
- // tmux status-dot offer
84
- 'tmuxdot.tip': ' Tip: to show a Claude status dot on each tmux window, run `handmux hooks install` from an interactive terminal — it wires {conf} for you (see tmux/README.md in the handmux package).',
85
- 'tmuxdot.confirm': 'Also show a per-window Claude status dot in tmux? (adds a block to ~/.tmux.conf)',
86
- 'tmuxdot.added': '✓ tmux dot added → {path}',
87
- 'tmuxdot.apply': ' Apply with: tmux source-file ~/.tmux.conf (it changes the shared tmux server — all clients, including your PC).',
83
+ // Claude statusLine usage capturer (powers the phone Usage page's 5h/weekly bars)
84
+ 'statusline.confirmEnable': "Show Claude's 5h/weekly usage on the phone? (installs a Claude statusLine)",
85
+ 'statusline.installed': '✓ Claude statusLine installed → ~/.claude/settings.json',
86
+ 'statusline.reload': ' Open a new Claude session to load it; the Usage page fills in as it reports.',
87
+ 'statusline.foreignHint': 'You already have a Claude statusLine — leaving it untouched. To also feed the phone Usage page, pipe it through our capturer:',
88
88
 
89
89
  // service
90
90
  'service.usage': 'usage: handmux service install [start-flags] | handmux service uninstall',
@@ -79,11 +79,11 @@ export default {
79
79
  'hooks.removed': '✓ agent hooks 已移除。',
80
80
  'hooks.usage': '用法:handmux hooks install|uninstall',
81
81
 
82
- // tmux 状态点
83
- 'tmuxdot.tip': ' 提示:想在每个 tmux 窗口上显示 Claude 状态点,请在交互终端里运行 `handmux hooks install` —— 它会替你写好 {conf}(见 handmux 包内的 tmux/README.md)。',
84
- 'tmuxdot.confirm': '同时在 tmux 里显示每个窗口的 Claude 状态点吗?(会往 ~/.tmux.conf 加一段)',
85
- 'tmuxdot.added': '✓ tmux 状态点已添加 → {path}',
86
- 'tmuxdot.apply': ' 生效方式:tmux source-file ~/.tmux.conf (它改的是共享 tmux 服务器 —— 对所有客户端生效,包括你的电脑)。',
82
+ // Claude statusLine 用量捕获(点亮手机用量页的 5h/周额度条)
83
+ 'statusline.confirmEnable': '在手机上显示 Claude 的 5h/周额度?(会安装一个 Claude statusLine)',
84
+ 'statusline.installed': '✓ Claude statusLine 已安装 → ~/.claude/settings.json',
85
+ 'statusline.reload': ' 新开一个 Claude 会话以加载;用量页会随上报逐渐填上。',
86
+ 'statusline.foreignHint': '你已经有自己的 Claude statusLine —— 保持不动。想同时点亮手机用量页,把它接到我们的捕获器后面:',
87
87
 
88
88
  // service
89
89
  'service.usage': '用法:handmux service install [start-flags] | handmux service uninstall',
@@ -124,7 +124,10 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
124
124
  log.log(t('setup.tunnel2'));
125
125
  log.log(t('setup.tunnel3'));
126
126
  log.log(t('setup.tunnel4'));
127
- const curPick = { none: '1', cloudflare: '2', 'cloudflare-named': '3', ssh: '4' }[cur.tunnel] || '3';
127
+ // Default to the CURRENT tunnel when re-running; for a brand-new user (no config) default to '2'
128
+ // (cloudflare quick tunnel — zero-config, instant public URL) rather than '3' (cloudflare-named),
129
+ // which a bare-Enter newcomer can't complete without a Cloudflare login + their own domain.
130
+ const curPick = { none: '1', cloudflare: '2', 'cloudflare-named': '3', ssh: '4' }[cur.tunnel] || '2';
128
131
  const pick = await ask(rl, t('setup.choose'), curPick);
129
132
  const tunnel = { 1: 'none', 2: 'cloudflare', 3: 'cloudflare-named', 4: 'ssh' }[pick];
130
133
  if (!tunnel) { log.error(t('setup.invalid')); return null; }
@@ -0,0 +1,79 @@
1
+ // Install/uninstall the handmux Claude statusLine — the capturer that snapshots the 5h/weekly rate-limit %
2
+ // (from Claude Code's statusLine stdin, the only documented local source) to ~/.handmux/claude-usage.json
3
+ // for the phone's Usage page. Opt-in, and NON-DESTRUCTIVE by design: Claude allows exactly one statusLine,
4
+ // so if the user already has their OWN we NEVER clobber it — we report 'foreign' and the CLI prints a
5
+ // one-line compose snippet instead. We only ever write settings.statusLine when it's absent or already ours.
6
+ //
7
+ // Iron rule (same as claudeHooks): only ever touch ~/.handmux/ and — after opt-in — ~/.claude/. Never
8
+ // create ~/.claude.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { homedir } from 'node:os';
12
+
13
+ const STATUS_MARK = 'handmux-statusline.cjs'; // identifies our statusLine command among the user's own
14
+ const SCRIPT = 'handmux-statusline.cjs';
15
+
16
+ function claudeDir(home = homedir()) { return path.join(home, '.claude'); }
17
+ function settingsPath(home = homedir()) { return path.join(claudeDir(home), 'settings.json'); }
18
+
19
+ function readSettings(home) {
20
+ try { return JSON.parse(fs.readFileSync(settingsPath(home), 'utf8')); } catch { return {}; }
21
+ }
22
+ function writeJsonAtomic(file, obj) {
23
+ const tmp = `${file}.tmp`;
24
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
25
+ fs.renameSync(tmp, file);
26
+ }
27
+
28
+ function isOurs(sl) {
29
+ return !!(sl && typeof sl.command === 'string' && sl.command.includes(STATUS_MARK));
30
+ }
31
+
32
+ // 'no-claude' → ~/.claude absent. 'ours' → our statusLine is installed. 'foreign' → the user has their own
33
+ // statusLine (we must not touch it). 'absent' → Claude Code is here but no statusLine configured.
34
+ export function statusLineStatus(home = homedir()) {
35
+ if (!fs.existsSync(claudeDir(home))) return 'no-claude';
36
+ const sl = readSettings(home).statusLine;
37
+ if (isOurs(sl)) return 'ours';
38
+ if (sl && (sl.command || sl.type)) return 'foreign';
39
+ return 'absent';
40
+ }
41
+
42
+ // The exact command a user with an EXISTING statusline appends to capture without changing their display:
43
+ // pipe their statusline's stdin through our capturer in TEE mode first. Returned so the CLI can print it.
44
+ export function composeHint(home = homedir(), { usageFile } = {}) {
45
+ const dest = path.join(claudeDir(home), 'hooks', SCRIPT);
46
+ return `HANDMUX_STATUS_TEE=1 node ${dest} ${usageFile} | <your existing statusline>`;
47
+ }
48
+
49
+ // Install (opt-in): copy the capturer to ~/.claude/hooks/ and point settings.statusLine at it — but ONLY
50
+ // when it's safe (absent or already ours). A 'foreign' statusLine is left untouched. Returns { status }.
51
+ // srcDir = bundled hooks dir (server/hooks)
52
+ // usageFile = ~/.handmux/claude-usage.json (the snapshot the server reads)
53
+ export function installStatusLine(home = homedir(), { srcDir, usageFile } = {}) {
54
+ if (!fs.existsSync(claudeDir(home))) return { status: 'no-claude' };
55
+ const status = statusLineStatus(home);
56
+ // Always deploy the capturer script (it's ours, inert until invoked) so the compose one-liner works even
57
+ // in the foreign case. Only the settings.statusLine write is gated on not clobbering the user's own.
58
+ const hooksDir = path.join(claudeDir(home), 'hooks');
59
+ fs.mkdirSync(hooksDir, { recursive: true });
60
+ const dest = path.join(hooksDir, SCRIPT);
61
+ fs.copyFileSync(path.join(srcDir, SCRIPT), dest);
62
+ if (status === 'foreign') return { status: 'foreign', script: dest }; // never touch their statusLine
63
+ const settings = readSettings(home);
64
+ settings.statusLine = { type: 'command', command: `node ${dest} ${usageFile}` };
65
+ writeJsonAtomic(settingsPath(home), settings);
66
+ return { status: 'installed' };
67
+ }
68
+
69
+ // Uninstall: drop settings.statusLine only if it's ours, and remove the copied script. Leaves a foreign
70
+ // statusLine and everything else intact.
71
+ export function uninstallStatusLine(home = homedir()) {
72
+ const settings = readSettings(home);
73
+ if (isOurs(settings.statusLine)) {
74
+ delete settings.statusLine;
75
+ if (fs.existsSync(settingsPath(home))) writeJsonAtomic(settingsPath(home), settings);
76
+ }
77
+ try { fs.unlinkSync(path.join(claudeDir(home), 'hooks', SCRIPT)); } catch { /* already gone */ }
78
+ return { status: 'absent' };
79
+ }
@@ -14,7 +14,7 @@ import { pocketHome } from './state.js';
14
14
  import { t } from './i18n/index.js';
15
15
 
16
16
  export const PKG_NAME = 'handmux';
17
- export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // refresh the cached "latest" at most once a day
17
+ export const CHECK_INTERVAL_MS = 60 * 60 * 1000; // refresh the cached "latest" at most once an hour
18
18
 
19
19
  export function updateCachePath(home) { return path.join(pocketHome(home), 'update-check.json'); }
20
20
 
@@ -60,6 +60,24 @@ export function fetchLatestVersion({ timeoutMs = 4000, run = spawnSync } = {}) {
60
60
  } catch { return null; }
61
61
  }
62
62
 
63
+ // Non-blocking refresh for the long-running server: query npm asynchronously (never stalls the event loop
64
+ // the way the CLI's spawnSync path would) and persist the same {checkedAt, latest} cache the CLI reads. The
65
+ // /api/version route calls this when the cache is stale, so the phone opening the app keeps `latest` current
66
+ // without the user re-running the CLI. Best-effort: npm missing/offline/blocked leaves the prior latest.
67
+ export function refreshLatestAsync(home, { now = Date.now(), spawnFn = spawn, timeoutMs = 4000 } = {}) {
68
+ try {
69
+ const child = spawnFn('npm', ['view', PKG_NAME, 'version'], { timeout: timeoutMs });
70
+ let out = '';
71
+ child.stdout?.on('data', (d) => { out += d; });
72
+ child.on('close', (code) => {
73
+ const v = String(out).trim();
74
+ const latest = (code === 0 && parts(v)) ? v : (readCache(home)?.latest ?? null);
75
+ writeCache(home, { checkedAt: now, latest });
76
+ });
77
+ child.on('error', () => { /* npm missing/offline — leave the cache untouched */ });
78
+ } catch { /* best effort */ }
79
+ }
80
+
63
81
  // The hidden `__update-check` worker (runs detached, prints nothing): refresh the cache. On a failed fetch
64
82
  // keep the previously-known latest but still stamp checkedAt, so a flaky network doesn't re-spawn every run.
65
83
  export function runUpdateCheck(home, { now = Date.now(), ...opts } = {}) {
package/src/git.js CHANGED
@@ -3,13 +3,37 @@ import { homedir } from 'node:os';
3
3
  import { join, basename, isAbsolute } from 'node:path';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { isUnder } from './docPath.js';
6
+ import { defaultExtraRoots } from './docs.js';
6
7
 
7
8
  // 只读子命令白名单:命令层硬过滤,杜绝任何写操作混入。
8
9
  const READONLY = new Set(['rev-parse', 'status', 'log', 'for-each-ref', 'diff', 'show', 'diff-tree']);
9
10
  const MAX_BUFFER = 8 * 1024 * 1024;
10
11
 
11
- export function createGit({ home } = {}) {
12
+ export function createGit({ home, extraRoots = [] } = {}) {
12
13
  const realHomeP = fs.realpath(home);
14
+ // Same multi-root allow-list as createDocs: $HOME plus a few roots OUTSIDE it (/tmp, $TMPDIR) so a repo
15
+ // an agent is working in under /tmp is reachable from the phone. Resolved once — realpath'd, deduped,
16
+ // missing ones skipped, extras already inside home dropped (home covers them). Keeps git browsing in
17
+ // lock-step with the file/doc browser; git.js used to be home-only, which rejected legit repos under
18
+ // /tmp with a red "outside home".
19
+ const rootsP = (async () => {
20
+ const rh = await realHomeP;
21
+ const out = [rh];
22
+ for (const r of extraRoots) {
23
+ if (typeof r !== 'string' || !r) continue;
24
+ let real;
25
+ try { real = await fs.realpath(r); } catch { continue; } // not present on this host → skip
26
+ if (isUnder(real, rh) || out.includes(real)) continue; // already covered by home / dup
27
+ out.push(real);
28
+ }
29
+ return out;
30
+ })();
31
+ // The allowed root that contains `real` (longest match wins should roots ever nest), or null.
32
+ const rootOf = (real, roots) => {
33
+ let best = null;
34
+ for (const r of roots) if (isUnder(real, r) && (!best || r.length > best.length)) best = r;
35
+ return best;
36
+ };
13
37
 
14
38
  function git(cwd, args) {
15
39
  const sub = args[0];
@@ -23,10 +47,9 @@ export function createGit({ home } = {}) {
23
47
 
24
48
  async function resolveRepo(rawPath) {
25
49
  if (typeof rawPath !== 'string' || !isAbsolute(rawPath)) return { error: 'not absolute', status: 400 };
26
- const rh = await realHomeP;
27
50
  let real;
28
51
  try { real = await fs.realpath(rawPath); } catch { return { error: 'not found', status: 404 }; }
29
- if (!isUnder(real, rh)) return { error: 'outside home', status: 400 };
52
+ if (!rootOf(real, await rootsP)) return { error: 'outside home', status: 400 };
30
53
  return { real };
31
54
  }
32
55
 
@@ -182,4 +205,4 @@ export function createGit({ home } = {}) {
182
205
  return { resolveRepo, isRepo, detectRepos, status, log, branches, diff, commit };
183
206
  }
184
207
 
185
- export const defaultGit = createGit({ home: homedir() });
208
+ export const defaultGit = createGit({ home: homedir(), extraRoots: defaultExtraRoots() });
package/src/httpApi.js CHANGED
@@ -24,10 +24,20 @@ import { hooksStatus, installHooks } from './cli/claudeHooks.js';
24
24
  import { codexHooksStatus, installCodexHooks } from './cli/codexHooks.js';
25
25
  import { claudeStatePath } from './cli/state.js';
26
26
  import { scanOrphans, takeoverOrphan, defaultProjectsDir } from './orphans.js';
27
+ import { getUsageCached } from './usage.js';
28
+ import { readFileSync } from 'node:fs';
29
+ import { readCache, isNewer, shouldRefresh, refreshLatestAsync } from './cli/updateCheck.js';
27
30
 
28
31
  const here = dirname(fileURLToPath(import.meta.url));
29
32
  const HOOKS_SRC = resolvePath(here, '../hooks'); // server/hooks (bundled scripts)
30
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
+
31
41
  // Summarize inbox-hook state across every coding agent for the phone: 'installed' if any agent is wired,
32
42
  // 'absent' if an agent is present but none wired (→ offer the one-tap enable), 'no-claude' if there's no
33
43
  // agent at all (→ hide the prompt).
@@ -39,10 +49,24 @@ function combinedHooksStatus(home) {
39
49
  return 'no-claude';
40
50
  }
41
51
 
42
- const ALLOWED_KEYS = new Set([
43
- 'Up', 'Down', 'Left', 'Right', 'Space', 'Enter', 'Escape', 'Tab', 'BTab', 'BSpace',
44
- 'C-c', 'C-d', 'C-z', 'C-l', 'C-r', 'C-o', 'C-e',
45
- ]);
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
+ }
46
70
 
47
71
  // Pause between typing the text and pressing Enter on a /send. A TUI like Claude Code needs a
48
72
  // beat to ingest the pasted line; without it, the Enter can fold into the input as a newline
@@ -270,11 +294,26 @@ export function createApiRouter({
270
294
  } catch (e) { next(e); }
271
295
  });
272
296
 
297
+ // First free filename in `dir`: the name as-is, else Finder-style "base (1).ext", "base (2).ext", …
298
+ // (the suffix goes before the extension). Bounded; a pathological fallback keeps it from looping.
299
+ async function freeUploadName(dir, name) {
300
+ const dot = name.lastIndexOf('.');
301
+ const base = dot > 0 ? name.slice(0, dot) : name;
302
+ const ext = dot > 0 ? name.slice(dot) : '';
303
+ let cand = name;
304
+ for (let n = 1; n <= 999; n++) {
305
+ try { await fsp.access(joinPath(dir, cand)); } // exists → try the next suffix
306
+ catch { return cand; } // ENOENT → free
307
+ cand = `${base} (${n})${ext}`;
308
+ }
309
+ return `${base} (${randomBytes(3).toString('hex')})${ext}`;
310
+ }
311
+
273
312
  // Upload a file into a directory under $HOME. Multipart streamed via busboy (the file never fully
274
313
  // buffers in memory, and the size cap aborts mid-stream). Guards, in order: target dir must be a
275
314
  // non-hidden subdir of home (resolveUploadDir); filename sanitised to a dotless basename; extension
276
- // in the allow-list; no overwrite of an existing name. The client appends `dir` BEFORE the file
277
- // part, so the field is known by the time the file event fires.
315
+ // in the allow-list. A name clash auto-suffixes (never overwrites). The client appends `dir` BEFORE
316
+ // the file part, so the field is known by the time the file event fires.
278
317
  r.post('/upload', (req, res) => {
279
318
  let bb;
280
319
  // defParamCharset:'utf8' — browsers put a non-ASCII (e.g. Chinese) filename into the multipart
@@ -306,11 +345,13 @@ export function createApiRouter({
306
345
  const target = stash ? await docs.resolveStashDir(dir) : await docs.resolveUploadDir(dir);
307
346
  if (target.error) { file.resume(); return done(target.status, { error: target.error }); }
308
347
 
309
- const dest = joinPath(target.real, name);
310
- try { await fsp.access(dest); file.resume(); return done(409, { error: 'exists' }); }
311
- catch { /* name free → proceed */ }
348
+ // Finder-style de-dup: never 409 / overwrite on a name clash — pick the first free "base (n).ext".
349
+ // Resolved up front for the response; re-resolved at link time if the race is lost (see below).
350
+ const origName = name;
351
+ let finalName = await freeUploadName(target.real, origName);
352
+ let dest = joinPath(target.real, finalName);
312
353
 
313
- tmp = joinPath(target.real, `.${name}.uploading-${randomBytes(6).toString('hex')}`);
354
+ tmp = joinPath(target.real, `.${finalName}.uploading-${randomBytes(6).toString('hex')}`);
314
355
  ws = createWriteStream(tmp);
315
356
  ws.on('error', () => { file.resume(); cleanup().finally(() => done(500, { error: 'write failed' })); });
316
357
  ws.on('finish', async () => {
@@ -319,13 +360,22 @@ export function createApiRouter({
319
360
  // maxUploadBytes is allowed; only strictly-larger trips it.)
320
361
  if (file.truncated) { await cleanup(); return done(413, { error: 'too large' }); }
321
362
  try {
322
- // link (NOT rename): if the name appeared meanwhile (a concurrent upload won the race)
323
- // link throws EEXIST → the loser gets 409. So we NEVER silently overwrite another file.
324
- try { await fsp.link(tmp, dest); }
325
- catch (e) { if (e.code === 'EEXIST') { await cleanup(); return done(409, { error: 'exists' }); } throw e; }
363
+ // link (NOT rename): if the name appeared meanwhile (a concurrent upload won the race) link
364
+ // throws EEXIST → we pick the NEXT free suffix and retry, so we still never overwrite another
365
+ // file, and a clash auto-suffixes rather than 409s.
366
+ let linked = false;
367
+ for (let attempt = 0; attempt < 6 && !linked; attempt++) {
368
+ try { await fsp.link(tmp, dest); linked = true; }
369
+ catch (e) {
370
+ if (e.code !== 'EEXIST') throw e;
371
+ finalName = await freeUploadName(target.real, origName);
372
+ dest = joinPath(target.real, finalName);
373
+ }
374
+ }
375
+ if (!linked) { await cleanup(); return done(409, { error: 'exists' }); }
326
376
  await cleanup(); // link made dest a second name for the data; drop the temp name
327
377
  const st = await fsp.stat(dest);
328
- done(201, { name, size: st.size, path: dest }); // absolute path: the dock pastes it into the box
378
+ done(201, { name: finalName, size: st.size, path: dest }); // absolute path: the dock pastes it in
329
379
  } catch { await cleanup(); done(500, { error: 'finalize failed' }); }
330
380
  });
331
381
  file.pipe(ws);
@@ -436,7 +486,7 @@ export function createApiRouter({
436
486
  r.post('/keys', async (req, res, next) => {
437
487
  const { pane, keys } = req.body || {};
438
488
  if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
439
- if (!Array.isArray(keys) || keys.some((k) => !ALLOWED_KEYS.has(k))) {
489
+ if (!Array.isArray(keys) || keys.some((k) => !isAllowedKey(k))) {
440
490
  return res.status(400).json({ error: 'disallowed key' });
441
491
  }
442
492
  try {
@@ -456,6 +506,18 @@ export function createApiRouter({
456
506
  res.json({ asr: isAsrConfigured(asrEnv), claudeHooks: combinedHooksStatus(home) });
457
507
  });
458
508
 
509
+ // Update hint for the phone: is the globally-installed CLI behind the latest npm release? `current` is
510
+ // this server's version; `latest` comes from the same cache the CLI maintains (~/.handmux/update-check.json).
511
+ // We never block on the network here — if the cache is stale we kick a best-effort async refresh (throttled
512
+ // to once an hour, like the CLI) and return the currently-known value. The upgrade itself is a computer-side
513
+ // `handmux update`; the phone only shows the notice.
514
+ r.get('/version', (req, res) => {
515
+ const cache = readCache(home);
516
+ if (shouldRefresh(cache)) refreshLatestAsync(home);
517
+ const latest = cache?.latest ?? null;
518
+ res.json({ current: PKG_VERSION, latest, updateAvailable: !!(latest && PKG_VERSION && isNewer(latest, PKG_VERSION)) });
519
+ });
520
+
459
521
  // One-tap enable from the phone: install the hooks for every present agent (Claude Code, Codex) on the
460
522
  // host (token-gated, like every API here). Opt-in — the inbox only offers this when status is 'absent'.
461
523
  // Never creates ~/.claude or ~/.codex; a user's own Codex `notify` is left untouched (see codexHooks.js).
@@ -530,6 +592,13 @@ export function createApiRouter({
530
592
  try { res.json(await claudeEvents.getStates(allowed)); } catch (e) { next(e); }
531
593
  });
532
594
 
595
+ // Agent usage/quota for the Usage page. Disk-only, no credentials: Claude's 5h/weekly % from the
596
+ // statusLine snapshot (if the capturer is opted in), Codex's rate_limits + tokens from its newest
597
+ // rollout. Either side is null when unavailable. Cached briefly (see usage.js); never throws.
598
+ r.get('/usage', (req, res, next) => {
599
+ try { res.json(getUsageCached(home)); } catch (e) { next(e); }
600
+ });
601
+
533
602
  // Orphan Claude sessions: `claude` processes running on this host but NOT inside a tmux pane, so
534
603
  // handmux can't steer them. Surfaced at the bottom of the Inbox with a "takeover" (spawn
535
604
  // `claude --resume` in tmux). Best-effort process scan (see orphans.js); never throws.
@@ -4,6 +4,8 @@
4
4
  export const DEFAULT_UPLOAD_EXTS = new Set([
5
5
  // images
6
6
  'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'heif', 'avif', 'ico', 'tiff',
7
+ // video
8
+ 'mp4', 'm4v', 'mov', 'webm', 'mkv', 'avi', 'wmv', 'flv', '3gp', 'ogv', 'mpeg', 'mpg',
7
9
  // text / code
8
10
  'txt', 'md', 'markdown', 'rst', 'log', 'csv', 'tsv', 'json', 'yaml', 'yml', 'toml', 'ini',
9
11
  'conf', 'xml', 'html', 'htm', 'css', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'rb', 'go',
package/src/usage.js ADDED
@@ -0,0 +1,94 @@
1
+ // Usage/quota reader for the phone's Usage page. Purely reads what each agent already puts on disk — no
2
+ // API calls, no credentials:
3
+ // • Claude — the snapshot the statusLine capturer writes to ~/.handmux/claude-usage.json. Claude Code's
4
+ // statusLine stdin is the ONLY documented local source of the 5h/weekly rate-limit % (see
5
+ // server/hooks/handmux-statusline.cjs). Absent until the user opts the capturer in → returns null.
6
+ // • Codex — the newest rollout's most recent `token_count` event, which carries `rate_limits` (used %,
7
+ // reset, window) and cumulative token usage. Always available once Codex has run, no wiring needed.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { homedir } from 'node:os';
11
+ import { pocketHome } from './cli/state.js';
12
+
13
+ export function claudeUsagePath(home = homedir()) { return path.join(pocketHome(home), 'claude-usage.json'); }
14
+ export function codexSessionsDir(home = homedir()) { return path.join(home, '.codex', 'sessions'); }
15
+
16
+ // Claude: read the statusLine snapshot. null if the capturer isn't wired / never populated it.
17
+ export function readClaudeUsage(home = homedir()) {
18
+ try {
19
+ const snap = JSON.parse(fs.readFileSync(claudeUsagePath(home), 'utf8'));
20
+ return (snap && typeof snap === 'object' && !Array.isArray(snap)) ? snap : null;
21
+ } catch { return null; }
22
+ }
23
+
24
+ // The rollout tree is date-nested (sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl) and every path segment
25
+ // sorts lexically = chronologically, so the newest rollout is the lexically-largest entry at each level —
26
+ // found without walking the whole tree.
27
+ function newestRollout(dir) {
28
+ const maxEntry = (d, pred) => {
29
+ let names;
30
+ try { names = fs.readdirSync(d); } catch { return null; }
31
+ names = names.filter((n) => !n.startsWith('.') && (!pred || pred(n))).sort();
32
+ return names.length ? names[names.length - 1] : null;
33
+ };
34
+ const y = maxEntry(dir); if (!y) return null;
35
+ const m = maxEntry(path.join(dir, y)); if (!m) return null;
36
+ const d = maxEntry(path.join(dir, y, m)); if (!d) return null;
37
+ const dayDir = path.join(dir, y, m, d);
38
+ const f = maxEntry(dayDir, (n) => n.startsWith('rollout-') && n.endsWith('.jsonl'));
39
+ return f ? path.join(dayDir, f) : null;
40
+ }
41
+
42
+ // One Codex rate-limit window → our shape, or null if absent (secondary is often null on plans without it).
43
+ function codexWindow(w) {
44
+ if (!w || typeof w.used_percent !== 'number') return null;
45
+ return {
46
+ usedPercent: w.used_percent,
47
+ windowMinutes: typeof w.window_minutes === 'number' ? w.window_minutes : null,
48
+ resetsAt: typeof w.resets_at === 'number' ? w.resets_at : null,
49
+ };
50
+ }
51
+
52
+ // Codex: scan the newest rollout from the end for the last `token_count` event (carries the account-wide
53
+ // rate_limits + the session's cumulative tokens). null if Codex hasn't run or the rollout has none yet.
54
+ export function readCodexUsage(home = homedir()) {
55
+ const f = newestRollout(codexSessionsDir(home));
56
+ if (!f) return null;
57
+ let lines;
58
+ try { lines = fs.readFileSync(f, 'utf8').split('\n'); } catch { return null; }
59
+ for (let i = lines.length - 1; i >= 0; i--) {
60
+ const ln = lines[i];
61
+ if (!ln || ln.indexOf('token_count') === -1) continue;
62
+ let rec; try { rec = JSON.parse(ln); } catch { continue; }
63
+ const p = rec.payload;
64
+ if (!p || p.type !== 'token_count') continue;
65
+ const info = p.info || {};
66
+ const tu = info.total_token_usage || {};
67
+ const rl = p.rate_limits || {};
68
+ return {
69
+ updatedAt: Date.parse(rec.timestamp) || null,
70
+ rateLimits: { primary: codexWindow(rl.primary), secondary: codexWindow(rl.secondary) },
71
+ tokens: {
72
+ total: tu.total_tokens ?? null,
73
+ input: tu.input_tokens ?? null,
74
+ cachedInput: tu.cached_input_tokens ?? null,
75
+ output: tu.output_tokens ?? null,
76
+ reasoning: tu.reasoning_output_tokens ?? null,
77
+ },
78
+ contextWindow: typeof info.model_context_window === 'number' ? info.model_context_window : null,
79
+ };
80
+ }
81
+ return null;
82
+ }
83
+
84
+ export function getUsage(home = homedir()) {
85
+ return { claude: readClaudeUsage(home), codex: readCodexUsage(home) };
86
+ }
87
+
88
+ // Small TTL cache so a phone that re-polls doesn't rescan the rollout every few seconds.
89
+ let _cache = { at: 0, home: null, data: null };
90
+ export function getUsageCached(home = homedir(), { ttlMs = 15000, now = Date.now() } = {}) {
91
+ if (_cache.data && _cache.home === home && (now - _cache.at) < ttlMs) return _cache.data;
92
+ _cache = { at: now, home, data: getUsage(home) };
93
+ return _cache.data;
94
+ }