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 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 { tmuxDotStatus, installTmuxDot, tmuxConfPath } from '../src/cli/tmuxConf.js';
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
- // The per-window tmux status dot is the natural companion to the inbox hooks: the hook already writes a
329
- // colour into each window's `@claude_dot` on every Claude event, but tmux only SHOWS it if
330
- // `window-status-format` references it — otherwise it's a silent no-op. Offer to add that display block to
331
- // ~/.tmux.conf (opt-in, idempotent). Skip when it's already wired (ours or hand-rolled). Non-TTY: just hint.
332
- async function maybeOfferTmuxDot() {
333
- if (tmuxDotStatus(HOME) !== 'absent') return;
334
- if (!process.stdin.isTTY) {
335
- console.log(t('tmuxdot.tip', { conf: tmuxConfPath(HOME) }));
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('tmuxdot.confirm'))) {
339
- installTmuxDot(HOME);
340
- console.log(t('tmuxdot.added', { path: tmuxConfPath(HOME) }));
341
- console.log(t('tmuxdot.apply'));
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 tmux-dot offer / the "reload"
348
- // hint. Codex's single `notify` slot may already hold the user's OWN program — we never clobber it, we warn.
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 maybeOfferTmuxDot();
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
+ }
@@ -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 进行中 dot
52
- // sticks forever; treat it as a soft end and clear the pane (and its @claude_dot, below).
53
- if (prevSrc === 'prompt' || prevSrc === 'resume') { delete obj[pane]; cleared = true; }
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 or repaint the
61
- // dot on every command (the load Claude's matcher avoids). Claude's resume — no agent arg — is unaffected.
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) { noop = true; return; }
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.6.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
  ],