handmux 0.18.0 → 0.19.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/README.md CHANGED
@@ -67,15 +67,16 @@ handmux start --tunnel cloudflare # instant public URL (cloudflared auto-insta
67
67
 
68
68
  ## Features
69
69
 
70
- - **Claude Code / Codex, deeply** — an inbox status ledger, thumb-approve permissions & plans, per-agent usage bars.
70
+ - **Claude Code / Codex, deeply** — an inbox status ledger, thumb-approve permissions & plans, and stable host-wide usage bars shared by every connected device.
71
71
  - **Chat view (experimental)** — read and drive a Claude session as a chat instead of a terminal: bubbles with Markdown, tool cards with colored diffs, question cards you answer with a tap, warm colour tones. Experimental — may be unstable: enable it in Settings → 启用对话视图 / Enable chat view, then switch views from the window bar.
72
+ - **Desktop physical keyboard input** — open the same Handmux URL on a desktop browser and type into the current tmux pane immediately, without tapping “Show keyboard”. Press `Shift+Enter` or click the draft field to enter draft mode with shortcuts, upload, history, voice and multiline editing intact. `Enter` sends and stays in draft mode, `Shift+Enter` inserts a newline, and IME Enter only confirms composition; press `Esc` or click the terminal to return to direct input. Drag to select terminal text, then copy/paste with `Cmd+C` / `Cmd+V` on macOS or `Ctrl+Shift+C` / `Ctrl+V` (or `Ctrl+Shift+V`) on Windows/Linux; `Ctrl+C` remains the terminal interrupt. Settings → Keyboard mode can auto-detect or force Mobile/Desktop for this browser only. Echo still follows Handmux’s terminal snapshots rather than a real-time PTY. Browser-reserved shortcuts such as `Cmd+W/T/L/R` stay with the browser.
72
73
  - **Command & chat modes** — one bottom bar, two modes: type straight into the terminal, or talk to the agent in natural language. Both default quick bars include `Ctrl+C`. `handmux shortcuts` configures shared key/text items; each phone's ⚙ editor shows the effective quick-bar order, interleaves shared and local items, and can remove a shared item from that device only with immediate undo. Adding a phone-local item confirms success and returns directly to the list. Server changes apply live and phones reload them on foreground—no restart or polling required.
73
74
  - **Script push** — notify your phone from any script or CI step with `handmux push`; target all devices, a named session, or a specific device.
74
75
  - **Workspace recovery** — handmux silently keeps the metadata needed to rebuild your latest tmux workspace. After a computer or tmux-server restart, restore it beside any new sessions from the phone or with `handmux restore`; existing sessions are never replaced.
75
76
  - **Git viewer** — changes / commit history / any branch / full-screen colored diff, multi-repo tabs, read-only, never touches your tree.
76
77
  - **Site preview** — a static folder, or a running HTTP/HTTPS service by port (routing / APIs / live-reload intact), in a phone or desktop viewport. Set its bare preview domain once in `handmux setup`.
77
78
  - **Docs** — tap a path in the terminal to open it; Markdown rendered, font zoom, sentence-by-sentence read-aloud.
78
- - **Select & copy text** — long-press to select in the terminal, drag iOS-style handles to fine-tune, copy the selection / a whole line / a whole paragraph.
79
+ - **Mobile select & copy** — long-press to select in the terminal, drag iOS-style handles to fine-tune, copy the selection / a whole line / a whole paragraph.
79
80
  - **Files both ways** — multi-select upload from the chat box, download, share in, copy any absolute path.
80
81
  - **Ideas — catch every one** — a thought the moment it strikes: a per-window idea/to-do list, jot one by voice and drop it straight into the prompt.
81
82
  - **Built for flaky networks** — backoff reconnect, connection-lost banner, offline page, polling that pauses in the background; a reflow-safe cursor.
package/README.zh-CN.md CHANGED
@@ -67,8 +67,9 @@ handmux start --tunnel cloudflare # 即时公网地址(自动装 cloudflared)
67
67
 
68
68
  ## 功能一览
69
69
 
70
- - **Claude Code / Codex 深度**——收件箱状态台账、拇指批授权批计划、各 agent 用量条。
70
+ - **Claude Code / Codex 深度**——收件箱状态台账、拇指批授权批计划,以及所有设备共享的本机最新 agent 用量。
71
71
  - **对话视图(实验性)**——把 Claude 会话当成聊天来看、来驱动,而不是终端:气泡 + Markdown 正文、带彩色 diff 的工具卡、点按即答的问题卡、暖色配色。实验性功能,可能不稳定:在设置里开启「启用对话视图(实验性功能)」后,从窗口栏切换视图。
72
+ - **电脑物理键盘直输**——电脑浏览器打开同一个 Handmux URL,无需点「展开键盘」即可直接输入当前 tmux pane。按 `Shift+Enter` 或点击草稿框进入草稿模式,快捷用语、上传、历史、语音和多行自动增高全部保留;`Enter` 发送后仍停留在草稿模式,`Shift+Enter` 换行,输入法合成中的 Enter 只确认候选词,按 `Esc` 或点击终端回到直通模式。终端文字可直接用鼠标拖选,macOS 按 `Cmd+C` / `Cmd+V`、Windows / Linux 按 `Ctrl+Shift+C` / `Ctrl+V`(或 `Ctrl+Shift+V`)复制粘贴,`Ctrl+C` 仍是终端中断。设置里的「键盘模式」可选自动识别、手机或电脑,选择只影响当前浏览器。终端回显仍跟随 Handmux 的快照刷新,并非实时 PTY;`Cmd+W/T/L/R` 等浏览器保留组合不承诺发送到终端。
72
73
  - **命令 / 聊天双模式**——底部一栏两种模式:直接敲进终端,或用自然语言发给 agent;两种默认快捷栏都有 `Ctrl+C`。`handmux shortcuts` 配置所有设备共享的按键 / 文字项;每台手机的 ⚙ 编辑器按快捷栏实际顺序统一显示共享与本机项,可自由混排,也可只从本机移除共享项并即时撤销。本机新增成功后会明确提示并直接返回列表。服务端修改立即生效,手机回到前台自动读取,无需重启或轮询。
73
74
  - **脚本推送**——用 `handmux push` 从脚本或 CI 步骤推消息到手机,可指定全部设备、某个会话或某台设备。
74
75
  - **工作区恢复**——handmux 静默保存重建最新 tmux 工作区所需的元数据。电脑或 tmux server 重启后,可从手机或 `handmux restore` 把旧工作区恢复到新会话旁边,绝不替换现有会话。
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ // Persistent machine-wide Codex usage snapshot. This file is standalone CommonJS because hook installers
3
+ // copy it into ~/.codex/hooks/, while the ESM server also requires the bundled copy directly.
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ const CHUNK_BYTES = 64 * 1024;
8
+
9
+ function windowUsage(value) {
10
+ if (!value || typeof value.used_percent !== 'number') return null;
11
+ return {
12
+ usedPercent: value.used_percent,
13
+ windowMinutes: typeof value.window_minutes === 'number' ? value.window_minutes : null,
14
+ resetsAt: typeof value.resets_at === 'number' ? value.resets_at : null,
15
+ };
16
+ }
17
+
18
+ function normalize(record) {
19
+ const payload = record && record.payload;
20
+ if (!payload || payload.type !== 'token_count') return null;
21
+ const updatedAt = Date.parse(record.timestamp);
22
+ if (!Number.isFinite(updatedAt)) return null;
23
+ const info = payload.info || {};
24
+ const totals = info.total_token_usage || {};
25
+ const limits = payload.rate_limits || {};
26
+ return {
27
+ updatedAt,
28
+ rateLimits: {
29
+ primary: windowUsage(limits.primary),
30
+ secondary: windowUsage(limits.secondary),
31
+ },
32
+ tokens: {
33
+ total: totals.total_tokens ?? null,
34
+ input: totals.input_tokens ?? null,
35
+ cachedInput: totals.cached_input_tokens ?? null,
36
+ output: totals.output_tokens ?? null,
37
+ reasoning: totals.reasoning_output_tokens ?? null,
38
+ },
39
+ contextWindow: typeof info.model_context_window === 'number' ? info.model_context_window : null,
40
+ };
41
+ }
42
+
43
+ function parseLatest(lines) {
44
+ for (let i = lines.length - 1; i >= 0; i--) {
45
+ const line = lines[i];
46
+ if (!line || !line.includes('token_count')) continue;
47
+ try {
48
+ const usage = normalize(JSON.parse(line));
49
+ if (usage) return usage;
50
+ } catch { /* damaged/incomplete line: continue toward older complete records */ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function readLatestUsage(transcriptPath) {
56
+ let fd;
57
+ try {
58
+ fd = fs.openSync(transcriptPath, 'r');
59
+ let end = fs.fstatSync(fd).size;
60
+ let suffix = '';
61
+ while (end > 0) {
62
+ const start = Math.max(0, end - CHUNK_BYTES);
63
+ const buffer = Buffer.allocUnsafe(end - start);
64
+ fs.readSync(fd, buffer, 0, buffer.length, start);
65
+ const lines = `${buffer.toString('utf8')}${suffix}`.split('\n');
66
+ if (start > 0) suffix = lines.shift() || '';
67
+ const usage = parseLatest(lines);
68
+ if (usage) return usage;
69
+ end = start;
70
+ }
71
+ return suffix ? parseLatest([suffix]) : null;
72
+ } catch {
73
+ return null;
74
+ } finally {
75
+ if (fd !== undefined) {
76
+ try { fs.closeSync(fd); } catch { /* best effort */ }
77
+ }
78
+ }
79
+ }
80
+
81
+ function readSnapshot(snapshotPath) {
82
+ try {
83
+ const value = JSON.parse(fs.readFileSync(snapshotPath, 'utf8'));
84
+ if (!value || value.version !== 1 || typeof value.checkedAt !== 'number') return null;
85
+ if (value.usage !== null && (!value.usage || typeof value.usage.updatedAt !== 'number')) return null;
86
+ return value;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function nap(ms) {
93
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
94
+ catch { const until = Date.now() + ms; while (Date.now() < until) { /* spin */ } }
95
+ }
96
+
97
+ function writeSnapshot(snapshotPath, usage, { checkedAt } = {}) {
98
+ try { fs.mkdirSync(path.dirname(snapshotPath), { recursive: true }); } catch { return null; }
99
+ const lock = `${snapshotPath}.lock`;
100
+ let held = false;
101
+ for (let i = 0; i < 40 && !held; i++) {
102
+ try { fs.closeSync(fs.openSync(lock, 'wx')); held = true; }
103
+ catch {
104
+ try { if (Date.now() - fs.statSync(lock).mtimeMs > 3000) fs.unlinkSync(lock); } catch { /* retry */ }
105
+ nap(5);
106
+ }
107
+ }
108
+ if (!held) return readSnapshot(snapshotPath)?.usage || null;
109
+
110
+ try {
111
+ const previous = readSnapshot(snapshotPath);
112
+ const priorUsage = previous?.usage || null;
113
+ const nextUsage = usage && (!priorUsage || usage.updatedAt >= priorUsage.updatedAt) ? usage : priorUsage;
114
+ const value = {
115
+ version: 1,
116
+ checkedAt: typeof checkedAt === 'number' ? checkedAt : (previous?.checkedAt || 0),
117
+ usage: nextUsage,
118
+ };
119
+ const tmp = `${snapshotPath}.${process.pid}.${Date.now()}.tmp`;
120
+ fs.writeFileSync(tmp, JSON.stringify(value));
121
+ fs.renameSync(tmp, snapshotPath);
122
+ return nextUsage;
123
+ } catch {
124
+ return readSnapshot(snapshotPath)?.usage || null;
125
+ } finally {
126
+ try { fs.unlinkSync(lock); } catch { /* best effort */ }
127
+ }
128
+ }
129
+
130
+ function captureTranscript(transcriptPath, snapshotPath) {
131
+ const usage = readLatestUsage(transcriptPath);
132
+ if (!usage) return readSnapshot(snapshotPath)?.usage || null;
133
+ return writeSnapshot(snapshotPath, usage);
134
+ }
135
+
136
+ module.exports = {
137
+ readLatestUsage,
138
+ readSnapshot,
139
+ writeSnapshot,
140
+ captureTranscript,
141
+ };
@@ -49,9 +49,22 @@ if (file) {
49
49
  };
50
50
  if (snap.context === undefined) delete snap.context;
51
51
  fs.mkdirSync(path.dirname(file), { recursive: true });
52
- const tmp = `${file}.${process.pid}.tmp`;
53
- fs.writeFileSync(tmp, JSON.stringify(snap));
54
- fs.renameSync(tmp, file); // atomic: concurrent statuslines (multiple sessions) can't tear the snapshot
52
+ let previousHasQuota = false;
53
+ try {
54
+ const previous = JSON.parse(fs.readFileSync(file, 'utf8'));
55
+ previousHasQuota = previous?.rateLimits && Object.keys(previous.rateLimits).length > 0;
56
+ } catch { /* no prior machine-wide snapshot */ }
57
+ // A new session renders statusLine before its first API response, when rate_limits is absent. Keep the
58
+ // previous machine-wide quota until a real rate-limit payload refreshes it instead of flashing empty.
59
+ if (Object.keys(rateLimits).length > 0) {
60
+ const tmp = `${file}.${process.pid}.tmp`;
61
+ fs.writeFileSync(tmp, JSON.stringify(snap));
62
+ fs.renameSync(tmp, file); // atomic: concurrent statuslines (multiple sessions) can't tear the snapshot
63
+ } else if (!previousHasQuota) {
64
+ // Create the initial "capturer active, awaiting data" marker only if no concurrent session has
65
+ // already published a real quota snapshot. An empty session can therefore never win that race.
66
+ try { fs.writeFileSync(file, JSON.stringify(snap), { flag: 'wx' }); } catch { /* file now exists */ }
67
+ }
55
68
 
56
69
  // Per-session context snapshot. The global file above is last-writer-wins across ALL sessions, so it
57
70
  // can't tell the phone which session a given pane is on. Claude's statusLine stdin carries `session_id`,
@@ -89,3 +89,12 @@ for (let i = 0; i < 60 && !held; i++) { // ~0.9s budget, t
89
89
  }
90
90
  try { update(); } catch { /* best effort */ }
91
91
  if (held) { try { fs.unlinkSync(lock); } catch { /* ignore */ } }
92
+
93
+ // Codex hook payloads include the exact rollout transcript_path. Capture its latest token_count into a
94
+ // machine-wide snapshot after the inbox update; failures are isolated so usage can never break the hook.
95
+ if (agent === 'codex' && typeof payload.transcript_path === 'string') {
96
+ try {
97
+ const { captureTranscript } = require('./handmux-codex-usage.cjs');
98
+ captureTranscript(payload.transcript_path, path.join(path.dirname(file), 'codex-usage.json'));
99
+ } catch { /* best effort */ }
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handmux",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "A mobile vibe-coding cockpit — built on tmux: drive your live session, Claude Code / Codex — anything a terminal can run — from your phone.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",
@@ -46,6 +46,7 @@
46
46
  "dependencies": {
47
47
  "@clack/prompts": "0.11.0",
48
48
  "busboy": "^1.6.0",
49
+ "compression": "^1.8.1",
49
50
  "express": "^4.19.2",
50
51
  "tunlite": "^0.10.0",
51
52
  "web-push": "^3.6.7"
@@ -58,13 +59,13 @@
58
59
  "vitest": "^2.0.0"
59
60
  },
60
61
  "whatsNew": [
62
+ {"version":"0.19.0","date":"2026-07-25","zh":"电脑物理键盘直输 · Window 切换更流畅","en":"Desktop keyboard input · faster window switching"},
61
63
  {"version":"0.18.0","date":"2026-07-22","zh":"Tmux 工作区恢复 · 快捷栏自由定制","en":"Tmux workspace recovery · customizable shortcuts"},
62
64
  {"version":"0.17.8","date":"2026-07-20","zh":"分屏地图显示尺寸 · iPhone 交互修复","en":"Pane dimensions in the map · iPhone interaction fixes"},
63
65
  {"version":"0.17.7","date":"2026-07-20","zh":"单实例启动兜底 · WSL 状态命令修复","en":"Single-instance startup guard · WSL status fix"},
64
66
  {"version":"0.17.6","date":"2026-07-20","zh":"推送结果可追踪 · 对话与权限操作更可靠","en":"Traceable push results · safer chats and permissions"},
65
67
  {"version":"0.17.5","date":"2026-07-20","zh":"通知开启修复 · 长会话与设置可靠性提升","en":"Notification setup fixed · stronger chat and settings reliability"},
66
68
  {"version":"0.17.4","date":"2026-07-19","zh":"HTTPS 本机预览修复 · 通知链接安全加固","en":"HTTPS localhost previews fixed · safer notification links"},
67
- {"version":"0.17.3","date":"2026-07-19","zh":"三修官方一键安装的 Claude 识别,真机已验证","en":"Third fix for native-install Claude detection, device-verified"},
68
- {"version":"0.17.0","date":"2026-07-18","zh":"对话视图(实验性)· 通知记录 · 本机地址一键预览","en":"Chat view (experimental) · notification inbox · one-tap localhost preview"}
69
+ {"version":"0.17.3","date":"2026-07-19","zh":"三修官方一键安装的 Claude 识别,真机已验证","en":"Third fix for native-install Claude detection, device-verified"}
69
70
  ]
70
71
  }