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.
@@ -1,90 +0,0 @@
1
- // Opt-in wiring of the per-window Claude status dot into ~/.tmux.conf. The handmux hook writes a colour
2
- // markup into each window's `@claude_dot` option on every Claude event (see hooks/handmux-write.cjs); tmux
3
- // only RENDERS it if `window-status-format` references `#{@claude_dot}`. This module appends that display
4
- // config PLUS the two enhancements (cold-start seed + focus-auto-clear), and — crucially — installs the
5
- // scripts those enhancements need into a STABLE location so the wiring never breaks.
6
- //
7
- // Why a stable location: the scripts must be referenced by absolute path from ~/.tmux.conf. Pointing that
8
- // at a repo checkout breaks the moment the repo moves or is renamed (the original failure: a stale
9
- // `…/tmux-web/tmux/claude-tab-seen.sh` returned 127 on every window switch). So, exactly like the Claude
10
- // hook copies its notify script into ~/.claude/hooks/, we copy the tmux scripts into ~/.handmux/tmux/ and
11
- // the conf block references THAT — a path tied to $HOME, not to where handmux happens to be installed.
12
- import fs from 'node:fs';
13
- import path from 'node:path';
14
- import { homedir } from 'node:os';
15
- import { fileURLToPath } from 'node:url';
16
- import { pocketHome } from './state.js';
17
-
18
- const here = path.dirname(fileURLToPath(import.meta.url));
19
- const PKG_TMUX = path.resolve(here, '../../tmux'); // server/tmux — shipped in the package ("files": [...,"tmux"])
20
- const SCRIPTS = ['claude-tab-seed.py', 'claude-tab-seen.sh'];
21
-
22
- const BEGIN = '# >>> handmux claude-dot >>>';
23
- const END = '# <<< handmux claude-dot <<<';
24
-
25
- export function tmuxConfPath(home = homedir()) { return path.join(home, '.tmux.conf'); }
26
-
27
- // Where the seed/seen scripts are installed — stable across repo moves / renames / a global npm install.
28
- export function tmuxScriptsDir(home = homedir()) { return path.join(pocketHome(home), 'tmux'); }
29
-
30
- // The marked block we append. `status-style` resets the default green status bar (which would swallow the
31
- // green "done" dot) to neutral grey; the window-status-format lines inject `#{@claude_dot}` before the
32
- // window name; the seed paints existing windows on (re)load; the two hooks clear a "done" dot when you
33
- // actually focus that window. All script references use the stable ~/.handmux/tmux path (see top comment).
34
- export function dotBlock(home = homedir()) {
35
- const dir = tmuxScriptsDir(home);
36
- const seed = path.join(dir, 'claude-tab-seed.py');
37
- const seen = path.join(dir, 'claude-tab-seen.sh');
38
- return [
39
- BEGIN,
40
- '# Per-window Claude status dot (live via the handmux hook) + cold-start seed + focus-auto-clear.',
41
- `# Scripts live in ${dir} (installed by handmux). Delete this whole block to disable.`,
42
- "set -g status-style 'bg=colour236,fg=colour250'",
43
- "set -g window-status-current-style 'bg=colour248,fg=colour234,bold'",
44
- "set -g window-status-format '#{@claude_dot}#I:#W#{?window_flags,#{window_flags}, }'",
45
- "set -g window-status-current-format '#{@claude_dot}#I:#W#{?window_flags,#{window_flags}, }'",
46
- 'set -g focus-events on',
47
- `run-shell -b '${seed}'`,
48
- `set-hook -g after-select-window 'run-shell -b "${seen} #{window_id}"'`,
49
- `set-hook -g pane-focus-in 'run-shell -b "${seen} #{window_id}"'`,
50
- END,
51
- '',
52
- ].join('\n');
53
- }
54
-
55
- // Pure: does this ~/.tmux.conf text already wire the dot? Keys on `@claude_dot` so a user who hand-rolled
56
- // their own is recognised as configured and never nagged or double-installed.
57
- export function dotConfigured(text) {
58
- return typeof text === 'string' && text.includes('@claude_dot');
59
- }
60
-
61
- function readConf(home) {
62
- try { return fs.readFileSync(tmuxConfPath(home), 'utf8'); } catch { return null; }
63
- }
64
-
65
- // 'present' if the dot is already wired, else 'absent'. A missing ~/.tmux.conf is 'absent'.
66
- export function tmuxDotStatus(home = homedir()) {
67
- return dotConfigured(readConf(home) || '') ? 'present' : 'absent';
68
- }
69
-
70
- // Copy the seed/seen scripts into the stable ~/.handmux/tmux dir (executable). Idempotent overwrite, so a
71
- // reinstall after an upgrade refreshes them. Returns the dir. srcDir defaults to the shipped server/tmux.
72
- export function installTmuxScripts(home = homedir(), srcDir = PKG_TMUX) {
73
- const dir = tmuxScriptsDir(home);
74
- fs.mkdirSync(dir, { recursive: true });
75
- for (const f of SCRIPTS) fs.copyFileSync(path.join(srcDir, f), path.join(dir, f));
76
- for (const f of SCRIPTS) { try { fs.chmodSync(path.join(dir, f), 0o755); } catch { /* best effort */ } }
77
- return dir;
78
- }
79
-
80
- // Install the scripts to ~/.handmux/tmux and append the marked block to ~/.tmux.conf (creating it if
81
- // absent), idempotently. Returns { status }: 'present' if already wired (no-op), 'installed' if just added.
82
- // Never touches the user's own lines — appends after them, separated by a newline.
83
- export function installTmuxDot(home = homedir(), { srcDir = PKG_TMUX } = {}) {
84
- const existing = readConf(home);
85
- if (dotConfigured(existing || '')) return { status: 'present' };
86
- installTmuxScripts(home, srcDir);
87
- const prefix = existing && !existing.endsWith('\n') ? existing + '\n' : (existing || '');
88
- fs.writeFileSync(tmuxConfPath(home), `${prefix}\n${dotBlock(home)}`);
89
- return { status: 'installed' };
90
- }
package/tmux/README.md DELETED
@@ -1,77 +0,0 @@
1
- # tmux 页签 Claude 状态标记(事件驱动)
2
-
3
- 给本机 tmux 的**每个 window 页签**前缀一个状态色点,SSH attach 时一眼看到哪个窗在跑、跑完了、还是在等你。**色值与手机端收件箱一致**(`web/src/styles.css` 的 `.inbox-dot`):
4
-
5
- | 状态 | 色点 | 何时变 |
6
- |---|---|---|
7
- | 需要你 | 🟠 橙 `#e0a020` | 出现权限/选择弹框(`permreq` / `permission_prompt`) |
8
- | 进行中 | 🔵 蓝 `#2f6fed` **闪** | 你发了 prompt / 答完选择(`prompt` / `resume`)。`blink` 需终端支持(iTerm2) |
9
- | 已完成 | 🟢 绿 `#2e7d46` | Claude 结束一轮(`stop`) |
10
- | 无 claude / 会话结束 | (无点) | `end` 清空 |
11
-
12
- 选中的窗页签用**浅灰底 + 深色粗体**(`window-status-current-style`)明显区分。
13
-
14
- ## 架构:事件驱动,不是轮询(这是重点)
15
-
16
- ```
17
- Claude 事件 → hook(handmux-write.cjs)→ tmux set-option -w @claude_dot '#[fg=…]●'
18
- ↓
19
- window-status-format = '#{@claude_dot}#I:#W' ← 纯查表,不跑任何 shell
20
- ```
21
-
22
- - **写**:`server/hooks/handmux-write.cjs`(Claude hook,本来就在每个事件时跑、写状态文件)在末尾顺手把这次事件对应的色点 markup 写进**该 pane 所在窗**的 `@claude_dot` 选项(`set-option -w -t <pane>` 能直接定位到窗)。best-effort + 1s 超时,不在 tmux 就静默忽略,永不阻塞 Claude。
23
- - **显示**:`~/.tmux.conf` 的 `window-status-format` 只引用 `#{@claude_dot}`(tmux 会解释里面的 `#[fg=…]` 颜色,实测真彩 hex 与手机端逐字节一致)。**格式里没有 `#()`,所以每次状态栏重绘零子进程、零开销。**
24
- - **填底**:`tmux/claude-tab-seed.py` 一次性把当前所有 claude 窗的点按状态文件填上 —— tmux 启动 / `source-file` 时由 `~/.tmux.conf` 的 `run-shell` 调一次,重新部署后手动跑一次。之后全交给 hook。
25
-
26
- **只有状态真变化(hook 触发那一刻)才写一次 `@claude_dot` → 才重绘一次。稳态零重绘。**
27
-
28
- ## 踩过的坑(别再走这两条死路)
29
-
30
- 这套方案是第三版,前两版把整个 tmux 拖到「整屏卡 + 光标狂闪」,根因都已实测坐实:
31
-
32
- 1. **不要在 `window-status-format` 里放 `#(脚本 …)` 主动查询。** tmux 会对【每个客户端 × 每个窗 × 每次重绘】都跑一遍脚本(spawn jq / list-panes),十几个 SSH 客户端一起跑必卡;而且 `#()` 每次返回都触发重绘 → 一直闪。**改成事件驱动:hook 推、状态栏只查表。**(隔离实测:`#{@claude_dot}` 格式空闲 3 秒输出 **0 字节**;`#()` 格式是几十~上百字节/秒不停。)
33
- 2. **不要为了存“看过/状态”在状态栏渲染路径里写 tmux 选项。** 实测**写任何 tmux 选项都会强制整条状态栏重绘**(空闲 2s 输出 0 字节,连打 12 次 `set-option` 输出 1593 字节)。在渲染路径里每帧写 = 无休止重绘 = 卡 + 光标闪。写选项只能由**低频的事件**(hook)来做,稳态绝不写。
34
-
35
- ## “已完成”绿点什么时候消失
36
-
37
- 两条途径,都不在渲染路径里、都只在你的动作那一刻各跑一次,不卡:
38
-
39
- 1. **看过即清** —— 你切到/聚焦那个窗时,`tmux/claude-tab-seen.sh` 把该窗的绿点清掉(进行中蓝、需要你橙是当前态,不清)。由两个 tmux 钩子触发:
40
- - `after-select-window`:会话内换 window(点页签 / prefix+数字 / next-window)。
41
- - `pane-focus-in`(需 `focus-events on`):切到别的 SSH 终端、让某个窗重新获得焦点 —— 补上 after-select-window 覆盖不到的「跨终端切会话」。实测 iTerm2 等会上报焦点,此钩子能触发。
42
- 2. **接着干自然清** —— 你在该窗发下一条 prompt → `prompt` 事件 → 点变蓝(进行中)。
43
-
44
- 清掉后若 Claude 再结束一轮,hook 会重新把绿点写回来;只瞥不切、也不操作,则绿点保留(它确实还停在已完成)。
45
-
46
- ## 安装
47
-
48
- > 需要 tmux ≥ 3.0 —— `window-status-format` 里引用用户选项 `#{@claude_dot}` 自 3.0 起才支持;更老的
49
- > tmux 不会显示色点(会忽略或原样吐出 `#{@claude_dot}`)。
50
-
51
- `handmux hooks install`(交互式)会把整套**自动**装好,无需手动改 conf:
52
-
53
- 1. **hook(写点)**:脚本拷进 `~/.claude/hooks/`、注册事件。每次现调,改了即生效,已在跑的 claude 无需重启。
54
- 2. **显示 + seed + 看过即清**:把 `claude-tab-seed.py` / `claude-tab-seen.sh` 拷进 **`~/.handmux/tmux/`**,并往 `~/.tmux.conf` 末尾追加下面这段**带标记的块**(引用那个稳定路径):
55
-
56
- ```tmux
57
- # >>> handmux claude-dot >>>
58
- set -g status-style 'bg=colour236,fg=colour250' # 默认 bg=green 会吞掉绿点,改中性深灰
59
- set -g window-status-current-style 'bg=colour248,fg=colour234,bold' # 选中的窗:浅灰底+深字
60
- set -g window-status-format '#{@claude_dot}#I:#W#{?window_flags,#{window_flags}, }'
61
- set -g window-status-current-format '#{@claude_dot}#I:#W#{?window_flags,#{window_flags}, }'
62
- set -g focus-events on
63
- run-shell -b '~/.handmux/tmux/claude-tab-seed.py'
64
- set-hook -g after-select-window 'run-shell -b "~/.handmux/tmux/claude-tab-seen.sh #{window_id}"'
65
- set-hook -g pane-focus-in 'run-shell -b "~/.handmux/tmux/claude-tab-seen.sh #{window_id}"'
66
- # <<< handmux claude-dot <<<
67
- ```
68
-
69
- `tmux source-file ~/.tmux.conf` 生效。这是**共享 tmux server** 的全局设置,所有 attach 的客户端(含 PC 本机)都会一起变;手机 web 不受影响(它抓的是 pane 内容,不是状态栏)。
70
-
71
- > 脚本走 **`~/.handmux/tmux/`**(实际写入的是展开后的绝对路径,随 `$HOME`、不随 handmux 装在哪),所以仓库搬家/改名都不会断 —— 这正是早期 `…/tmux-web/tmux/…` 写死 repo 路径后 `returned 127` 的教训。已自己手写过配置(conf 里已含 `@claude_dot`)的人会被识别为「已配置」,不会被覆盖。
72
-
73
- ## 调一调 / 卸载
74
-
75
- - **颜色**:同时改 `handmux-write.cjs` 的 `claudeDot()` 和 `claude-tab-seed.py` 的 `DOT`(两处保持一致),hex 同手机端。
76
- - **不想要「进行中」闪**:两处把 `,blink` 删掉。
77
- - **卸载**:删 `~/.tmux.conf` 里 `# >>> handmux claude-dot >>>` 到 `# <<< handmux claude-dot <<<` 整段 + `tmux source-file`;清残留点 `for w in $(tmux list-windows -a -F '#{window_id}'); do tmux set-option -uw -t $w @claude_dot; done`;想彻底停止写点,直接 `handmux hooks uninstall`(移除 hook,不再写 `@claude_dot`)。
@@ -1,72 +0,0 @@
1
- #!/usr/bin/env python3
2
- # claude-tab-seed.py —— 一次性把【当前所有 Claude 窗】的状态色点写进各窗的 @claude_dot 选项。
3
- #
4
- # 平时这些点由 Claude hook(server/hooks/handmux-write.cjs)在状态变化那一刻事件驱动地写,不轮询、
5
- # 不卡。但 hook 只在“有新事件”时写,所以本脚本负责冷启动填底:
6
- # - tmux 启动 / `source-file ~/.tmux.conf` 时由 `run-shell` 调一次;
7
- # - 重新部署后手动跑一次。
8
- # 之后就交给 hook。整个机制零 `#()`、零轮询 —— 详见 tmux/README.md。
9
- #
10
- # 色值/分类与 hook 里的 claudeDot 保持一致;与手机端 web/src/styles.css 的 .inbox-dot 同色。
11
-
12
- import json, os, subprocess, sys
13
-
14
- F = os.environ.get("CLAUDE_STATE_FILE",
15
- os.path.expanduser("~/.handmux/claude-state.json"))
16
- try:
17
- state = json.load(open(F))
18
- except Exception:
19
- sys.exit(0)
20
-
21
- def tmux(*a):
22
- try:
23
- return subprocess.run(["tmux", *a], capture_output=True, text=True, timeout=5).stdout
24
- except Exception:
25
- return ""
26
-
27
- def classify(e):
28
- s = e.get("src")
29
- if s == "stop": return "done"
30
- if s in ("prompt", "resume"): return "working"
31
- if s == "permreq": return "needs"
32
- if s == "notify" and (e.get("payload") or {}).get("notification_type") == "permission_prompt":
33
- return "needs"
34
- return None
35
-
36
- RANK = {"needs": 3, "done": 2, "working": 1}
37
- DOT = {
38
- "needs": "#[fg=#e0a020]●#[default] ", # 橙
39
- "done": "#[fg=#2e7d46]●#[default] ", # 绿
40
- "working": "#[fg=#2f6fed,blink]●#[default] ", # 蓝闪
41
- }
42
-
43
- # 冷启动只给【正在跑某个 agent】的窗补点。Claude 的 pane_current_command 是 "claude";Codex 的 PATH 入口
44
- # 是个 node 启动器,所以是 "node"(与 server 端 liveness 的 procNames 一致)。有状态条目 + 命令属于 agent
45
- # 才补点,避免给回到 shell 的窗残留脏点。
46
- AGENT_CMDS = {"claude", "codex", "node"}
47
-
48
- # window_id -> 最高优先级 kind
49
- top = {}
50
- for line in tmux("list-panes", "-a", "-F", "#{pane_current_command} #{pane_id} #{window_id}").splitlines():
51
- parts = line.split()
52
- if len(parts) < 3 or parts[0] not in AGENT_CMDS:
53
- continue
54
- _, pane, win = parts[0], parts[1], parts[2]
55
- e = state.get(pane)
56
- if not e:
57
- continue
58
- k = classify(e)
59
- if not k:
60
- continue
61
- if RANK[k] > RANK.get(top.get(win, ""), 0):
62
- top[win] = k
63
-
64
- # 写当前点;并清掉已不再有 claude 状态的窗的残留点
65
- for line in tmux("list-windows", "-a", "-F", "#{window_id}").splitlines():
66
- win = line.strip()
67
- if not win:
68
- continue
69
- if win in top:
70
- tmux("set-option", "-w", "-t", win, "@claude_dot", DOT[top[win]])
71
- elif tmux("show-options", "-wv", "-t", win, "@claude_dot").strip():
72
- tmux("set-option", "-uw", "-t", win, "@claude_dot")
@@ -1,14 +0,0 @@
1
- #!/bin/bash
2
- # claude-tab-seen.sh <window_id> —— 你切到/聚焦某个窗时调用:若该窗当前是「已完成」绿点,清掉它
3
- # (看过即清)。进行中(蓝)/需要你(橙)是当前态、不清。
4
- #
5
- # 由 ~/.tmux.conf 的 after-select-window / pane-focus-in 两个钩子触发 —— 只在你切窗、切 pane、
6
- # 切终端这些【用户动作】时各跑一次,不在状态栏渲染路径里,所以不轮询、不卡。清绿点会写一次
7
- # @claude_dot → 触发一次重绘,这是“状态真变了(你看过了)”,正是该重绘的时刻。
8
- #
9
- # 之后若 Claude 再结束一轮,hook 会重新把绿点写回来;只瞥不切则绿点保留。
10
- W=$1
11
- [ -n "$W" ] || exit 0
12
- case "$(tmux show-options -wv -t "$W" @claude_dot 2>/dev/null)" in
13
- *2e7d46*) tmux set-option -w -t "$W" @claude_dot '' 2>/dev/null ;; # 绿(已完成)→ 清(设空串)
14
- esac