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/README.md +1 -0
- package/README.zh-CN.md +1 -0
- package/bin/handmux.js +6 -2
- package/hooks/handmux-notify.sh +3 -1
- package/hooks/handmux-statusline.cjs +25 -0
- package/hooks/handmux-write.cjs +7 -1
- package/package.json +3 -3
- package/public/assets/index-BT03K2MQ.css +32 -0
- package/public/assets/index-C8uRJNnP.js +330 -0
- package/public/index.html +2 -2
- package/public/sw.js +17 -5
- package/src/agents/claude.js +30 -0
- package/src/claudeEvents.js +64 -3
- package/src/cli/claudeHooks.js +112 -8
- package/src/cli/state.js +1 -0
- package/src/cli/statusLine.js +15 -0
- package/src/cli/supervisor.js +2 -1
- package/src/httpApi.js +6 -1
- package/src/notifications.js +51 -0
- package/src/pendingPrompt.js +163 -0
- package/src/push.js +11 -0
- package/src/routes/notifications.js +19 -0
- package/src/routes/push.js +8 -2
- package/src/routes/transcript.js +93 -0
- package/src/server.js +13 -0
- package/src/tmux/commands.js +6 -0
- package/src/transcriptParse.js +169 -0
- package/src/usage.js +13 -0
- package/public/assets/index-Cf-WEkt5.js +0 -330
- package/public/assets/index-D5UQBvph.css +0 -32
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-
|
|
52
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
package/src/agents/claude.js
CHANGED
|
@@ -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 || '' };
|
package/src/claudeEvents.js
CHANGED
|
@@ -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 (
|
|
88
|
-
return { kind: 'working', msg: '' };
|
|
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
|
-
|
|
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
|
}
|
package/src/cli/claudeHooks.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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; }
|
package/src/cli/statusLine.js
CHANGED
|
@@ -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()) {
|
package/src/cli/supervisor.js
CHANGED
|
@@ -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
|
+
}
|