handmux 0.16.0 → 0.17.3

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/public/index.html CHANGED
@@ -48,8 +48,8 @@
48
48
  @keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
49
49
  @media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
50
50
  </style>
51
- <script type="module" crossorigin src="/assets/index-Cf-WEkt5.js"></script>
52
- <link rel="stylesheet" crossorigin href="/assets/index-D5UQBvph.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-D5UQBvph.css"></noscript>
51
+ <script type="module" crossorigin src="/assets/index-Ddagv-Lc.js"></script>
52
+ <link rel="stylesheet" crossorigin href="/assets/index-BT03K2MQ.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-BT03K2MQ.css"></noscript>
53
53
  </head>
54
54
  <body>
55
55
  <div id="boot-splash" aria-hidden="true">
package/public/sw.js CHANGED
@@ -96,16 +96,28 @@ self.addEventListener('notificationclick', (event) => {
96
96
  event.notification.close();
97
97
  const d = event.notification.data || {};
98
98
  const e = encodeURIComponent;
99
- const url = d.url
100
- ? d.url
101
- : d.session
102
- ? `/#/s/${e(d.session)}/w/${e(d.window || '')}/p/${e(d.pane || '')}`
103
- : '/';
99
+ // A deep-link target is present only for a session push or an explicit `--url`. A plain manual push
100
+ // (`handmux push <title> <body>`) has neither → hasTarget=false, and we must NOT navigate an open
101
+ // client: url would be '/', and navigating an app sitting at '/#/s/…' to '/' is a same-document
102
+ // navigation that pushes a spurious history entry above the app (Back then needs an extra press —
103
+ // reads as "a nested page opened"). Just focus it. openWindow still needs a concrete url below.
104
+ const hasTarget = !!(d.inboxId || d.url || d.session);
105
+ const url = d.inboxId
106
+ ? `/#/inbox/${e(d.inboxId)}`
107
+ : d.url
108
+ ? d.url
109
+ : d.session
110
+ ? `/#/s/${e(d.session)}/w/${e(d.window || '')}/p/${e(d.pane || '')}`
111
+ : '/';
104
112
  event.waitUntil((async () => {
105
113
  const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
106
114
  const open = all.find((c) => 'focus' in c);
107
115
  if (open) {
108
116
  await open.focus();
117
+ if (!hasTarget) return; // no deep-link → focus alone, never push a history entry
118
+
119
+ if (d.inboxId) { open.postMessage({ type: 'navigate-inbox', id: d.inboxId }); return; }
120
+
109
121
  // A session deep-link opens IN PLACE via postMessage (the app switches sessions and writes the hash
110
122
  // with replaceState). We deliberately do NOT navigate() an already-open client for this: a
111
123
  // same-document navigation pushes a spurious history entry ABOVE the app, so Back then needed an
@@ -10,7 +10,64 @@
10
10
  // sessions cwd → session resolution + the `resume` command, for orphan takeover
11
11
  import path from 'node:path';
12
12
  import os from 'node:os';
13
- import { resolveEncodedDirSession, isSessionUuid } from './scanUtils.js';
13
+ import { promises as fsp } from 'node:fs';
14
+ import { resolveEncodedDirSession, isSessionUuid, normTty } from './scanUtils.js';
15
+
16
+ // The NATIVE installer names the real binary by version (~/.local/share/claude/versions/2.1.196 —
17
+ // ~/.local/bin/claude is only a symlink to it), and tmux #{pane_current_command} follows that basename
18
+ // ("2_1_196", dots sanitized to underscores) instead of "claude". ps can't tie it: ps `comm` shows the
19
+ // process's self-set title ("claude"), which does NOT match tmux's report (verified live on a native-
20
+ // install machine). Matching any semver-shaped name would misidentify any version-named binary, so we
21
+ // corroborate with the process's REAL executable path (`lsof -d txt` on macOS, /proc/<pid>/exe on
22
+ // Linux): a pid on the pane's tty whose exe BASENAME equals tmux's version comm, and whose path carries
23
+ // "claude" — every official install layout carries it (Caskroom/claude-code@latest, .local/share/
24
+ // claude/versions, node_modules/@anthropic-ai/claude-code, …), version- and layout-proof, while a
25
+ // foreign version-named binary's path doesn't. Only then is the pane's cmd normalized to 'claude', so
26
+ // every downstream match (identity + liveness) stays exact-name. Verdicts are cached per (tty, cmd) in
27
+ // an injected Map — a cache miss costs one ps + one lsof per candidate pane, once per version per pane.
28
+ const VERSION_COMM_RE = /^\d+[._]\d+[._]\d+$/;
29
+ const CLAUDE_PATH_RE = /claude/;
30
+
31
+ // The real executable path of a pid: first txt entry from lsof (resolves symlinks → the actual binary),
32
+ // falling back to /proc on Linux where lsof output may be restricted. '' when unknowable.
33
+ async function exePath(run, pid) {
34
+ const out = await run('lsof', ['-a', '-p', String(pid), '-d', 'txt', '-Fn']);
35
+ for (const line of String(out).split('\n')) if (line[0] === 'n') return line.slice(1).trim();
36
+ try { return await fsp.readlink(`/proc/${pid}/exe`); } catch { return ''; }
37
+ }
38
+
39
+ export async function resolveVersionedComms(panes, run, verdicts = new Map()) {
40
+ const candidates = panes.filter((p) => p && p.tty && VERSION_COMM_RE.test(p.cmd || ''));
41
+ if (!candidates.length) return panes;
42
+ const key = (p) => `${normTty(p.tty)}|${p.cmd}`;
43
+ for (const p of candidates) if (verdicts.get(key(p)) === true) p.cmd = 'claude';
44
+ const pending = candidates.filter((p) => p.cmd !== 'claude' && !verdicts.has(key(p)));
45
+ if (!pending.length) return panes;
46
+
47
+ const out = await run('ps', ['-Ao', 'tty=,pid=,comm=']);
48
+ const procs = []; // [{ tty, pid, comm }]
49
+ for (const line of String(out).split('\n')) {
50
+ const m = line.match(/^\s*(\S+)\s+(\d+)\s+(.*)$/);
51
+ if (!m) continue;
52
+ const tty = normTty(m[1]);
53
+ if (tty) procs.push({ tty, pid: m[2], comm: m[3].trim() });
54
+ }
55
+ for (const p of pending) {
56
+ let ok = false;
57
+ // Tie by tty + exe BASENAME (== tmux's version comm; ps comm is the self-set title and can't be
58
+ // trusted to match), then require the real path to carry "claude" — a random semver-named binary
59
+ // elsewhere is NOT Claude.
60
+ const want = p.cmd.replace(/_/g, '.');
61
+ for (const r of procs.filter((r) => r.tty === normTty(p.tty))) {
62
+ const exe = await exePath(run, r.pid);
63
+ const base = exe.split('/').pop() || '';
64
+ if ((base === p.cmd || base === want) && CLAUDE_PATH_RE.test(exe)) { ok = true; break; }
65
+ }
66
+ verdicts.set(key(p), ok);
67
+ if (ok) p.cmd = 'claude';
68
+ }
69
+ return panes;
70
+ }
14
71
 
15
72
  // Build the 需要你 one-liner for a PermissionRequest, from the tool it's gating on (PermissionRequest
16
73
  // carries tool_name + tool_input, unlike the later permission_prompt Notification which only has an
@@ -26,6 +83,22 @@ function permMsg(body) {
26
83
  return t ? `需要你授权:${t}` : '需要你';
27
84
  }
28
85
 
86
+ // Friendly Chinese for the StopFailure error type (matcher values, see the hooks doc). The payload shape
87
+ // isn't verified against a live rate-limit yet, so read the type defensively from several likely fields and
88
+ // always fall back to a bare 本轮出错 — a wrong field name degrades to the generic label, never throws.
89
+ const STOPFAIL_LABEL = {
90
+ rate_limit: '触发限流', overloaded: '服务过载', authentication_failed: '认证失败',
91
+ oauth_org_not_allowed: '组织未授权', billing_error: '额度/账单问题', invalid_request: '请求无效',
92
+ model_not_found: '模型不可用', server_error: '服务端错误', max_output_tokens: '输出超长', unknown: '未知错误',
93
+ };
94
+ function stopFailMsg(body = {}) {
95
+ const type = body.error_type || body.reason || body.type
96
+ || (body.error && (typeof body.error === 'string' ? body.error : body.error.type)) || '';
97
+ if (STOPFAIL_LABEL[type]) return STOPFAIL_LABEL[type];
98
+ const raw = typeof body.error === 'string' ? body.error : (body.message || '');
99
+ return raw ? String(raw).replace(/\s+/g, ' ').trim().slice(0, 80) : '';
100
+ }
101
+
29
102
  // Build the 进行中 one-liner for a resume (PostToolUse after the user answered/approved), surfacing the
30
103
  // choice they just made — AskUserQuestion stores it in tool_input.answers, keyed by question.
31
104
  function resumeMsg(body) {
@@ -42,6 +115,10 @@ function resumeMsg(body) {
42
115
  // stop → done (turn finished; carries last message)
43
116
  // prompt → working (UserPromptSubmit; carries the prompt)
44
117
  // end → end (SessionEnd; the pane's claude is gone)
118
+ // start → null (SessionStart startup/clear/resume: (re)binds pane→session to
119
+ // the NEW transcript_path — the whole point on /clear, which
120
+ // starts a fresh session file. Neutral: a fresh/just-cleared
121
+ // session reads as present, not 进行中, until its first prompt)
45
122
  // notify + idle_prompt → idle (waited ~60s; carries the notification message)
46
123
  // notify + permission_prompt → permission (blocked on a permission/选择 gate; carries the message)
47
124
  // resume → working (PostToolUse on AskUserQuestion/ExitPlanMode: the user just
@@ -52,13 +129,23 @@ function resumeMsg(body) {
52
129
  // before the permission_prompt Notification and names the tool,
53
130
  // so 需要你 shows faster and says what's being asked. Verified
54
131
  // NOT to fire for auto-approved tools → no false 需要你 in auto)
132
+ // compacting → compacting (PreCompact: context compaction started — a slow op, shown as
133
+ // 「压缩中」; version-gated, only paired with the clearing event)
134
+ // compact → null (PostCompact: compaction finished → clear 压缩中/进行中. null
135
+ // drops the roster entry; the pane falls back to neutral present)
136
+ // stopfail → error (StopFailure: the turn ended on an API error — no Stop fires, so
137
+ // this is the only signal that un-sticks the pane from 进行中)
55
138
  // anything else → null (ignored: auth_success, elicitation_*, etc.)
56
139
  export function classifyClaude(src, body = {}) {
57
140
  if (src === 'stop') return { kind: 'done', msg: body.last_assistant_message || '' };
58
141
  if (src === 'prompt') return { kind: 'working', msg: body.prompt || '' };
59
142
  if (src === 'resume') return { kind: 'working', msg: resumeMsg(body) };
60
143
  if (src === 'permreq') return { kind: 'permission', msg: permMsg(body) };
144
+ if (src === 'compacting') return { kind: 'compacting', msg: '' }; // PreCompact: 压缩上下文进行中
145
+ if (src === 'compact') return null; // PostCompact: done → clear 压缩中/进行中
146
+ if (src === 'stopfail') return { kind: 'error', msg: stopFailMsg(body) }; // turn died on an API error
61
147
  if (src === 'end') return { kind: 'end' };
148
+ if (src === 'start') return null; // SessionStart: only (re)binds pane→session
62
149
  if (src === 'notify') {
63
150
  if (body.notification_type === 'idle_prompt') return { kind: 'idle', msg: body.message || '' };
64
151
  if (body.notification_type === 'permission_prompt') return { kind: 'permission', msg: body.message || '' };
@@ -73,7 +160,9 @@ export const claude = {
73
160
  label: 'Claude Code',
74
161
  procName: 'claude',
75
162
  // Which tmux #{pane_current_command} values mean "this agent is still the pane's foreground app" (inbox
76
- // liveness). Claude sets its process title to "claude", so an exact single-name match is right.
163
+ // liveness). Claude sets its process title to "claude", so an exact single-name match is right — for
164
+ // native-install machines (whose comm is the VERSION string) resolveVersionedComms below normalizes the
165
+ // cmd back to 'claude' at ingest, keeping every match here exact-name.
77
166
  procNames: ['claude'],
78
167
  // The `claude` CLI sets its process title to "claude" (verified via ps): match the program token at the
79
168
  // start of argv — bare "claude", "claude --continue", or an absolute path ending in /claude. Anchored so
@@ -16,5 +16,7 @@ const BY_ID = new Map(AGENTS.map((a) => [a.id, a]));
16
16
  export function getAgent(id) { return BY_ID.get(id) || claude; }
17
17
 
18
18
  // The driver whose foreground process this tmux #{pane_current_command} is, or null. Used by the inbox to
19
- // decide a recorded pane is still running its agent (vs. the agent having exited to a shell).
19
+ // decide a recorded pane is still running its agent (vs. the agent having exited to a shell). Matches the
20
+ // canonical procName only — never the ambiguous extras in procNames (e.g. Codex's 'node'); native-install
21
+ // Claude binaries (comm = version string) are normalized to 'claude' at ingest (resolveVersionedComms).
20
22
  export function agentForProc(cmd) { return AGENTS.find((a) => a.procName === cmd) || null; }
@@ -2,6 +2,8 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { getAgent, agentForProc } from './agents/index.js';
5
+ import { resolveVersionedComms } from './agents/claude.js';
6
+ import { defaultRun } from './agents/scanUtils.js';
5
7
  import { claude } from './agents/claude.js';
6
8
 
7
9
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -39,6 +41,14 @@ function pushKey(view, ts) { return view === 'done' ? `done:${ts}` : view; }
39
41
  // its next event, and a new prompt resets the clock.
40
42
  const WORKING_TTL_MS = 2 * 60 * 60 * 1000;
41
43
 
44
+ // 压缩中 (compacting) clears three ways: a SUCCESSFUL compaction fires PostCompact (src 'compact' → cleared
45
+ // the instant it finishes); a NO-OP /compact ("Not enough messages to compact") fires no PostCompact but
46
+ // writes its <local-command-stdout> immediately, which the transcript-tail check below catches within a poll;
47
+ // and this TTL is the ultimate backstop for a genuine crash/abort mid-compaction where neither signal comes.
48
+ // It must stay WELL above a real compaction's duration (routinely 1-2min) so it never truncates the animation
49
+ // of a compaction that's actually still running — real ones are cleared by PostCompact, not by this timer.
50
+ const COMPACTING_TTL_MS = 5 * 60 * 1000;
51
+
42
52
  // A pane latched in `permission` (需要你) has NO hook event to close it when the user resolves the prompt:
43
53
  // approving a normal tool (Bash/Edit/…) doesn't hit our PostToolUse matcher (only AskUserQuestion|ExitPlanMode
44
54
  // do), and DENYING or ESC-interrupting fires no hook at all (verified across all hook events). So the prompt
@@ -84,8 +94,30 @@ export function permissionResolved(rec, mtimeMs, guard = PERM_RESOLVED_GUARD_MS)
84
94
  // pane to neutral (null). An unparseable/absent tail is treated as a resume (clears the stale 需要你 and
85
95
  // shows active — a truncated huge last line is a big tool_result, never the tiny interrupt marker).
86
96
  export function resolvedPermissionKind(lastLine) {
87
- if (lastLine && /Request interrupted by user/.test(lastLine)) return null; // ESC → neutral
88
- return { kind: 'working', msg: '' }; // approve / deny → 进行中
97
+ if (isInterruptTail(lastLine)) return null; // ESC → neutral
98
+ return { kind: 'working', msg: '' }; // approve / deny → 进行中
99
+ }
100
+
101
+ // Pure: does a transcript's last line mark a user ESC-interrupt? Matches both the plain and the
102
+ // "…for tool use" forms. Used to un-stick a 进行中 pane the instant the user aborts a turn.
103
+ export function isInterruptTail(lastLine) {
104
+ return !!(lastLine && /Request interrupted by user/.test(lastLine));
105
+ }
106
+
107
+ // Pure: does a transcript's last line mark a /compact having RESOLVED — i.e. Claude wrote the command's
108
+ // <local-command-stdout>? A NO-OP /compact ("Not enough messages to compact") writes it IMMEDIATELY, whereas
109
+ // a real compaction stays silent for its whole (often 1-2min) run and writes it only at the very end — the
110
+ // same moment PostCompact already clears us. So a fresh stdout tail while still 压缩中 ⇒ the compaction is
111
+ // over (nothing to do / done) ⇒ drop the stuck state. Recognised by the system entry's subtype or the tag.
112
+ export function isLocalCommandStdout(lastLine) {
113
+ if (!lastLine) return false;
114
+ try {
115
+ const o = JSON.parse(lastLine);
116
+ if (o && o.subtype === 'local_command') return true;
117
+ const c = o && o.message && o.message.content;
118
+ if (typeof c === 'string' && /<local-command-stdout>/.test(c)) return true;
119
+ } catch { /* not JSON → fall through to a raw tag match */ }
120
+ return /<local-command-stdout>/.test(lastLine);
89
121
  }
90
122
 
91
123
  // Truncate a Claude message to a notification-friendly one-liner.
@@ -104,12 +136,16 @@ function readStateFile(file) {
104
136
  }
105
137
 
106
138
  // Per-process event reader. Deps injected for testability:
107
- // commands.listLivePanes() → [{ id, cmd, session, window, windowName }] (one tmux call: liveness+location)
139
+ // commands.listLivePanes() → [{ id, cmd, tty, session, window, windowName }] (one tmux call)
140
+ // run(cmd, args) → stdout (only used by the version-named-comm corroboration; default defaultRun)
108
141
  // push.sendToSession(session, payload, {ttl, urgency, topic})
109
142
  // file: the hook-maintained JSON state file (DEFAULT_STATE_FILE).
110
143
  // The hook is the sole writer; the server reads the file fresh on every getStates and on every file
111
144
  // change (the watcher, for push). No persisted state of our own — the file IS the persistence.
112
- export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE, now = () => Date.now(), statMtime = defaultStatMtime, readTail = defaultReadTail } = {}) {
145
+ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE, now = () => Date.now(), statMtime = defaultStatMtime, readTail = defaultReadTail, run = defaultRun } = {}) {
146
+ // Cache for resolveVersionedComms verdicts ((tty|cmd) → bool) — one corroboration per version per pane,
147
+ // not one per poll. Per instance: prod shares one server-wide map; tests get isolation for free.
148
+ const commVerdicts = new Map();
113
149
  const lastPushed = {}; // pane → 'needs' | 'done' | null (in-process push-transition dedup, by display view)
114
150
  // The dedup above is in-process ONLY: a restart (e.g. ./deploy.sh) wipes it while the hook's state
115
151
  // file on disk keeps every pane's latest 需要你/已完成. Without priming, the first read after boot
@@ -151,7 +187,13 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
151
187
  const allow = allowedSessions == null ? null : new Set(allowedSessions);
152
188
  const recorded = readStateFile(file);
153
189
  let live = null;
154
- try { live = new Map((await commands.listLivePanes()).map((p) => [p.id, p])); } catch { /* tmux down */ }
190
+ try {
191
+ const panes = await commands.listLivePanes();
192
+ // Native-install Claude binaries report a version string as pane_current_command — corroborate via
193
+ // ps and normalize to 'claude' BEFORE any identity/liveness match (see agents/claude.js).
194
+ await resolveVersionedComms(panes, run, commVerdicts);
195
+ live = new Map(panes.map((p) => [p.id, p]));
196
+ } catch { /* tmux down */ }
155
197
 
156
198
  const out = {};
157
199
  for (const [pane, rec] of Object.entries(recorded)) {
@@ -163,6 +205,21 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
163
205
  if (c && c.kind === 'permission') {
164
206
  const tp = rec.payload && rec.payload.transcript_path;
165
207
  if (tp && permissionResolved(rec, statMtime(tp))) c = resolvedPermissionKind(readTail(tp));
208
+ } else if (c && c.kind === 'working') {
209
+ // ESC-interrupt during a turn leaves the last hook as the stale 'prompt' (working) — no Stop fires,
210
+ // so working would otherwise stick until WORKING_TTL_MS (2h), pinning the composer's send→stop toggle.
211
+ // Once the transcript has grown past the prompt event, a bounded tail read settles it: an interrupt
212
+ // marker → neutral (un-stick now); any other line → Claude is still producing, stay 进行中. Content is
213
+ // definitive so no guard window is needed; an unreadable stat/tail can't tell → keep working.
214
+ const tp = rec.payload && rec.payload.transcript_path;
215
+ if (tp && statMtime(tp) > (rec.ts || 0) && isInterruptTail(readTail(tp))) c = null;
216
+ } else if (c && c.kind === 'compacting') {
217
+ // A no-op /compact fires no PostCompact; it writes its <local-command-stdout> at once. Once the
218
+ // transcript has grown past the PreCompact event and its tail is that stdout, the /compact is done
219
+ // (nothing to compact) → drop 压缩中. A real compaction stays silent until PostCompact, so it keeps
220
+ // showing for its whole run. Unreadable stat/tail → can't tell → keep 压缩中 (the TTL is the backstop).
221
+ const tp = rec.payload && rec.payload.transcript_path;
222
+ if (tp && statMtime(tp) > (rec.ts || 0) && isLocalCommandStdout(readTail(tp))) c = null;
166
223
  }
167
224
  const lp = live ? live.get(pane) : null;
168
225
  // Dropped when tmux says the pane is gone or no longer running THIS agent (hard kill / crash /
@@ -193,6 +250,7 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
193
250
  // Expire a 进行中 latched past the TTL (an ESC-interrupt / walk-away that never got a Stop): drop it
194
251
  // from the roster so the stuck working pane goes away. See WORKING_TTL_MS.
195
252
  if (c.kind === 'working' && now() - (rec.ts || 0) > WORKING_TTL_MS) continue;
253
+ if (c.kind === 'compacting' && now() - (rec.ts || 0) > COMPACTING_TTL_MS) continue;
196
254
  const loc = lp ? { session: lp.session, window: lp.window, windowName: lp.windowName } : {};
197
255
  if (allow && !allow.has(loc.session)) continue;
198
256
  out[pane] = { ...loc, kind: c.kind, msg: c.msg || '', ts: rec.ts || 0, agent: agent.id };
@@ -251,5 +309,20 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
251
309
  }
252
310
  function stop() { if (watcher) { watcher.close(); watcher = null; } clearTimeout(deb); }
253
311
 
254
- return { getStates, start, stop };
312
+ // The chat lens's pane→session bind: the hook state file records THIS pane's exact session (session_id +
313
+ // transcript_path), authoritative over the terminal-side cwd→newest-jsonl guess (which collapses distinct
314
+ // sessions that happen to share a cwd — see transcript.js). Returns null when hooks are off / the pane
315
+ // isn't a Claude pane / the recorded payload carries no session info, so callers can fall back cleanly.
316
+ function paneSession(pane) {
317
+ const rec = readStateFile(file)[pane];
318
+ const p = rec && rec.payload;
319
+ if (!p || typeof p !== 'object') return null;
320
+ const transcriptPath = typeof p.transcript_path === 'string' ? p.transcript_path : null;
321
+ const sessionId = typeof p.session_id === 'string' ? p.session_id : null;
322
+ const cwd = typeof p.cwd === 'string' ? p.cwd : null;
323
+ if (!transcriptPath && !sessionId) return null;
324
+ return { sessionId, transcriptPath, cwd };
325
+ }
326
+
327
+ return { getStates, start, stop, paneSession };
255
328
  }
@@ -7,38 +7,111 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { homedir } from 'node:os';
10
+ import { spawnSync } from 'node:child_process';
10
11
  import { writeJsonAtomic, deployHookScripts, removeHookScripts } from './hookScaffold.js';
11
12
 
12
- // The six events the inbox reads. src is the arg passed to handmux-notify.sh; only PostToolUse is scoped to
13
+ // The seven events the inbox reads. src is the arg passed to handmux-notify.sh; only PostToolUse is scoped to
13
14
  // a matcher (the two "需要你" interaction tools) — every other Read/Bash/Edit must NOT wake the hook, or
14
15
  // many concurrent Claude panes would each spawn the hook on every tool call. Keep this table in sync with
15
16
  // the hook scripts in ../../hooks.
17
+ // SessionStart binds the pane→session mapping the instant a session begins — critically after /clear, which
18
+ // starts a NEW transcript file. Without it, /clear's SessionEnd(old) drops the pane and nothing rebinds it
19
+ // until the next prompt, so the 对话 lens goes blank / falls back to the ambiguous cwd→newest-jsonl guess.
20
+ // Fires only at session boundaries (startup/clear/resume), never per tool call, so it adds no hot-path load;
21
+ // classified neutral (no push, no roster entry — see agents/claude.js). Base, not version-gated: SessionStart
22
+ // is a long-standing lifecycle hook every supported Claude recognises.
16
23
  export const HOOK_EVENTS = [
17
24
  { event: 'Stop', src: 'stop' },
18
25
  { event: 'Notification', src: 'notify' },
19
26
  { event: 'UserPromptSubmit', src: 'prompt' },
27
+ { event: 'SessionStart', src: 'start' },
20
28
  { event: 'SessionEnd', src: 'end' },
21
29
  { event: 'PostToolUse', src: 'resume', matcher: 'AskUserQuestion|ExitPlanMode' },
22
30
  { event: 'PermissionRequest', src: 'permreq' },
23
31
  ];
24
32
 
33
+ // Version-gated events, only registered on a Claude Code new enough to EMIT them. Older Claude does not
34
+ // recognise these event names — and the docs give NO guarantee it ignores unknown ones (it "likely" does,
35
+ // but might reject the settings file) — so we NEVER write an event a version can't handle. Each carries a
36
+ // minVersion; below it (or when the version can't be detected) the event isn't written at all → pure
37
+ // downgrade, never an error. The pane instead self-heals via the existing idle_prompt(~60s) fallback.
38
+ // PreCompact → 压缩中 shown while compaction runs (honest progress for a slow op).
39
+ // PostCompact → clears the 压缩中/进行中 state the instant compaction finishes.
40
+ // StopFailure → a turn that died on an API error (rate limit / overload / …) fires NO Stop, so without
41
+ // this the pane sticks at 进行中 forever; maps to an 'error' state.
42
+ // `pairWith` welds an invariant (learned the hard way — never light a state you can't turn off): PreCompact
43
+ // is installed ONLY when its clearer PostCompact is also installed. minVersion is set conservatively to the
44
+ // version we've actually verified emits these (2.1.207); lower it only after test-firing an older build.
45
+ const COMPACT_MIN = '2.1.207';
46
+ export const HOOK_EVENTS_EXT = [
47
+ { event: 'PostCompact', src: 'compact', minVersion: COMPACT_MIN },
48
+ { event: 'PreCompact', src: 'compacting', minVersion: COMPACT_MIN, pairWith: 'PostCompact' },
49
+ { event: 'StopFailure', src: 'stopfail', minVersion: COMPACT_MIN },
50
+ ];
51
+
25
52
  const HOOK_MARK = 'handmux-notify.sh'; // identifies our hooks among the user's own
26
53
 
54
+ // Parse `claude --version` output ("2.1.207 (Claude Code)") → { major, minor, patch } | null.
55
+ export function parseClaudeVersion(out) {
56
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(String(out || ''));
57
+ return m ? { major: +m[1], minor: +m[2], patch: +m[3] } : null;
58
+ }
59
+
60
+ // v >= min ("X.Y.Z"), full major.minor.patch compare. A null/undefined version is always below → fail-closed.
61
+ export function claudeVersionAtLeast(v, minStr) {
62
+ if (!v) return false;
63
+ const [a, b, c] = String(minStr).split('.').map(Number);
64
+ if (v.major !== a) return v.major > a;
65
+ if (v.minor !== b) return v.minor > b;
66
+ return v.patch >= c;
67
+ }
68
+
69
+ // Detect the installed Claude Code version, or null if `claude` can't be run/parsed (→ ext hooks skipped).
70
+ export function detectClaudeVersion(exec = spawnSync) {
71
+ try {
72
+ const r = exec('claude', ['--version'], { encoding: 'utf8', timeout: 4000 });
73
+ if (!r || r.status !== 0 || !r.stdout) return null;
74
+ return parseClaudeVersion(r.stdout);
75
+ } catch { return null; }
76
+ }
77
+
78
+ // Drop OUR hook from settings.hooks[event] (used to prune an ext event that no longer passes the version
79
+ // gate — e.g. Claude was downgraded after a newer install). Mutates the passed hooks object.
80
+ function dropOurHook(hooks, event) {
81
+ if (!hooks[event]) return;
82
+ const kept = hooks[event]
83
+ .map((g) => ({ ...g, hooks: (g.hooks || []).filter((h) => !(typeof h.command === 'string' && h.command.includes(HOOK_MARK))) }))
84
+ .filter((g) => (g.hooks || []).length > 0);
85
+ if (kept.length) hooks[event] = kept; else delete hooks[event];
86
+ }
87
+
27
88
  // True if `settings.hooks[event]` already has one of our hooks (command references the dest script).
28
89
  function alreadyHas(hooks, event) {
29
90
  return (hooks[event] || []).some((g) => (g.hooks || []).some(
30
91
  (h) => typeof h.command === 'string' && h.command.includes(HOOK_MARK)));
31
92
  }
32
93
 
94
+ // Merge one event's hook group into `hooks` (mutates), idempotently — a no-op if one of ours is already
95
+ // registered for that event, so it's safe to call on every install/sync. Shared by mergeHooks and syncHooks.
96
+ function addHook(hooks, e, dest) {
97
+ if (alreadyHas(hooks, e.event)) return;
98
+ const groups = hooks[e.event] = [...(hooks[e.event] || [])];
99
+ groups.push({ matcher: e.matcher || '', hooks: [{ type: 'command', command: `${dest} ${e.src}`, async: true, timeout: 5 }] });
100
+ }
101
+
33
102
  // Pure: return a NEW settings object with our six hooks merged into settings.hooks, idempotently, leaving
34
103
  // the user's own hooks and other keys untouched. `dest` is the absolute path to the copied notify script.
35
- export function mergeHooks(settings, dest) {
104
+ export function mergeHooks(settings, dest, claudeVersion = null) {
36
105
  const s = { ...(settings || {}) };
37
106
  const hooks = (s.hooks && typeof s.hooks === 'object' && !Array.isArray(s.hooks)) ? { ...s.hooks } : {};
38
- for (const e of HOOK_EVENTS) {
39
- if (alreadyHas(hooks, e.event)) continue;
40
- const groups = hooks[e.event] = [...(hooks[e.event] || [])];
41
- groups.push({ matcher: e.matcher || '', hooks: [{ type: 'command', command: `${dest} ${e.src}`, async: true, timeout: 5 }] });
107
+ for (const e of HOOK_EVENTS) addHook(hooks, e, dest);
108
+ // Version-gated ext events: install only when the detected Claude is new enough AND (for a paired event)
109
+ // its clearer is also being installed. Anything that fails the gate is actively PRUNED, so a Claude
110
+ // downgrade after a newer install can't leave an unrecognised event name lingering in settings.json.
111
+ const enabled = new Set();
112
+ for (const e of HOOK_EVENTS_EXT) {
113
+ const ok = claudeVersionAtLeast(claudeVersion, e.minVersion) && (!e.pairWith || enabled.has(e.pairWith));
114
+ if (ok) { enabled.add(e.event); addHook(hooks, e, dest); } else dropOurHook(hooks, e.event);
42
115
  }
43
116
  s.hooks = hooks;
44
117
  return s;
@@ -79,7 +152,7 @@ export function hooksStatus(home = homedir()) {
79
152
  // absent the user doesn't run Claude Code, so we report 'no-claude' and do nothing.
80
153
  // srcDir = the bundled hooks dir (server/hooks)
81
154
  // stateFile = the unified ~/.handmux/claude-state.json path the hook writes and the server reads
82
- export function installHooks(home = homedir(), { srcDir, stateFile } = {}) {
155
+ export function installHooks(home = homedir(), { srcDir, stateFile, claudeVersion } = {}) {
83
156
  if (!fs.existsSync(claudeDir(home))) return { status: 'no-claude' };
84
157
  const hooksDir = path.join(claudeDir(home), 'hooks');
85
158
  deployHookScripts(hooksDir, srcDir, stateFile);
@@ -87,10 +160,41 @@ export function installHooks(home = homedir(), { srcDir, stateFile } = {}) {
87
160
  const dest = path.join(hooksDir, 'handmux-notify.sh');
88
161
  let settings = {};
89
162
  try { settings = JSON.parse(fs.readFileSync(settingsPath(home), 'utf8')); } catch { /* missing/corrupt → {} */ }
90
- writeJsonAtomic(settingsPath(home), mergeHooks(settings, dest));
163
+ // Gate the version-specific events (compact pair, StopFailure) on the installed Claude. Detect when the
164
+ // caller didn't inject a version; a null result (no `claude` / unparseable) is fail-closed = base 6 only.
165
+ const version = claudeVersion !== undefined ? claudeVersion : detectClaudeVersion();
166
+ writeJsonAtomic(settingsPath(home), mergeHooks(settings, dest, version));
91
167
  return { status: 'installed' };
92
168
  }
93
169
 
170
+ // Keep an ALREADY-installed user's hooks in step with this handmux version on every server start, so a plain
171
+ // `./deploy.sh` (restart) rolls out newly-added lifecycle events (e.g. SessionStart) and refreshed hook
172
+ // scripts — no phone re-enable needed. Two moves: (1) re-deploy the bundled hook scripts (idempotent — picks
173
+ // up a fixed handmux-write.cjs), and (2) add any of our BASE events a prior install predates.
174
+ //
175
+ // Strictly opt-in-preserving: a NO-OP unless our hooks are already present ('installed'). It never enables
176
+ // hooks for a user who hasn't opted in ('absent') and never creates ~/.claude ('no-claude'). BASE events
177
+ // only — it deliberately does NOT touch the version-gated ext events (compact pair / StopFailure): reconciling
178
+ // those needs the Claude version (a null read would PRUNE the ext hooks a user already has), and detecting it
179
+ // means spawning `claude --version` on the hot startup path. So sync stays pure-fs and non-pruning; ext-event
180
+ // rollout stays with the explicit installHooks (phone re-enable) path. settings.json is rewritten only when
181
+ // the merge actually changes it, so a steady state writes nothing.
182
+ export function syncHooks(home = homedir(), { srcDir, stateFile } = {}) {
183
+ const status = hooksStatus(home);
184
+ if (status !== 'installed') return { status, changed: false };
185
+ const hooksDir = path.join(claudeDir(home), 'hooks');
186
+ deployHookScripts(hooksDir, srcDir, stateFile); // refresh the deployed scripts (idempotent)
187
+ const dest = path.join(hooksDir, 'handmux-notify.sh');
188
+ let settings = {};
189
+ try { settings = JSON.parse(fs.readFileSync(settingsPath(home), 'utf8')); } catch { /* missing/corrupt → {} */ }
190
+ const hooks = (settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)) ? { ...settings.hooks } : {};
191
+ const before = JSON.stringify(hooks);
192
+ for (const e of HOOK_EVENTS) addHook(hooks, e, dest); // add only MISSING base events; never add/prune ext
193
+ const changed = JSON.stringify(hooks) !== before;
194
+ if (changed) writeJsonAtomic(settingsPath(home), { ...settings, hooks });
195
+ return { status: 'installed', changed };
196
+ }
197
+
94
198
  // Uninstall: strip our hooks from settings.json and remove the copied scripts/env. Best-effort on the file
95
199
  // deletes (a missing file is fine). Leaves ~/.claude and the user's own hooks intact.
96
200
  export function uninstallHooks(home = homedir()) {
package/src/cli/state.js CHANGED
@@ -20,6 +20,7 @@ export function claudeStatePath(home) { return path.join(pocketHome(home), 'clau
20
20
  // PREVIEW_STORE for the server child.
21
21
  export function pushStorePath(home) { return path.join(pocketHome(home), 'push-subs.json'); }
22
22
  export function previewStorePath(home) { return path.join(pocketHome(home), 'previews.json'); }
23
+ export function notificationsDirPath(home) { return path.join(pocketHome(home), 'notifications'); }
23
24
 
24
25
  export function readState(home) {
25
26
  try { return JSON.parse(fs.readFileSync(statePath(home), 'utf8')); } catch { return null; }
@@ -62,6 +62,21 @@ export function installStatusLine(home = homedir(), { srcDir, usageFile } = {})
62
62
  return { status: 'installed' };
63
63
  }
64
64
 
65
+ // Refresh the on-disk capturer script to the BUNDLED version without touching settings.statusLine — so an
66
+ // npm upgrade actually reaches a user who already opted in. Critically settings-safe: a user who composed us
67
+ // into their OWN statusline (the TEE form) still reads as 'ours' (the command contains our mark), and we must
68
+ // NOT rewrite their command to the bare form (that would drop their downstream renderer). So this only ever
69
+ // copies the script. No-op (returns false) when we're not installed. Idempotent; safe to call every start.
70
+ export function refreshStatusLineScript(home = homedir(), { srcDir } = {}) {
71
+ if (statusLineStatus(home) !== 'ours') return false;
72
+ try {
73
+ const dest = path.join(claudeDir(home), 'hooks', SCRIPT);
74
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
75
+ fs.copyFileSync(path.join(srcDir, SCRIPT), dest);
76
+ return true;
77
+ } catch { return false; }
78
+ }
79
+
65
80
  // Uninstall: drop settings.statusLine only if it's ours, and remove the copied script. Leaves a foreign
66
81
  // statusLine and everything else intact.
67
82
  export function uninstallStatusLine(home = homedir()) {
@@ -8,7 +8,7 @@ import path from 'node:path';
8
8
  import os from 'node:os';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { getDriver } from './drivers.js';
11
- import { writeState, clearState, claudeStatePath, pushStorePath, previewStorePath } from './state.js';
11
+ import { writeState, clearState, claudeStatePath, pushStorePath, previewStorePath, notificationsDirPath } from './state.js';
12
12
 
13
13
  const here = path.dirname(fileURLToPath(import.meta.url));
14
14
  const SERVER = path.resolve(here, '../server.js');
@@ -84,6 +84,7 @@ export function supervise(cfg, { home, log = console } = {}) {
84
84
  CLAUDE_STATE_FILE: claudeStatePath(home),
85
85
  PUSH_STORE: pushStorePath(home),
86
86
  PREVIEW_STORE: previewStorePath(home),
87
+ NOTIF_DIR: notificationsDirPath(home),
87
88
  };
88
89
  if (cfg.previewDomain) env.HANDMUX_PREVIEW_DOMAIN = cfg.previewDomain;
89
90
  if (cfg.name) env.HANDMUX_APP_NAME = cfg.name;
package/src/httpApi.js CHANGED
@@ -8,6 +8,7 @@ import * as defaultCommands from './tmux/commands.js';
8
8
  import { defaultDocs, MAX_TRANSFER_BYTES } from './docs.js';
9
9
  import { defaultGit } from './git.js';
10
10
  import * as push from './push.js';
11
+ import * as notifications from './notifications.js';
11
12
  import { createClaudeEvents } from './claudeEvents.js';
12
13
  import { homedir } from 'node:os';
13
14
  import { DEFAULT_UPLOAD_EXTS } from './uploadTypes.js';
@@ -19,6 +20,8 @@ import { fileRoutes } from './routes/files.js';
19
20
  import { pushRoutes } from './routes/push.js';
20
21
  import { systemRoutes } from './routes/system.js';
21
22
  import { previewRoutes } from './routes/previews.js';
23
+ import { notificationRoutes } from './routes/notifications.js';
24
+ import { transcriptRoutes } from './routes/transcript.js';
22
25
 
23
26
  // Re-exported for tests (test/keys.test.js) and any caller that imported it by this path historically.
24
27
  export { isAllowedKey } from './routes/terminal.js';
@@ -35,7 +38,7 @@ export function createApiRouter({
35
38
  const claudeEvents = events || createClaudeEvents({ commands, push });
36
39
 
37
40
  const deps = {
38
- token, commands, docs, git, push, claudeEvents,
41
+ token, commands, docs, git, push, notifications, claudeEvents,
39
42
  uploadExts, maxUploadBytes, asrEnv, previews, previewDomain, home, stateFile,
40
43
  };
41
44
 
@@ -44,8 +47,10 @@ export function createApiRouter({
44
47
  r.use(gitRoutes(deps));
45
48
  r.use(fileRoutes(deps));
46
49
  r.use(pushRoutes(deps));
50
+ r.use(notificationRoutes(deps));
47
51
  r.use(systemRoutes(deps));
48
52
  r.use(previewRoutes(deps));
53
+ r.use(transcriptRoutes(deps));
49
54
 
50
55
  return r;
51
56
  }