handmux 0.5.3 → 0.7.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 +18 -4
- package/README.zh-CN.md +16 -4
- package/bin/handmux.js +176 -117
- package/hooks/handmux-notify.sh +4 -2
- package/hooks/handmux-statusline.cjs +75 -0
- package/hooks/handmux-write.cjs +17 -24
- package/package.json +1 -2
- package/public/assets/index-CCvTPJwI.js +157 -0
- package/public/assets/{index-CSX2d9C7.css → index-CYEn5HNU.css} +1 -1
- package/public/index.html +2 -2
- package/src/agents/claude.js +95 -0
- package/src/agents/codex.js +110 -0
- package/src/agents/index.js +20 -0
- package/src/agents/scanUtils.js +209 -0
- package/src/claudeEvents.js +27 -69
- package/src/cli/cloudflared.js +49 -3
- package/src/cli/codexHooks.js +125 -0
- package/src/cli/i18n/en.js +186 -0
- package/src/cli/i18n/index.js +53 -0
- package/src/cli/i18n/zh.js +185 -0
- package/src/cli/options.js +3 -0
- package/src/cli/setupWizard.js +62 -48
- package/src/cli/statusLine.js +79 -0
- package/src/cli/updateCheck.js +90 -0
- package/src/httpApi.js +55 -5
- package/src/orphans.js +138 -0
- package/src/usage.js +94 -0
- package/public/assets/index-CHabGCEm.js +0 -157
- package/src/cli/tmuxConf.js +0 -90
- package/tmux/README.md +0 -77
- package/tmux/claude-tab-seed.py +0 -67
- package/tmux/claude-tab-seen.sh +0 -14
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// handmux statusLine capturer. Claude Code pipes a JSON blob to its `statusLine` command on stdin — the
|
|
3
|
+
// ONLY officially-documented local source of the 5-hour / weekly rate-limit percentages (the same numbers
|
|
4
|
+
// `/usage` shows). See https://code.claude.com/docs/en/statusline ("Available data": rate_limits.five_hour
|
|
5
|
+
// / seven_day, context_window, model). We snapshot those fields to a file the handmux server serves to the
|
|
6
|
+
// phone's Usage page, then produce stdout so the terminal statusline still works:
|
|
7
|
+
//
|
|
8
|
+
// node handmux-statusline.cjs <usageFile> # snapshot + print a compact status line
|
|
9
|
+
// HANDMUX_STATUS_TEE=1 node handmux-statusline.cjs <usageFile> # snapshot + re-emit stdin verbatim
|
|
10
|
+
//
|
|
11
|
+
// TEE mode is for a user who ALREADY has a statusline: they pipe `... | handmux-statusline.cjs <f> | their
|
|
12
|
+
// renderer`, so their renderer downstream receives the exact same JSON and their display is unchanged.
|
|
13
|
+
//
|
|
14
|
+
// .cjs so it runs standalone via `node <file>` regardless of any surrounding package.json "type". Best-
|
|
15
|
+
// effort and silent throughout — a statusLine command must never fail Claude.
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
19
|
+
const file = process.argv[2];
|
|
20
|
+
const raw = (() => { try { return fs.readFileSync(0, 'utf8'); } catch { return ''; } })();
|
|
21
|
+
let j = {};
|
|
22
|
+
try { j = JSON.parse(raw || '{}'); } catch { /* not JSON → leave j = {} */ }
|
|
23
|
+
|
|
24
|
+
// One rate-limit window → our shape, or undefined if the field is absent (rate_limits only appears for
|
|
25
|
+
// Pro/Max plans, and only after a session's first API response).
|
|
26
|
+
function win(o) {
|
|
27
|
+
if (!o || typeof o.used_percentage !== 'number') return undefined;
|
|
28
|
+
const w = { usedPercent: o.used_percentage };
|
|
29
|
+
if (typeof o.resets_at === 'number') w.resetsAt = o.resets_at;
|
|
30
|
+
return w;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (file) {
|
|
34
|
+
try {
|
|
35
|
+
const rl = j.rate_limits || {};
|
|
36
|
+
const cw = j.context_window || {};
|
|
37
|
+
const rateLimits = {
|
|
38
|
+
fiveHour: win(rl.five_hour),
|
|
39
|
+
sevenDay: win(rl.seven_day),
|
|
40
|
+
sevenDayOpus: win(rl.seven_day_opus),
|
|
41
|
+
sevenDaySonnet: win(rl.seven_day_sonnet),
|
|
42
|
+
};
|
|
43
|
+
for (const k of Object.keys(rateLimits)) if (rateLimits[k] === undefined) delete rateLimits[k];
|
|
44
|
+
const snap = {
|
|
45
|
+
updatedAt: Date.now(),
|
|
46
|
+
model: (j.model && (j.model.display_name || j.model.id)) || null,
|
|
47
|
+
context: (typeof cw.used_percentage === 'number') ? { usedPercent: cw.used_percentage } : undefined,
|
|
48
|
+
rateLimits,
|
|
49
|
+
};
|
|
50
|
+
if (snap.context === undefined) delete snap.context;
|
|
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
|
|
55
|
+
} catch { /* best effort — never fail the statusline */ }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Output. TEE → re-emit stdin so a downstream renderer is unaffected. Otherwise render a compact line from
|
|
59
|
+
// whatever fields are present (a plain default statusline for users who had none).
|
|
60
|
+
if (process.env.HANDMUX_STATUS_TEE === '1') {
|
|
61
|
+
process.stdout.write(raw);
|
|
62
|
+
} else {
|
|
63
|
+
try {
|
|
64
|
+
const seg = [];
|
|
65
|
+
const dir = j.workspace && j.workspace.current_dir;
|
|
66
|
+
if (dir) seg.push(path.basename(dir));
|
|
67
|
+
if (j.model && j.model.display_name) seg.push(j.model.display_name);
|
|
68
|
+
const cw = j.context_window || {};
|
|
69
|
+
if (typeof cw.used_percentage === 'number') seg.push(`Ctx ${Math.round(cw.used_percentage)}%`);
|
|
70
|
+
const rl = j.rate_limits || {};
|
|
71
|
+
if (rl.five_hour && typeof rl.five_hour.used_percentage === 'number') seg.push(`5h ${Math.round(rl.five_hour.used_percentage)}%`);
|
|
72
|
+
if (rl.seven_day && typeof rl.seven_day.used_percentage === 'number') seg.push(`Wk ${Math.round(rl.seven_day.used_percentage)}%`);
|
|
73
|
+
if (seg.length) process.stdout.write(seg.join(' · '));
|
|
74
|
+
} catch { /* silent */ }
|
|
75
|
+
}
|
package/hooks/handmux-write.cjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
const fs = require('node:fs');
|
|
17
17
|
const path = require('node:path');
|
|
18
18
|
|
|
19
|
-
const [, , file, pane, src, ts, host = ''] = process.argv;
|
|
19
|
+
const [, , file, pane, src, ts, host = '', agent = ''] = process.argv;
|
|
20
20
|
if (!file || !pane || !src) process.exit(0);
|
|
21
21
|
|
|
22
22
|
let payload = {};
|
|
@@ -27,7 +27,6 @@ try { payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { /* unr
|
|
|
27
27
|
// terminates an ESC-interrupted working turn (→ clear the stuck 进行中). Flag it here; the read-modify-
|
|
28
28
|
// write under the lock — the only place we can read the prior state safely — makes the call.
|
|
29
29
|
const isIdle = src === 'notify' && payload && payload.notification_type === 'idle_prompt';
|
|
30
|
-
let cleared = false; // set by update() when idle cleared an interrupted 进行中 → also clear @claude_dot below
|
|
31
30
|
|
|
32
31
|
// Synchronous nap without busy-spinning (the hook runs async, so a few ms is free). SharedArrayBuffer
|
|
33
32
|
// may be unavailable in odd runtimes — fall back to a tiny busy loop so the lock retry still paces.
|
|
@@ -47,14 +46,26 @@ function update() {
|
|
|
47
46
|
// idle after a resting state (done/needs/nothing) is just the "still waiting" reminder → drop and
|
|
48
47
|
// leave the file as it was (recording it would bump ts and re-surface an already-cleared 已完成).
|
|
49
48
|
// idle after a WORKING turn (prompt/resume) that never got a Stop = an ESC interrupt / walk-away —
|
|
50
|
-
// no Stop hook fires there, so idle is the only signal the turn ended. Without this the
|
|
51
|
-
//
|
|
52
|
-
if (prevSrc === 'prompt' || prevSrc === 'resume') { delete obj[pane];
|
|
49
|
+
// no Stop hook fires there, so idle is the only signal the turn ended. Without this the pane would
|
|
50
|
+
// stay latched at 进行中 forever; treat it as a soft end and clear the pane.
|
|
51
|
+
if (prevSrc === 'prompt' || prevSrc === 'resume') { delete obj[pane]; }
|
|
53
52
|
else return; // resting → drop without writing
|
|
54
53
|
} else if (src === 'end') {
|
|
55
54
|
delete obj[pane]; // SessionEnd (clean exit) → drop the pane
|
|
55
|
+
} else if (src === 'resume' && agent === 'codex') {
|
|
56
|
+
// Codex fires PostToolUse on EVERY tool call, so its resume exists purely to un-stick a pane from 需要你
|
|
57
|
+
// back to 进行中 after the user approved a PermissionRequest. Apply it ONLY as that transition — a mid-
|
|
58
|
+
// turn tool call (pane already 进行中 / 已完成) is a no-op, so we don't rewrite the entry on every
|
|
59
|
+
// command (the load Claude's matcher avoids). Claude's resume — no agent arg — is unaffected.
|
|
60
|
+
const prev = obj[pane];
|
|
61
|
+
const prevPerm = prev && (prev.src === 'permreq'
|
|
62
|
+
|| (prev.src === 'notify' && (prev.payload || {}).notification_type === 'permission_prompt'));
|
|
63
|
+
if (!prevPerm) { return; }
|
|
64
|
+
obj[pane] = { ts: Number(ts) || 0, src, host, payload, agent };
|
|
56
65
|
} else {
|
|
57
|
-
|
|
66
|
+
// agent tag lets the server dispatch classify + liveness per agent (Codex passes 'codex'); omitted for
|
|
67
|
+
// Claude so legacy entries stay byte-identical and default to claude server-side.
|
|
68
|
+
obj[pane] = { ts: Number(ts) || 0, src, host, payload, ...(agent ? { agent } : {}) };
|
|
58
69
|
}
|
|
59
70
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
60
71
|
fs.writeFileSync(tmp, JSON.stringify(obj));
|
|
@@ -72,21 +83,3 @@ for (let i = 0; i < 60 && !held; i++) { // ~0.9s budget, t
|
|
|
72
83
|
}
|
|
73
84
|
try { update(); } catch { /* best effort */ }
|
|
74
85
|
if (held) { try { fs.unlinkSync(lock); } catch { /* ignore */ } }
|
|
75
|
-
|
|
76
|
-
// tmux 页签状态色点(事件驱动):把本次事件的状态色 markup 写进该 pane 所在窗的 @claude_dot 选项。
|
|
77
|
-
// tmux 的 window-status-format 只读 #{@claude_dot}(纯查表、不跑任何 shell),所以稳态零重绘——只有
|
|
78
|
-
// 状态真变化(即本 hook 触发的这一刻)才写一次、才重绘一次,不轮询、不卡。详见 tmux/README.md。
|
|
79
|
-
// best-effort + 1s 超时:不在 tmux / tmux 不可达都静默忽略,永不阻塞或失败 Claude。
|
|
80
|
-
function claudeDot(s, p) {
|
|
81
|
-
if (s === 'stop') return '#[fg=#2e7d46]●#[default] '; // 已完成 绿
|
|
82
|
-
if (s === 'prompt' || s === 'resume') return '#[fg=#2f6fed,blink]●#[default] '; // 进行中 蓝闪
|
|
83
|
-
if (s === 'permreq') return '#[fg=#e0a020]●#[default] '; // 需要你 橙
|
|
84
|
-
if (s === 'notify' && p && p.notification_type === 'permission_prompt') return '#[fg=#e0a020]●#[default] ';
|
|
85
|
-
return null; // 其它无法分类 → 不动该窗的点(end 在下方单独清空)
|
|
86
|
-
}
|
|
87
|
-
try {
|
|
88
|
-
const dot = (src === 'end' || cleared) ? '' : claudeDot(src, payload);
|
|
89
|
-
if (dot !== null) {
|
|
90
|
-
require('node:child_process').execFileSync('tmux', ['set-option', '-w', '-t', pane, '@claude_dot', dot], { stdio: 'ignore', timeout: 1000 });
|
|
91
|
-
}
|
|
92
|
-
} catch { /* 不在 tmux / tmux 不可达 → 忽略 */ }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handmux",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Mobile vibe coding — drive your real tmux session (and Claude Code) from your phone.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
"bin",
|
|
25
25
|
"src",
|
|
26
26
|
"hooks",
|
|
27
|
-
"tmux",
|
|
28
27
|
"public",
|
|
29
28
|
"README.zh-CN.md"
|
|
30
29
|
],
|