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/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 +4 -4
- package/public/assets/index-BT03K2MQ.css +32 -0
- package/public/assets/index-Ddagv-Lc.js +330 -0
- package/public/index.html +2 -2
- package/public/sw.js +17 -5
- package/src/agents/claude.js +91 -2
- package/src/agents/index.js +3 -1
- package/src/claudeEvents.js +79 -6
- 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 +9 -3
- 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-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
|
-
|
|
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
|
@@ -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 {
|
|
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
|
package/src/agents/index.js
CHANGED
|
@@ -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; }
|
package/src/claudeEvents.js
CHANGED
|
@@ -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 (
|
|
88
|
-
return { kind: 'working', msg: '' };
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|
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
|
}
|