handmux 0.6.0 → 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 +0 -4
- package/README.zh-CN.md +0 -4
- package/bin/handmux.js +24 -17
- package/hooks/handmux-statusline.cjs +75 -0
- package/hooks/handmux-write.cjs +6 -26
- package/package.json +1 -2
- package/public/assets/index-CCvTPJwI.js +157 -0
- package/public/assets/{index-_nvN0yNe.css → index-CYEn5HNU.css} +1 -1
- package/public/index.html +2 -2
- package/src/claudeEvents.js +2 -9
- package/src/cli/i18n/en.js +5 -5
- package/src/cli/i18n/zh.js +5 -5
- package/src/cli/setupWizard.js +4 -1
- package/src/cli/statusLine.js +79 -0
- package/src/httpApi.js +8 -0
- package/src/usage.js +94 -0
- package/public/assets/index-DlO8ul0J.js +0 -157
- package/src/cli/tmuxConf.js +0 -90
- package/tmux/README.md +0 -77
- package/tmux/claude-tab-seed.py +0 -72
- package/tmux/claude-tab-seen.sh +0 -14
package/README.md
CHANGED
|
@@ -153,10 +153,6 @@ hook events in `~/.claude/settings.json` (idempotent; leaves your own hooks alon
|
|
|
153
153
|
this too, and you can turn it on from the phone the first time you open the inbox. `handmux hooks uninstall`
|
|
154
154
|
removes it. If you don't use Claude Code, this is skipped — nothing touches `~/.claude`.
|
|
155
155
|
|
|
156
|
-
After installing the hooks it also offers (opt-in) to add a small block to `~/.tmux.conf` so each tmux
|
|
157
|
-
window tab shows a live Claude status dot (working / needs you / done) — the same signal as the inbox, on
|
|
158
|
-
your terminal. Decline it and nothing is changed; the block is marked and easy to remove later.
|
|
159
|
-
|
|
160
156
|
### start flags
|
|
161
157
|
|
|
162
158
|
Flags override the config file for **one run** and never persist — handy for a quick try
|
package/README.zh-CN.md
CHANGED
|
@@ -141,10 +141,6 @@ Agent 收件箱和「需要你就推给你」靠的是 Claude Code 的生命周
|
|
|
141
141
|
注册六个 hook 事件(幂等;不动你自己的 hook)。`handmux setup` 也会问一句,首次在手机上打开收件箱时
|
|
142
142
|
还能一键开启。`handmux hooks uninstall` 移除它。不用 Claude Code 的话直接跳过——绝不碰 `~/.claude`。
|
|
143
143
|
|
|
144
|
-
装完 hook 后,它还会(opt-in)问一句要不要往 `~/.tmux.conf` 加一小段配置,让每个 tmux 窗页签也显示一个
|
|
145
|
-
实时的 Claude 状态点(进行中 / 需要你 / 已完成)——和收件箱同一个信号,直接画在你的终端上。拒绝则什么都不改;
|
|
146
|
-
这段配置带标记,以后想删很容易。
|
|
147
|
-
|
|
148
144
|
### start 参数
|
|
149
145
|
|
|
150
146
|
参数只对**这一次运行**覆盖配置文件、绝不落盘——适合不动已存配置就快速试一下
|
package/bin/handmux.js
CHANGED
|
@@ -32,7 +32,8 @@ import { readState, clearState, isAlive, pocketHome, logPath, configPath, claude
|
|
|
32
32
|
import { runSetup } from '../src/cli/setupWizard.js';
|
|
33
33
|
import { hooksStatus, installHooks, uninstallHooks } from '../src/cli/claudeHooks.js';
|
|
34
34
|
import { codexHooksStatus, installCodexHooks, uninstallCodexHooks } from '../src/cli/codexHooks.js';
|
|
35
|
-
import {
|
|
35
|
+
import { statusLineStatus, installStatusLine, uninstallStatusLine, composeHint } from '../src/cli/statusLine.js';
|
|
36
|
+
import { claudeUsagePath } from '../src/usage.js';
|
|
36
37
|
import { probe } from '../src/cli/probe.js';
|
|
37
38
|
import { notifyUpdate, runUpdateCheck, PKG_NAME } from '../src/cli/updateCheck.js';
|
|
38
39
|
import { t, initLocale, setLocale } from '../src/cli/i18n/index.js';
|
|
@@ -319,33 +320,38 @@ async function setupCmd() {
|
|
|
319
320
|
const offerHooks = hooksStatus(HOME) === 'absent' || codexHooksStatus(HOME) === 'absent';
|
|
320
321
|
if (offerHooks && await confirm(t('hooks.confirmEnable'))) {
|
|
321
322
|
installAgentHooks();
|
|
322
|
-
await maybeOfferTmuxDot();
|
|
323
323
|
}
|
|
324
|
+
await maybeOfferStatusLine();
|
|
324
325
|
if (await confirm(t('setup.confirmStart'))) { Object.assign(flags, cfg); return start(); }
|
|
325
326
|
console.log(t('setup.later'));
|
|
326
327
|
}
|
|
327
328
|
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
329
|
+
// Offer to enable the Claude statusLine usage capturer — it feeds the phone Usage page's 5h/weekly bars
|
|
330
|
+
// (Claude Code's statusLine stdin is the only documented local source of those %). Opt-in and NON-
|
|
331
|
+
// DESTRUCTIVE: auto-installs only when there's no statusLine yet; if the user already has one we print a
|
|
332
|
+
// one-line compose snippet and change nothing. Codex needs no capturer — its rollout already carries the
|
|
333
|
+
// quota. No-op when Claude Code isn't installed or ours is already in place.
|
|
334
|
+
async function maybeOfferStatusLine() {
|
|
335
|
+
const st = statusLineStatus(HOME);
|
|
336
|
+
if (st === 'no-claude' || st === 'ours') return;
|
|
337
|
+
if (st === 'foreign') {
|
|
338
|
+
// Deploy the capturer script (doesn't touch their statusLine) so the compose one-liner is runnable.
|
|
339
|
+
installStatusLine(HOME, { srcDir: HOOKS_SRC, usageFile: claudeUsagePath(HOME) });
|
|
340
|
+
console.log(t('statusline.foreignHint'));
|
|
341
|
+
console.log(' ' + composeHint(HOME, { usageFile: claudeUsagePath(HOME) }));
|
|
336
342
|
return;
|
|
337
343
|
}
|
|
338
|
-
if (await confirm(t('
|
|
339
|
-
|
|
340
|
-
console.log(t('
|
|
341
|
-
console.log(t('
|
|
344
|
+
if (await confirm(t('statusline.confirmEnable'))) {
|
|
345
|
+
installStatusLine(HOME, { srcDir: HOOKS_SRC, usageFile: claudeUsagePath(HOME) });
|
|
346
|
+
console.log(t('statusline.installed'));
|
|
347
|
+
console.log(t('statusline.reload'));
|
|
342
348
|
}
|
|
343
349
|
}
|
|
344
350
|
|
|
345
351
|
// Install the inbox hooks for every coding agent present on this host (Claude Code, Codex — the state file
|
|
346
352
|
// is shared, entries are agent-tagged). Each is opt-in by the mere presence of its config dir. Prints a
|
|
347
|
-
// per-agent line and returns how many were wired, so callers can gate the
|
|
348
|
-
//
|
|
353
|
+
// per-agent line and returns how many were wired, so callers can gate the "reload" hint. Codex's single
|
|
354
|
+
// `notify` slot may already hold the user's OWN program — we never clobber it, we warn.
|
|
349
355
|
function installAgentHooks() {
|
|
350
356
|
let installed = 0;
|
|
351
357
|
if (hooksStatus(HOME) !== 'no-claude') {
|
|
@@ -371,12 +377,13 @@ async function hooksCmd() {
|
|
|
371
377
|
return;
|
|
372
378
|
}
|
|
373
379
|
if (installAgentHooks() > 0) console.log(t('hooks.installedHint'));
|
|
374
|
-
await
|
|
380
|
+
await maybeOfferStatusLine();
|
|
375
381
|
return;
|
|
376
382
|
}
|
|
377
383
|
if (sub === 'uninstall') {
|
|
378
384
|
uninstallHooks(HOME);
|
|
379
385
|
uninstallCodexHooks(HOME);
|
|
386
|
+
uninstallStatusLine(HOME);
|
|
380
387
|
console.log(t('hooks.removed'));
|
|
381
388
|
return;
|
|
382
389
|
}
|
|
@@ -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
|
@@ -27,8 +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
|
-
let noop = false; // set when a codex PostToolUse resume had nothing to un-stick → skip the state + dot write
|
|
32
30
|
|
|
33
31
|
// Synchronous nap without busy-spinning (the hook runs async, so a few ms is free). SharedArrayBuffer
|
|
34
32
|
// may be unavailable in odd runtimes — fall back to a tiny busy loop so the lock retry still paces.
|
|
@@ -48,21 +46,21 @@ function update() {
|
|
|
48
46
|
// idle after a resting state (done/needs/nothing) is just the "still waiting" reminder → drop and
|
|
49
47
|
// leave the file as it was (recording it would bump ts and re-surface an already-cleared 已完成).
|
|
50
48
|
// idle after a WORKING turn (prompt/resume) that never got a Stop = an ESC interrupt / walk-away —
|
|
51
|
-
// no Stop hook fires there, so idle is the only signal the turn ended. Without this the
|
|
52
|
-
//
|
|
53
|
-
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]; }
|
|
54
52
|
else return; // resting → drop without writing
|
|
55
53
|
} else if (src === 'end') {
|
|
56
54
|
delete obj[pane]; // SessionEnd (clean exit) → drop the pane
|
|
57
55
|
} else if (src === 'resume' && agent === 'codex') {
|
|
58
56
|
// Codex fires PostToolUse on EVERY tool call, so its resume exists purely to un-stick a pane from 需要你
|
|
59
57
|
// back to 进行中 after the user approved a PermissionRequest. Apply it ONLY as that transition — a mid-
|
|
60
|
-
// turn tool call (pane already 进行中 / 已完成) is a no-op, so we don't rewrite the entry
|
|
61
|
-
//
|
|
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.
|
|
62
60
|
const prev = obj[pane];
|
|
63
61
|
const prevPerm = prev && (prev.src === 'permreq'
|
|
64
62
|
|| (prev.src === 'notify' && (prev.payload || {}).notification_type === 'permission_prompt'));
|
|
65
|
-
if (!prevPerm) {
|
|
63
|
+
if (!prevPerm) { return; }
|
|
66
64
|
obj[pane] = { ts: Number(ts) || 0, src, host, payload, agent };
|
|
67
65
|
} else {
|
|
68
66
|
// agent tag lets the server dispatch classify + liveness per agent (Codex passes 'codex'); omitted for
|
|
@@ -85,21 +83,3 @@ for (let i = 0; i < 60 && !held; i++) { // ~0.9s budget, t
|
|
|
85
83
|
}
|
|
86
84
|
try { update(); } catch { /* best effort */ }
|
|
87
85
|
if (held) { try { fs.unlinkSync(lock); } catch { /* ignore */ } }
|
|
88
|
-
|
|
89
|
-
// tmux 页签状态色点(事件驱动):把本次事件的状态色 markup 写进该 pane 所在窗的 @claude_dot 选项。
|
|
90
|
-
// tmux 的 window-status-format 只读 #{@claude_dot}(纯查表、不跑任何 shell),所以稳态零重绘——只有
|
|
91
|
-
// 状态真变化(即本 hook 触发的这一刻)才写一次、才重绘一次,不轮询、不卡。详见 tmux/README.md。
|
|
92
|
-
// best-effort + 1s 超时:不在 tmux / tmux 不可达都静默忽略,永不阻塞或失败 Claude。
|
|
93
|
-
function claudeDot(s, p) {
|
|
94
|
-
if (s === 'stop') return '#[fg=#2e7d46]●#[default] '; // 已完成 绿
|
|
95
|
-
if (s === 'prompt' || s === 'resume') return '#[fg=#2f6fed,blink]●#[default] '; // 进行中 蓝闪
|
|
96
|
-
if (s === 'permreq') return '#[fg=#e0a020]●#[default] '; // 需要你 橙
|
|
97
|
-
if (s === 'notify' && p && p.notification_type === 'permission_prompt') return '#[fg=#e0a020]●#[default] ';
|
|
98
|
-
return null; // 其它无法分类 → 不动该窗的点(end 在下方单独清空)
|
|
99
|
-
}
|
|
100
|
-
try {
|
|
101
|
-
const dot = (src === 'end' || cleared) ? '' : claudeDot(src, payload);
|
|
102
|
-
if (dot !== null && !noop) {
|
|
103
|
-
require('node:child_process').execFileSync('tmux', ['set-option', '-w', '-t', pane, '@claude_dot', dot], { stdio: 'ignore', timeout: 1000 });
|
|
104
|
-
}
|
|
105
|
-
} 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
|
],
|