handmux 0.5.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +303 -0
  3. package/README.zh-CN.md +285 -0
  4. package/bin/handmux.js +417 -0
  5. package/hooks/handmux-notify.sh +20 -0
  6. package/hooks/handmux-write.cjs +92 -0
  7. package/package.json +52 -0
  8. package/public/assets/index-BN-IwtP6.css +32 -0
  9. package/public/assets/index-BUQ0R83h.js +157 -0
  10. package/public/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  11. package/public/fonts/TWUnifont.woff2 +0 -0
  12. package/public/icons/apple-touch-icon.png +0 -0
  13. package/public/icons/badge-96.png +0 -0
  14. package/public/icons/icon-192.png +0 -0
  15. package/public/icons/icon-512.png +0 -0
  16. package/public/icons/logo.svg +32 -0
  17. package/public/index.html +105 -0
  18. package/public/manifest.webmanifest +37 -0
  19. package/public/offline.html +50 -0
  20. package/public/sw.js +117 -0
  21. package/src/.gitkeep +0 -0
  22. package/src/appName.js +23 -0
  23. package/src/asr/iflyConfig.js +10 -0
  24. package/src/asr/iflySign.js +16 -0
  25. package/src/auth.js +30 -0
  26. package/src/claudeEvents.js +212 -0
  27. package/src/cli/cfNamed.js +5 -0
  28. package/src/cli/claudeHooks.js +116 -0
  29. package/src/cli/cloudflareUrl.js +9 -0
  30. package/src/cli/cloudflared.js +53 -0
  31. package/src/cli/drivers.js +59 -0
  32. package/src/cli/options.js +169 -0
  33. package/src/cli/probe.js +16 -0
  34. package/src/cli/qr.js +34 -0
  35. package/src/cli/service.js +98 -0
  36. package/src/cli/setupWizard.js +248 -0
  37. package/src/cli/sshTunnel.js +12 -0
  38. package/src/cli/state.js +42 -0
  39. package/src/cli/supervisor.js +172 -0
  40. package/src/cli/tmuxConf.js +90 -0
  41. package/src/cli/tmuxVersion.js +49 -0
  42. package/src/cli/tunlite.js +22 -0
  43. package/src/config.js +6 -0
  44. package/src/docPath.js +46 -0
  45. package/src/docs.js +222 -0
  46. package/src/git.js +185 -0
  47. package/src/httpApi.js +546 -0
  48. package/src/previewServer.js +182 -0
  49. package/src/previews.js +118 -0
  50. package/src/push.js +121 -0
  51. package/src/server.js +97 -0
  52. package/src/staticCache.js +8 -0
  53. package/src/tmux/commands.js +223 -0
  54. package/src/trimCapture.js +28 -0
  55. package/src/uploadTypes.js +28 -0
  56. package/tmux/README.md +77 -0
  57. package/tmux/claude-tab-seed.py +67 -0
  58. package/tmux/claude-tab-seen.sh +14 -0
@@ -0,0 +1,223 @@
1
+ import { execFile } from 'node:child_process';
2
+ import os from 'node:os';
3
+
4
+ export const isPaneId = (s) => typeof s === 'string' && /^%\d+$/.test(s);
5
+ export const isWindowId = (s) => typeof s === 'string' && /^@\d+$/.test(s);
6
+ export const isSessionId = (s) => typeof s === 'string' && /^\$\d+$/.test(s);
7
+
8
+ // Letters, digits and hyphens only (1-16 chars). A positive allowlist sidesteps tmux's target
9
+ // syntax entirely (no '.'/':' separators, no whitespace, no control chars) and is trivial to
10
+ // mirror on the client. Only NEW session names are validated — binding an existing PC-made name
11
+ // (which may contain spaces) checks existence first and never calls this.
12
+ export const isValidSessionName = (s) =>
13
+ typeof s === 'string' && /^[A-Za-z0-9-]{1,16}$/.test(s);
14
+
15
+ // Optional startup command run in a freshly-created window/session (e.g. "claude"). It's typed via
16
+ // send-keys + Enter, so it must be a single line: reject control chars (newline/CR/tab included) and
17
+ // cap the length. No shell-arg restriction — it runs in the new shell exactly as if typed, same trust
18
+ // model as the existing sendText. Empty means "no command" and is handled by the caller, not here.
19
+ export const isValidStartupCmd = (s) =>
20
+ typeof s === 'string' && s.length > 0 && s.length <= 200 && [...s].every((c) => { const n = c.charCodeAt(0); return n >= 0x20 && n !== 0x7f; });
21
+
22
+ export function runTmux(args) {
23
+ return new Promise((resolve, reject) => {
24
+ execFile('tmux', args, { maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
25
+ if (err) reject(new Error(stderr?.toString() || err.message));
26
+ else resolve(stdout.toString());
27
+ });
28
+ });
29
+ }
30
+
31
+ const lines = (out) => out.split('\n').filter((l) => l.length > 0);
32
+
33
+ export async function listSessions() {
34
+ const out = await runTmux(['list-sessions', '-F', '#{session_id}\t#{session_name}']);
35
+ return lines(out).map((l) => { const [id, name] = l.split('\t'); return { id, name }; });
36
+ }
37
+
38
+ export async function listWindows(sessionId) {
39
+ const out = await runTmux(['list-windows', '-t', sessionId, '-F', '#{window_id}\t#{window_name}\t#{window_active}\t#{window_panes}']);
40
+ return lines(out).map((l) => {
41
+ const [id, name, active, panes] = l.split('\t');
42
+ return { id, name, active: active === '1', panes: Number(panes) };
43
+ });
44
+ }
45
+
46
+ export async function listPanes(windowId) {
47
+ const out = await runTmux(['list-panes', '-t', windowId, '-F', '#{pane_id}\t#{pane_active}\t#{pane_width}\t#{pane_height}\t#{pane_current_command}\t#{pane_current_path}']);
48
+ return lines(out).map((l) => {
49
+ const [id, active, width, height, command, cwd] = l.split('\t');
50
+ return { id, active: active === '1', width: Number(width), height: Number(height), command, cwd };
51
+ });
52
+ }
53
+
54
+ // All live pane ids across every session — used to reconcile the in-memory Claude paneState against
55
+ // reality. A hard-killed pane fires no hook, so its last state would otherwise linger as a ghost.
56
+ export async function listPaneIds() {
57
+ return lines(await runTmux(['list-panes', '-a', '-F', '#{pane_id}']));
58
+ }
59
+
60
+ // Every live pane across all sessions WITH its foreground command AND its tmux location, in ONE call.
61
+ // getStates uses this to (a) catch "pane alive but Claude exited" (cmd flips from 'claude' back to the
62
+ // shell — a hard kill / crash / Ctrl-C-out fires no SessionEnd yet the shell keeps the pane, so it
63
+ // still EXISTS) and (b) resolve each recorded pane to its session/window for the inbox roster without a
64
+ // per-pane display-message. The hook only records the pane id; location comes from here, always fresh.
65
+ export async function listLivePanes() {
66
+ return lines(await runTmux(['list-panes', '-a', '-F',
67
+ '#{pane_id}\t#{pane_current_command}\t#{session_name}\t#{window_id}\t#{window_name}']))
68
+ .map((l) => {
69
+ const [id, cmd, session, window, windowName] = l.split('\t');
70
+ return { id, cmd, session, window, windowName };
71
+ });
72
+ }
73
+
74
+ // -N preserves trailing whitespace. Without it, capture-pane trims trailing cells at each line
75
+ // end — including background-colored padding — which both drops the right-hand part of a
76
+ // full-width highlight (Claude Code's sent-message bar) AND loses the SGR reset that closes the
77
+ // background, so the highlight bleeds onto the rows below when re-rendered. With -N the capture
78
+ // faithfully reproduces the pane, so the client can write it verbatim (see prepareSeed).
79
+ export async function capturePane(paneId, linesBack) {
80
+ return runTmux(['capture-pane', '-p', '-e', '-N', '-S', String(-Math.abs(linesBack)), '-t', paneId]);
81
+ }
82
+
83
+ // Size AND cursor in one display-message (capture-pane carries neither the cursor position nor its
84
+ // visibility — it snapshots cells only — so we read them here for the client to re-place xterm's own
85
+ // cursor onto Claude's input cell). cursor_x/cursor_y are 0-based, relative to the visible screen;
86
+ // cursor_flag is DECTCEM visibility (1 while Claude accepts input, 0 while it's working / in a dialog).
87
+ export async function paneInfo(paneId) {
88
+ const out = await runTmux(['display-message', '-p', '-t', paneId,
89
+ '#{pane_width}\t#{pane_height}\t#{cursor_x}\t#{cursor_y}\t#{cursor_flag}']);
90
+ const [width, height, cx, cy, cflag] = out.trim().split('\t');
91
+ return {
92
+ width: Number(width), height: Number(height),
93
+ cursorX: Number(cx), cursorY: Number(cy), cursorVisible: cflag === '1',
94
+ };
95
+ }
96
+
97
+ // Resolve a pane to its tmux session name + window for routing a notification back to it. The hook
98
+ // gives us only $TMUX_PANE; this turns "%263" into the session/window the phone navigates by.
99
+ export async function paneLocation(paneId) {
100
+ const out = await runTmux(['display-message', '-p', '-t', paneId,
101
+ '#{session_name}\t#{window_id}\t#{window_name}']);
102
+ const [session, windowId, windowName] = out.trim().split('\t');
103
+ return { session, window: windowId, windowName };
104
+ }
105
+
106
+ export async function sendText(paneId, text) {
107
+ await runTmux(['send-keys', '-t', paneId, '-l', '--', text]);
108
+ }
109
+
110
+ export async function sendEnter(paneId) {
111
+ await runTmux(['send-keys', '-t', paneId, 'Enter']);
112
+ }
113
+
114
+ export async function sendKey(paneId, key) {
115
+ await runTmux(['send-keys', '-t', paneId, key]);
116
+ }
117
+
118
+ // Force the window to an explicit width (and optionally height) so tmux reflows to it. This
119
+ // sets the window-size option to `manual`, so it sticks (and applies to every client on this
120
+ // window — including the PC) until restoreWindowSize is called. Pass rows = null to change
121
+ // only the column count and leave the height untouched.
122
+ export async function resizeWindow(windowId, cols, rows) {
123
+ const args = ['resize-window', '-t', windowId, '-x', String(cols)];
124
+ if (rows != null) args.push('-y', String(rows));
125
+ await runTmux(args);
126
+ }
127
+
128
+ // Hand sizing back to the attached clients (tmux default), so the PC's terminal dictates
129
+ // the window size again instead of the phone-sized grid left by resizeWindow.
130
+ export async function restoreWindowSize(windowId) {
131
+ await runTmux(['set-window-option', '-t', windowId, 'window-size', 'latest']);
132
+ }
133
+
134
+ // Resize just one pane's width inside its window (siblings absorb the difference; the window
135
+ // total is unchanged). Use this for a pane in a split — resizing the whole window would
136
+ // shrink every pane. A lone pane can't be resized this way (it fills the window).
137
+ export async function resizePane(paneId, cols) {
138
+ await runTmux(['resize-pane', '-t', paneId, '-x', String(cols)]);
139
+ }
140
+
141
+ // The window's exact pane arrangement, captured so "restore" can put a split back the way it
142
+ // was after resizePane changed the ratio. resizePane doesn't touch window size, so window-size
143
+ // latest alone can't undo it — select-layout with this string does.
144
+ export async function getWindowLayout(windowId) {
145
+ const out = await runTmux(['display-message', '-p', '-t', windowId, '#{window_layout}']);
146
+ return out.trim();
147
+ }
148
+
149
+ export async function applyWindowLayout(windowId, layout) {
150
+ await runTmux(['select-layout', '-t', windowId, layout]);
151
+ }
152
+
153
+ // -d: detached (the node server has no tty). -c cwd (defaults to $HOME): start dir for the session.
154
+ // -P -F prints the new session id so the caller can confirm/route. A duplicate name makes tmux
155
+ // error (runTmux rejects) — the route pre-checks and returns a clean 409 before reaching here.
156
+ // Self-guard the name even though the route validates first: this is an exported boundary to tmux,
157
+ // and a name with target-syntax chars ('$', ':', …) would create a hard-to-address session.
158
+ export async function newSession(name, cwd, cmd) {
159
+ if (!isValidSessionName(name)) throw new Error(`invalid session name: ${JSON.stringify(name)}`);
160
+ const out = await runTmux(['new-session', '-d', '-s', name, '-c', cwd || os.homedir(), '-P', '-F', '#{session_id}']);
161
+ const id = out.trim(); // e.g. "$7"
162
+ if (cmd) await runStartupCmd(id, cmd); // auto-run the startup command in the new session's first pane
163
+ return id;
164
+ }
165
+
166
+ // Type a startup command into a freshly-created window/session and press Enter — same path as a user
167
+ // typing it. The target ($id / @id) resolves to the new shell's active pane. Runs inside the shell (we
168
+ // don't pass it to new-window/new-session as the pane command) so the pane survives the command exiting.
169
+ async function runStartupCmd(target, cmd) {
170
+ await sendText(target, cmd);
171
+ await sendEnter(target);
172
+ }
173
+
174
+ // Read a pane's working directory, so a new window can open in the dir you're working in.
175
+ export async function paneCurrentPath(paneId) {
176
+ const out = await runTmux(['display-message', '-p', '-t', paneId, '#{pane_current_path}']);
177
+ return out.trim();
178
+ }
179
+
180
+ // -d: don't steal the active window from the PC (the phone navigates to it itself). -c: start dir
181
+ // (omitted when cwd is falsy → tmux uses the session default). -n: window name (omitted when falsy →
182
+ // tmux auto-names after the running command). -P -F prints the new window id.
183
+ export async function newWindow(sessionId, cwd, name, cmd) {
184
+ const args = ['new-window', '-d', '-t', sessionId, '-P', '-F', '#{window_id}'];
185
+ if (cwd) args.push('-c', cwd); // tmux accepts -c in any position; push mirrors resizeWindow's style
186
+ if (name) args.push('-n', name);
187
+ const id = (await runTmux(args)).trim(); // e.g. "@32"
188
+ if (cmd) await runStartupCmd(id, cmd); // auto-run the startup command in the new window's pane
189
+ return id;
190
+ }
191
+
192
+ // rename-session keeps the session's $id — only the name changes. Self-guard the name (exported
193
+ // boundary to tmux; a name with target-syntax chars would create a hard-to-address session). A
194
+ // duplicate name makes tmux error (runTmux rejects); the route pre-checks and returns a clean 409.
195
+ export async function renameSession(id, name) {
196
+ if (!isValidSessionName(name)) throw new Error(`invalid session name: ${JSON.stringify(name)}`);
197
+ await runTmux(['rename-session', '-t', id, name]);
198
+ }
199
+
200
+ // rename-window sets the name manually (and implicitly turns off that window's automatic-rename,
201
+ // so the chosen name sticks instead of tracking the running command — the expected tmux behavior).
202
+ export async function renameWindow(id, name) {
203
+ if (!isValidSessionName(name)) throw new Error(`invalid window name: ${JSON.stringify(name)}`);
204
+ await runTmux(['rename-window', '-t', id, name]);
205
+ }
206
+
207
+ // The number of windows in the window's session. The delete guard refuses to kill the last one
208
+ // (killing it would take the whole session with it).
209
+ export async function sessionWindowCount(id) {
210
+ const out = await runTmux(['display-message', '-p', '-t', id, '#{session_windows}']);
211
+ return Number(out.trim());
212
+ }
213
+
214
+ export async function killWindow(id) {
215
+ await runTmux(['kill-window', '-t', id]);
216
+ }
217
+
218
+ // Swap two windows' positions (indices) within a session. -d keeps the active window unchanged so
219
+ // reordering from the phone doesn't yank the PC's focus to the swapped window. The window ids are
220
+ // unchanged — only their order in list-windows flips.
221
+ export async function swapWindows(a, b) {
222
+ await runTmux(['swap-window', '-d', '-s', a, '-t', b]);
223
+ }
@@ -0,0 +1,28 @@
1
+ // capture-pane faithfully includes the empty grid below the cursor: a fresh shell draws its prompt
2
+ // at the TOP of the pane and leaves every row below it blank, so the capture is "prompt + a wall of
3
+ // blank rows". The phone bottom-anchors the capture, so that blank tail lands at the bottom of the
4
+ // screen and pushes the real content above the viewport — you open a session and see nothing, and
5
+ // have to scroll up (which pauses the live refresh) to find it.
6
+ //
7
+ // Fix at the source: cap the trailing run of blank rows so the last content row anchors near the
8
+ // bottom. We apply this BEFORE hashing/sending, so the change-hash, the transferred body, and the
9
+ // client's render + at-bottom logic all key off the same trimmed capture.
10
+ //
11
+ // A row counts as blank only if it has no glyph AND no SGR escape — a shaded/full-width padding row
12
+ // (e.g. Claude Code's grey message bar) carries \x1b and is NEVER trimmed here; that stays for the
13
+ // client's trimTrailingShadow (and such boxes are never the very bottom of the pane anyway).
14
+ export const MAX_TRAILING_BLANK = 3;
15
+
16
+ const isBlank = (row) => row === '' || (/^[ \t]*$/.test(row) && !row.includes('\x1b'));
17
+
18
+ export function capTrailingBlankRows(ansi, max = MAX_TRAILING_BLANK) {
19
+ // capture-pane terminates every row with \n (including a trailing one). Peel that off so split
20
+ // gives exactly the rows, then restore it so the output keeps capture-pane's shape (prepareSeed
21
+ // drops a single trailing newline downstream).
22
+ const hadNL = ansi.endsWith('\n');
23
+ const rows = (hadNL ? ansi.slice(0, -1) : ansi).split('\n');
24
+ let end = rows.length;
25
+ while (end > 0 && isBlank(rows[end - 1])) end -= 1;
26
+ if (rows.length - end > max) rows.length = end + max;
27
+ return rows.join('\n') + (hadNL ? '\n' : '');
28
+ }
@@ -0,0 +1,28 @@
1
+ // Extension allow-list for uploads. By extension only (not magic bytes) — this is a "be tidy"
2
+ // guard, not a trust boundary (a token holder can write anything from the terminal anyway; see spec
3
+ // threat model). Override the whole set with HANDMUX_UPLOAD_EXTS (comma-separated).
4
+ export const DEFAULT_UPLOAD_EXTS = new Set([
5
+ // images
6
+ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'heif', 'avif', 'ico', 'tiff',
7
+ // text / code
8
+ 'txt', 'md', 'markdown', 'rst', 'log', 'csv', 'tsv', 'json', 'yaml', 'yml', 'toml', 'ini',
9
+ 'conf', 'xml', 'html', 'htm', 'css', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'rb', 'go',
10
+ 'rs', 'java', 'c', 'h', 'cpp', 'sh',
11
+ // documents / office
12
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf',
13
+ ]);
14
+
15
+ // Read HANDMUX_UPLOAD_EXTS (comma-separated) → a Set, normalising leading dots/case/whitespace.
16
+ // Blank/absent → the default set (returned by reference so callers can `=== DEFAULT_UPLOAD_EXTS`).
17
+ export function loadUploadExts(env = process.env) {
18
+ const raw = env.HANDMUX_UPLOAD_EXTS;
19
+ if (!raw || !raw.trim()) return DEFAULT_UPLOAD_EXTS;
20
+ const exts = raw.split(',').map((s) => s.trim().replace(/^\.+/, '').toLowerCase()).filter(Boolean);
21
+ return new Set(exts);
22
+ }
23
+
24
+ // True if `name`'s final extension is in `exts`. No extension → false.
25
+ export function isAllowedUploadExt(name, exts) {
26
+ const m = /\.([A-Za-z0-9]+)$/.exec(name || '');
27
+ return m ? exts.has(m[1].toLowerCase()) : false;
28
+ }
package/tmux/README.md ADDED
@@ -0,0 +1,77 @@
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`)。
@@ -0,0 +1,67 @@
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
+ # window_id -> 最高优先级 kind
44
+ top = {}
45
+ for line in tmux("list-panes", "-a", "-F", "#{pane_current_command} #{pane_id} #{window_id}").splitlines():
46
+ parts = line.split()
47
+ if len(parts) < 3 or parts[0] != "claude":
48
+ continue
49
+ _, pane, win = parts[0], parts[1], parts[2]
50
+ e = state.get(pane)
51
+ if not e:
52
+ continue
53
+ k = classify(e)
54
+ if not k:
55
+ continue
56
+ if RANK[k] > RANK.get(top.get(win, ""), 0):
57
+ top[win] = k
58
+
59
+ # 写当前点;并清掉已不再有 claude 状态的窗的残留点
60
+ for line in tmux("list-windows", "-a", "-F", "#{window_id}").splitlines():
61
+ win = line.strip()
62
+ if not win:
63
+ continue
64
+ if win in top:
65
+ tmux("set-option", "-w", "-t", win, "@claude_dot", DOT[top[win]])
66
+ elif tmux("show-options", "-wv", "-t", win, "@claude_dot").strip():
67
+ tmux("set-option", "-uw", "-t", win, "@claude_dot")
@@ -0,0 +1,14 @@
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