handmux 0.16.0 → 0.17.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/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-C8uRJNnP.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
@@ -26,6 +26,22 @@ function permMsg(body) {
26
26
  return t ? `需要你授权:${t}` : '需要你';
27
27
  }
28
28
 
29
+ // Friendly Chinese for the StopFailure error type (matcher values, see the hooks doc). The payload shape
30
+ // isn't verified against a live rate-limit yet, so read the type defensively from several likely fields and
31
+ // always fall back to a bare 本轮出错 — a wrong field name degrades to the generic label, never throws.
32
+ const STOPFAIL_LABEL = {
33
+ rate_limit: '触发限流', overloaded: '服务过载', authentication_failed: '认证失败',
34
+ oauth_org_not_allowed: '组织未授权', billing_error: '额度/账单问题', invalid_request: '请求无效',
35
+ model_not_found: '模型不可用', server_error: '服务端错误', max_output_tokens: '输出超长', unknown: '未知错误',
36
+ };
37
+ function stopFailMsg(body = {}) {
38
+ const type = body.error_type || body.reason || body.type
39
+ || (body.error && (typeof body.error === 'string' ? body.error : body.error.type)) || '';
40
+ if (STOPFAIL_LABEL[type]) return STOPFAIL_LABEL[type];
41
+ const raw = typeof body.error === 'string' ? body.error : (body.message || '');
42
+ return raw ? String(raw).replace(/\s+/g, ' ').trim().slice(0, 80) : '';
43
+ }
44
+
29
45
  // Build the 进行中 one-liner for a resume (PostToolUse after the user answered/approved), surfacing the
30
46
  // choice they just made — AskUserQuestion stores it in tool_input.answers, keyed by question.
31
47
  function resumeMsg(body) {
@@ -42,6 +58,10 @@ function resumeMsg(body) {
42
58
  // stop → done (turn finished; carries last message)
43
59
  // prompt → working (UserPromptSubmit; carries the prompt)
44
60
  // end → end (SessionEnd; the pane's claude is gone)
61
+ // start → null (SessionStart startup/clear/resume: (re)binds pane→session to
62
+ // the NEW transcript_path — the whole point on /clear, which
63
+ // starts a fresh session file. Neutral: a fresh/just-cleared
64
+ // session reads as present, not 进行中, until its first prompt)
45
65
  // notify + idle_prompt → idle (waited ~60s; carries the notification message)
46
66
  // notify + permission_prompt → permission (blocked on a permission/选择 gate; carries the message)
47
67
  // resume → working (PostToolUse on AskUserQuestion/ExitPlanMode: the user just
@@ -52,13 +72,23 @@ function resumeMsg(body) {
52
72
  // before the permission_prompt Notification and names the tool,
53
73
  // so 需要你 shows faster and says what's being asked. Verified
54
74
  // NOT to fire for auto-approved tools → no false 需要你 in auto)
75
+ // compacting → compacting (PreCompact: context compaction started — a slow op, shown as
76
+ // 「压缩中」; version-gated, only paired with the clearing event)
77
+ // compact → null (PostCompact: compaction finished → clear 压缩中/进行中. null
78
+ // drops the roster entry; the pane falls back to neutral present)
79
+ // stopfail → error (StopFailure: the turn ended on an API error — no Stop fires, so
80
+ // this is the only signal that un-sticks the pane from 进行中)
55
81
  // anything else → null (ignored: auth_success, elicitation_*, etc.)
56
82
  export function classifyClaude(src, body = {}) {
57
83
  if (src === 'stop') return { kind: 'done', msg: body.last_assistant_message || '' };
58
84
  if (src === 'prompt') return { kind: 'working', msg: body.prompt || '' };
59
85
  if (src === 'resume') return { kind: 'working', msg: resumeMsg(body) };
60
86
  if (src === 'permreq') return { kind: 'permission', msg: permMsg(body) };
87
+ if (src === 'compacting') return { kind: 'compacting', msg: '' }; // PreCompact: 压缩上下文进行中
88
+ if (src === 'compact') return null; // PostCompact: done → clear 压缩中/进行中
89
+ if (src === 'stopfail') return { kind: 'error', msg: stopFailMsg(body) }; // turn died on an API error
61
90
  if (src === 'end') return { kind: 'end' };
91
+ if (src === 'start') return null; // SessionStart: only (re)binds pane→session
62
92
  if (src === 'notify') {
63
93
  if (body.notification_type === 'idle_prompt') return { kind: 'idle', msg: body.message || '' };
64
94
  if (body.notification_type === 'permission_prompt') return { kind: 'permission', msg: body.message || '' };
@@ -39,6 +39,14 @@ function pushKey(view, ts) { return view === 'done' ? `done:${ts}` : view; }
39
39
  // its next event, and a new prompt resets the clock.
40
40
  const WORKING_TTL_MS = 2 * 60 * 60 * 1000;
41
41
 
42
+ // 压缩中 (compacting) clears three ways: a SUCCESSFUL compaction fires PostCompact (src 'compact' → cleared
43
+ // the instant it finishes); a NO-OP /compact ("Not enough messages to compact") fires no PostCompact but
44
+ // writes its <local-command-stdout> immediately, which the transcript-tail check below catches within a poll;
45
+ // and this TTL is the ultimate backstop for a genuine crash/abort mid-compaction where neither signal comes.
46
+ // It must stay WELL above a real compaction's duration (routinely 1-2min) so it never truncates the animation
47
+ // of a compaction that's actually still running — real ones are cleared by PostCompact, not by this timer.
48
+ const COMPACTING_TTL_MS = 5 * 60 * 1000;
49
+
42
50
  // A pane latched in `permission` (需要你) has NO hook event to close it when the user resolves the prompt:
43
51
  // approving a normal tool (Bash/Edit/…) doesn't hit our PostToolUse matcher (only AskUserQuestion|ExitPlanMode
44
52
  // do), and DENYING or ESC-interrupting fires no hook at all (verified across all hook events). So the prompt
@@ -84,8 +92,30 @@ export function permissionResolved(rec, mtimeMs, guard = PERM_RESOLVED_GUARD_MS)
84
92
  // pane to neutral (null). An unparseable/absent tail is treated as a resume (clears the stale 需要你 and
85
93
  // shows active — a truncated huge last line is a big tool_result, never the tiny interrupt marker).
86
94
  export function resolvedPermissionKind(lastLine) {
87
- if (lastLine && /Request interrupted by user/.test(lastLine)) return null; // ESC → neutral
88
- return { kind: 'working', msg: '' }; // approve / deny → 进行中
95
+ if (isInterruptTail(lastLine)) return null; // ESC → neutral
96
+ return { kind: 'working', msg: '' }; // approve / deny → 进行中
97
+ }
98
+
99
+ // Pure: does a transcript's last line mark a user ESC-interrupt? Matches both the plain and the
100
+ // "…for tool use" forms. Used to un-stick a 进行中 pane the instant the user aborts a turn.
101
+ export function isInterruptTail(lastLine) {
102
+ return !!(lastLine && /Request interrupted by user/.test(lastLine));
103
+ }
104
+
105
+ // Pure: does a transcript's last line mark a /compact having RESOLVED — i.e. Claude wrote the command's
106
+ // <local-command-stdout>? A NO-OP /compact ("Not enough messages to compact") writes it IMMEDIATELY, whereas
107
+ // a real compaction stays silent for its whole (often 1-2min) run and writes it only at the very end — the
108
+ // same moment PostCompact already clears us. So a fresh stdout tail while still 压缩中 ⇒ the compaction is
109
+ // over (nothing to do / done) ⇒ drop the stuck state. Recognised by the system entry's subtype or the tag.
110
+ export function isLocalCommandStdout(lastLine) {
111
+ if (!lastLine) return false;
112
+ try {
113
+ const o = JSON.parse(lastLine);
114
+ if (o && o.subtype === 'local_command') return true;
115
+ const c = o && o.message && o.message.content;
116
+ if (typeof c === 'string' && /<local-command-stdout>/.test(c)) return true;
117
+ } catch { /* not JSON → fall through to a raw tag match */ }
118
+ return /<local-command-stdout>/.test(lastLine);
89
119
  }
90
120
 
91
121
  // Truncate a Claude message to a notification-friendly one-liner.
@@ -163,6 +193,21 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
163
193
  if (c && c.kind === 'permission') {
164
194
  const tp = rec.payload && rec.payload.transcript_path;
165
195
  if (tp && permissionResolved(rec, statMtime(tp))) c = resolvedPermissionKind(readTail(tp));
196
+ } else if (c && c.kind === 'working') {
197
+ // ESC-interrupt during a turn leaves the last hook as the stale 'prompt' (working) — no Stop fires,
198
+ // so working would otherwise stick until WORKING_TTL_MS (2h), pinning the composer's send→stop toggle.
199
+ // Once the transcript has grown past the prompt event, a bounded tail read settles it: an interrupt
200
+ // marker → neutral (un-stick now); any other line → Claude is still producing, stay 进行中. Content is
201
+ // definitive so no guard window is needed; an unreadable stat/tail can't tell → keep working.
202
+ const tp = rec.payload && rec.payload.transcript_path;
203
+ if (tp && statMtime(tp) > (rec.ts || 0) && isInterruptTail(readTail(tp))) c = null;
204
+ } else if (c && c.kind === 'compacting') {
205
+ // A no-op /compact fires no PostCompact; it writes its <local-command-stdout> at once. Once the
206
+ // transcript has grown past the PreCompact event and its tail is that stdout, the /compact is done
207
+ // (nothing to compact) → drop 压缩中. A real compaction stays silent until PostCompact, so it keeps
208
+ // showing for its whole run. Unreadable stat/tail → can't tell → keep 压缩中 (the TTL is the backstop).
209
+ const tp = rec.payload && rec.payload.transcript_path;
210
+ if (tp && statMtime(tp) > (rec.ts || 0) && isLocalCommandStdout(readTail(tp))) c = null;
166
211
  }
167
212
  const lp = live ? live.get(pane) : null;
168
213
  // Dropped when tmux says the pane is gone or no longer running THIS agent (hard kill / crash /
@@ -193,6 +238,7 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
193
238
  // Expire a 进行中 latched past the TTL (an ESC-interrupt / walk-away that never got a Stop): drop it
194
239
  // from the roster so the stuck working pane goes away. See WORKING_TTL_MS.
195
240
  if (c.kind === 'working' && now() - (rec.ts || 0) > WORKING_TTL_MS) continue;
241
+ if (c.kind === 'compacting' && now() - (rec.ts || 0) > COMPACTING_TTL_MS) continue;
196
242
  const loc = lp ? { session: lp.session, window: lp.window, windowName: lp.windowName } : {};
197
243
  if (allow && !allow.has(loc.session)) continue;
198
244
  out[pane] = { ...loc, kind: c.kind, msg: c.msg || '', ts: rec.ts || 0, agent: agent.id };
@@ -251,5 +297,20 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
251
297
  }
252
298
  function stop() { if (watcher) { watcher.close(); watcher = null; } clearTimeout(deb); }
253
299
 
254
- return { getStates, start, stop };
300
+ // The chat lens's pane→session bind: the hook state file records THIS pane's exact session (session_id +
301
+ // transcript_path), authoritative over the terminal-side cwd→newest-jsonl guess (which collapses distinct
302
+ // sessions that happen to share a cwd — see transcript.js). Returns null when hooks are off / the pane
303
+ // isn't a Claude pane / the recorded payload carries no session info, so callers can fall back cleanly.
304
+ function paneSession(pane) {
305
+ const rec = readStateFile(file)[pane];
306
+ const p = rec && rec.payload;
307
+ if (!p || typeof p !== 'object') return null;
308
+ const transcriptPath = typeof p.transcript_path === 'string' ? p.transcript_path : null;
309
+ const sessionId = typeof p.session_id === 'string' ? p.session_id : null;
310
+ const cwd = typeof p.cwd === 'string' ? p.cwd : null;
311
+ if (!transcriptPath && !sessionId) return null;
312
+ return { sessionId, transcriptPath, cwd };
313
+ }
314
+
315
+ return { getStates, start, stop, paneSession };
255
316
  }
@@ -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
  }
@@ -0,0 +1,51 @@
1
+ // Per-device manual-push inbox: each subscribed device gets its own file `<NOTIF_DIR>/<pushKey>.json`, so a
2
+ // `--device`/`--session`-scoped push only lands in the targeted devices' inboxes (delete/read are naturally
3
+ // per-device too). NOTIF_DIR is injected by the CLI (~/.handmux/notifications) — NEVER the package-internal
4
+ // default, which a global reinstall wipes. Low-frequency, so each op is a plain read-modify-write of one
5
+ // device file (no in-memory state). The same push shares one record id across its target devices so a
6
+ // notification tap's inboxId resolves on whichever device opens it.
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import crypto from 'node:crypto';
10
+ import { readJsonArray, writeJsonAtomic } from './jsonStore.js';
11
+
12
+ const here = path.dirname(fileURLToPath(import.meta.url));
13
+ const DIR = process.env.NOTIF_DIR || path.resolve(here, '../data/notifications');
14
+ const CAP = 100;
15
+ const genId = () => crypto.randomBytes(9).toString('base64url');
16
+
17
+ // pushKey is base64url already; sanitize anyway so a hostile value can't escape DIR. Empty → null (skip).
18
+ function fileFor(key) {
19
+ const safe = String(key || '').replace(/[^A-Za-z0-9_-]/g, '');
20
+ return safe ? path.join(DIR, `${safe}.json`) : null;
21
+ }
22
+ const load = (file) => readJsonArray(file).filter((n) => n && typeof n.title === 'string');
23
+
24
+ export function record(pushKeys, { title, body, tag, url } = {}) {
25
+ const rec = { id: genId(), ts: Date.now(), title: String(title ?? ''), body: String(body ?? '') };
26
+ if (tag) rec.tag = String(tag);
27
+ if (url) rec.url = String(url);
28
+ for (const key of pushKeys || []) {
29
+ const file = fileFor(key);
30
+ if (!file) continue;
31
+ const items = load(file);
32
+ items.push(rec);
33
+ writeJsonAtomic(file, items.length > CAP ? items.slice(items.length - CAP) : items);
34
+ }
35
+ return rec;
36
+ }
37
+
38
+ export function list(pushKey) {
39
+ const file = fileFor(pushKey);
40
+ return file ? load(file).reverse() : [];
41
+ }
42
+
43
+ export function remove(pushKey, id) {
44
+ const file = fileFor(pushKey);
45
+ if (!file) return false;
46
+ const items = load(file);
47
+ const kept = items.filter((n) => n.id !== id);
48
+ if (kept.length === items.length) return false;
49
+ writeJsonAtomic(file, kept);
50
+ return true;
51
+ }