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.
@@ -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
+ }
@@ -0,0 +1,163 @@
1
+ // Parse a pending interactive PROMPT (an AskUserQuestion menu — single OR multi-question — or a tool-
2
+ // permission menu) out of a Claude Code pane's `capture-pane` text. This is deliberately screen-scraping: a
3
+ // pending prompt's options exist ONLY in the rendered TUI — they are NOT written to the session .jsonl until
4
+ // AFTER the user answers (verified live), so the transcript can't be the source. Same approach amux/
5
+ // VibeTunnel/Conductor use for the "attach to a running TUI" model; there is no structured channel short of
6
+ // relaunching Claude via the SDK.
7
+ //
8
+ // Verified against a live Claude Code (2026-07-17):
9
+ //
10
+ // SINGLE question / permission menu: MULTI-question (tabbed): REVIEW / submit screen:
11
+ // ☐ 颜色 ← header (optional) ← ☒ 水果 ☐ 颜色 ✔ Submit → ← ☒ 水果 ☒ 颜色 ✔ Submit →
12
+ // 你喜欢哪个? ← question 选个颜色? ← current tab Review your answers
13
+ // ❯ 1. 红色 ← ❯ marks the cursor ❯ 1. 红 ❯ 1. Submit answers
14
+ // 热情、醒目 ← description (optional) 2. 蓝 2. Cancel
15
+ // 2. 蓝色 Enter to select · Tab/… · Esc (NO footer line)
16
+ // 4. Type something. ← built-in meta (dropped)
17
+ // Enter to select · ↑/↓ to navigate · Esc to cancel
18
+ //
19
+ // Driving (verified live): sending the literal DIGIT of an option selects it. For a SINGLE question it
20
+ // selects AND submits immediately; for MULTI it selects AND auto-advances to the next tab; on the review
21
+ // screen digit 1 = "Submit answers". So the caller drives the whole flow by sending `String(option.n)` and
22
+ // re-polling — each screen (Q1 → Q2 → review) is itself a parseable menu. Escape cancels.
23
+ //
24
+ // The parser ANCHORS on the ❯ cursor line, NOT a footer: the review screen has options but no footer.
25
+
26
+ // Cursor-selected option line: ❯ (or ›»>) then "N.".
27
+ const CURSOR_RE = /^\s*[❯›»>]\s*\d+\.\s/;
28
+ // Any option row: optional cursor, then "N. label".
29
+ const OPTION_RE = /^\s*[❯›»>]?\s*(\d+)\.\s+(.+?)\s*$/;
30
+ // A description row: indented, not itself an option.
31
+ const isDesc = (l) => /^\s{3,}\S/.test(l) && !OPTION_RE.test(l);
32
+ // A horizontal rule Claude draws inside/around the card (kept within an option block so meta rows past it
33
+ // are still seen and dropped).
34
+ const RULE_RE = /^[\s─━—–\-_=·⎯]{8,}$/;
35
+ // The multi-question tab strip: "← ☒ 水果 ☐ 颜色 ✔ Submit →".
36
+ const TAB_BAR_RE = /✔\s*Submit|[☐☒].*[☐☒]/;
37
+ // Claude's built-in trailing meta-options — not real answers.
38
+ const META_LABELS = new Set(['type something', 'type something.', 'chat about this', 'chat about this.']);
39
+ // Leading decoration on the header line.
40
+ const HEADER_DECOR_RE = /^[\s☐☑☒◯●○·•\-*]+/;
41
+ // A menu footer (single-question / permission), used only as a title boundary — NOT required to detect a menu.
42
+ const FOOTER_RE = /enter to select|esc to (cancel|reject)/i;
43
+ // A Claude activity/spinner line — the top edge of the card, above which is prior transcript.
44
+ const ACTIVITY_RE = /^\s*[✻✳✶✽⏺]\s|worked for|cogitated|crafting|cooked for|crunched for|esc to interrupt/i;
45
+ // The input cursor ❯ leading the user's echoed prompt (above the card).
46
+ const PROMPT_ECHO_RE = /^\s*[❯›»>]\s*\D/; // ❯ NOT followed by a digit (that would be an option)
47
+ const isTitleBoundary = (l) =>
48
+ ACTIVITY_RE.test(l) || RULE_RE.test(l) || TAB_BAR_RE.test(l) || FOOTER_RE.test(l) || PROMPT_ECHO_RE.test(l);
49
+
50
+ const stripRight = (s) => String(s == null ? '' : s).replace(/\s+$/, '');
51
+ // Strip ANSI/OSC escapes so a capture taken WITH `-e` (SGR) still parses — belt-and-suspenders even though
52
+ // the endpoint captures plain.
53
+ const stripAnsi = (s) => String(s || '').replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '').replace(/\x1b[[0-9;?]*[ -/]*[@-~]/g, '');
54
+
55
+ // The assistant text immediately PRECEDING the menu, scraped because the turn (text + AskUserQuestion) is NOT
56
+ // flushed to the session jsonl until AFTER the user answers (verified live: probe + out-of-order line
57
+ // timestamps) — so the 对话 lens's transcript view structurally can't show it while the gate is up. The title
58
+ // walk below stops at the first boundary (activity spinner / box rule / prompt echo), which is exactly where
59
+ // the lead-in text usually sits. Walk on upward: skip boundary chrome, take the nearest content block, keep
60
+ // its LAST lines (the freshest context, "问题的最后一行"). `fromIdx` = the first line NOT consumed by the
61
+ // title walk, so adjacent lead-in the title already absorbed is never duplicated here.
62
+ const LEADIN_MAX = 2;
63
+ const MSG_MARK_RE = /^\s*⏺/; // ⏺ opens an assistant message in the scrollback — include it, stop there
64
+ function extractLeadIn(lines, fromIdx) {
65
+ const block = [];
66
+ let i = fromIdx;
67
+ // Skip the chrome between the menu and the preceding text (spinner / rules / tab bar / stale footer /
68
+ // blanks) — but never past a ❯ prompt echo (above it is the PREVIOUS exchange, stale context) nor past a
69
+ // ⏺ message head (it IS the text's first line — the collect loop includes it).
70
+ while (i >= 0 && (!lines[i].trim() || (isTitleBoundary(lines[i]) && !PROMPT_ECHO_RE.test(lines[i]) && !MSG_MARK_RE.test(lines[i])))) i--;
71
+ for (; i >= 0 && block.length < 12; i--) {
72
+ const l = lines[i];
73
+ if (!l.trim()) break; // blank = the block's top (last paragraph)
74
+ if (MSG_MARK_RE.test(l)) { block.unshift(l.trim()); break; } // the ⏺ message head: include & stop
75
+ if (isTitleBoundary(l)) break; // hard chrome (incl. ❯ echo): stop
76
+ block.unshift(l.trim());
77
+ }
78
+ const tail = block.slice(-LEADIN_MAX).map((l) => l.replace(/^⏺\s*/, '').trim()).filter(Boolean);
79
+ return tail.length ? tail.join(' ') : null;
80
+ }
81
+
82
+ // Parse the multi-question tab strip → [{ label, answered }]. ☒ = answered, ☐ = not. "✔ Submit" is excluded.
83
+ function parseTabs(line) {
84
+ const tabs = [];
85
+ const re = /([☐☒])\s*(\S+)/g;
86
+ let m;
87
+ while ((m = re.exec(line))) tabs.push({ label: m[2], answered: m[1] === '☒' });
88
+ return tabs;
89
+ }
90
+
91
+ // capture-pane text → a normalized pending prompt, or null when no interactive menu is on screen.
92
+ // { kind:'question'|'permission', title, options:[{n,label,description}], cursor,
93
+ // multi, step, total, submit }
94
+ // `n` is the digit to send to pick that option. For multi, `step`/`total` drive the "第 i/N 题" progress and
95
+ // `submit` marks the final review screen (options are Submit answers / Cancel).
96
+ export function parsePendingPrompt(text) {
97
+ const lines = stripAnsi(text).split('\n').map(stripRight);
98
+
99
+ // Anchor on the cursor-selected option (present in every menu: question, permission, review). Take the
100
+ // LAST one so stale menus higher in the scrollback are ignored — the live menu is at the bottom.
101
+ const anchor = lines.findLastIndex((l) => CURSOR_RE.test(l));
102
+ if (anchor < 0) return null;
103
+
104
+ // The option block = the contiguous run of option / description / rule lines around the anchor.
105
+ const inBlock = (l) => OPTION_RE.test(l) || isDesc(l) || RULE_RE.test(l);
106
+ let top = anchor;
107
+ let bot = anchor;
108
+ while (top - 1 >= 0 && inBlock(lines[top - 1])) top--;
109
+ while (bot + 1 < lines.length && inBlock(lines[bot + 1])) bot++;
110
+
111
+ const options = [];
112
+ let cursor = null;
113
+ for (let i = top; i <= bot; i++) {
114
+ const m = lines[i].match(OPTION_RE);
115
+ if (!m) continue;
116
+ const n = Number(m[1]);
117
+ const label = m[2].trim();
118
+ if (CURSOR_RE.test(lines[i])) cursor = n;
119
+ let description = '';
120
+ for (let j = i + 1; j <= bot; j++) {
121
+ if (OPTION_RE.test(lines[j])) break;
122
+ if (!isDesc(lines[j])) break;
123
+ description += (description ? ' ' : '') + lines[j].trim();
124
+ }
125
+ if (META_LABELS.has(label.toLowerCase())) continue; // drop Claude's built-in meta-options
126
+ options.push({ n, label, description });
127
+ }
128
+ if (!options.length) return null;
129
+
130
+ // Title = header/question text above the block, skipping in-card blanks, stopping at the card's top edge.
131
+ const head = [];
132
+ let headStop = top - 1;
133
+ for (let i = top - 1; i >= 0 && head.length < 5; i--) {
134
+ if (!lines[i].trim()) { headStop = i - 1; continue; }
135
+ if (isTitleBoundary(lines[i])) { headStop = i; break; }
136
+ head.unshift(lines[i]);
137
+ headStop = i - 1;
138
+ }
139
+ const title = head.map((l) => l.replace(HEADER_DECOR_RE, '').trim()).filter(Boolean).join(' — ') || '需要你选择';
140
+
141
+ // Multi-question: the tab strip drives progress. `answered` ☒ tabs → current step is the next unanswered.
142
+ const tabLine = lines.find((l) => TAB_BAR_RE.test(l));
143
+ const tabs = tabLine ? parseTabs(tabLine) : [];
144
+ const multi = tabs.length > 1;
145
+ const answered = tabs.filter((t) => t.answered).length;
146
+ const submit = /^submit answers?$/i.test(options[0].label) || (multi && answered >= tabs.length);
147
+
148
+ const kind = /do you want to proceed/i.test(title) || /^yes\b/i.test(options[0].label)
149
+ ? 'permission' : 'question';
150
+
151
+ const out = { kind, title, options, cursor };
152
+ // Lead-in context the title walk didn't absorb (it stopped at a boundary or its 5-line cap) — the
153
+ // assistant's last line(s) before the question, shown above the gate so the phone isn't asked blind.
154
+ const leadIn = extractLeadIn(lines, headStop);
155
+ if (leadIn) out.leadIn = leadIn;
156
+ if (multi) {
157
+ out.multi = true;
158
+ out.total = tabs.length;
159
+ out.step = Math.min(answered + 1, tabs.length);
160
+ out.submit = submit;
161
+ }
162
+ return out;
163
+ }
package/src/push.js CHANGED
@@ -129,6 +129,17 @@ export const sendToDevices = (keys, payload, opts = {}) =>
129
129
  export const sendToSessions = (sessions, payload, opts = {}) =>
130
130
  deliver(subs.filter((s) => s.boundSessions.some((x) => sessions.includes(x))), payload, opts);
131
131
 
132
+ // The pushKeys a given scope resolves to (mirrors sendToDevices/sendToSessions/sendToAll targeting) — used
133
+ // by the inbox to write a record into exactly the devices a manual push is delivered to.
134
+ export const resolveTargetKeys = ({ devices, sessions } = {}) => {
135
+ const pick = (devices && devices.length)
136
+ ? subs.filter((s) => devices.includes(s.pushKey))
137
+ : (sessions && sessions.length)
138
+ ? subs.filter((s) => s.boundSessions.some((x) => sessions.includes(x)))
139
+ : subs;
140
+ return [...new Set(pick.map((s) => s.pushKey).filter(Boolean))];
141
+ };
142
+
132
143
  // Back-compat: the /push/subscribe welcome still pushes to a single just-added subscription.
133
144
  export async function sendToOne(sub, payload, opts = {}) {
134
145
  const rec = subs.find((s) => s.subscription.endpoint === sub.endpoint)
@@ -0,0 +1,19 @@
1
+ // Read/delete for the manual-push inbox. Recording happens in routes/push.js (send-local). This
2
+ // module is single-purpose: list history (newest first) and delete one entry by id.
3
+ import express from 'express';
4
+
5
+ export function notificationRoutes({ notifications }) {
6
+ const r = express.Router();
7
+
8
+ r.get('/notifications', (req, res) => {
9
+ const device = req.query.device;
10
+ res.json({ items: typeof device === 'string' && device ? notifications.list(device) : [] });
11
+ });
12
+
13
+ r.delete('/notifications/:id', (req, res) => {
14
+ const device = req.query.device;
15
+ res.json({ ok: typeof device === 'string' && device ? notifications.remove(device, req.params.id) : false });
16
+ });
17
+
18
+ return r;
19
+ }
@@ -2,7 +2,7 @@
2
2
  // the local script-push send entry. The push module owns the delivery contract (TTL/topic/prune).
3
3
  import express from 'express';
4
4
 
5
- export function pushRoutes({ push }) {
5
+ export function pushRoutes({ push, notifications }) {
6
6
  const r = express.Router();
7
7
 
8
8
  // The client needs the VAPID public key to subscribe; 503 if the server has no keys configured.
@@ -60,9 +60,15 @@ export function pushRoutes({ push }) {
60
60
  if (hasSessions && hasDevices) return res.status(400).json({ error: 'use --session or --device, not both' });
61
61
  const payload = { title, body };
62
62
  if (typeof tag === 'string' && tag) payload.tag = tag;
63
- if (typeof url === 'string' && url) payload.data = { url };
64
63
  const opts = { urgency: 'normal', ttl: 1800 };
65
64
  if (payload.tag) opts.topic = payload.tag;
65
+ // Record FIRST so the notification tap can deep-link to this exact message's detail page. `--url`
66
+ // is stored on the record (surfaced in the detail), NOT used as the tap target.
67
+ if (notifications) {
68
+ const targetKeys = push.resolveTargetKeys({ devices: hasDevices ? devices : null, sessions: hasSessions ? sessions : null });
69
+ const rec = notifications.record(targetKeys, { title, body, tag: payload.tag, url });
70
+ payload.data = { inboxId: rec.id };
71
+ }
66
72
  try {
67
73
  const out = hasDevices ? await push.sendToDevices(devices, payload, opts)
68
74
  : hasSessions ? await push.sendToSessions(sessions, payload, opts)
@@ -0,0 +1,93 @@
1
+ // Read a pane's Claude Code jsonl session and return it as normalized chat messages (the "对话" lens's
2
+ // read-projection). Pane → cwd (tmux) → the session file under ~/.claude/projects/<encoded-cwd>/.
3
+ // Server-side paginated — the phone must never receive the whole transcript:
4
+ // - Recent window (default + polling): ?pane=&limit=10&since=<hash> — the last `limit` messages, with
5
+ // the same content-hash `since`省流 as /history (unchanged window → 204). `hasMore`/`firstSeq` tell the
6
+ // client whether/where an older page starts.
7
+ // - History page (scroll-up, not polled): ?pane=&before=<k>&limit=10 — the last `limit` messages with
8
+ // `k < before`, no hash.
9
+ // `k` = each message's global ordinal from `all.map((m,k)=>({...m,k}))` — stable because the jsonl is
10
+ // append-only, so it doubles as the client's dedup key. `limit` clamps to [1,100], default 10. Mounted
11
+ // under /api.
12
+ import express from 'express';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { createHash } from 'node:crypto';
16
+ import { isPaneId } from '../tmux/commands.js';
17
+ import { projectsDir } from '../agents/claude.js';
18
+ import { resolveEncodedDirSession, encodeProjectDir } from '../agents/scanUtils.js';
19
+ import { parseTranscript } from '../transcriptParse.js';
20
+ import { parsePendingPrompt } from '../pendingPrompt.js';
21
+ import { readClaudeContext } from '../usage.js';
22
+
23
+ export function transcriptRoutes({ commands, claudeEvents }) {
24
+ const r = express.Router();
25
+
26
+ // The pending interactive PROMPT on the pane's screen — an AskUserQuestion menu or a tool-permission
27
+ // menu — scraped from `capture-pane` (its options are NOT in the .jsonl while pending; see pendingPrompt.js).
28
+ // Returns { prompt: {kind,title,options,cursor} | null }. Polled by the 对话 lens only while a gate is up.
29
+ r.get('/pending-prompt', async (req, res, next) => {
30
+ if (!isPaneId(req.query.pane)) return res.status(400).json({ error: 'bad pane id' });
31
+ try {
32
+ const text = await commands.capturePlain(req.query.pane);
33
+ return res.json({ prompt: parsePendingPrompt(text) });
34
+ } catch (e) { next(e); }
35
+ });
36
+
37
+ // The pane's CURRENT context-window occupancy (model + used %) — the number Claude Code shows before
38
+ // auto-compact. Joined pane→session (hook state) → the statusLine capturer's per-session snapshot. Returns
39
+ // { model, usedPercent } (either may be null: capturer not opted in / session hasn't rendered / no hooks).
40
+ // The 对话 composer polls this to show a small "模型 · 24%" chip. Best-effort: never 500 on a missing file.
41
+ r.get('/context', (req, res, next) => {
42
+ if (!isPaneId(req.query.pane)) return res.status(400).json({ error: 'bad pane id' });
43
+ try {
44
+ const hooked = claudeEvents && claudeEvents.paneSession ? claudeEvents.paneSession(req.query.pane) : null;
45
+ const sid = hooked && (hooked.sessionId || (hooked.transcriptPath ? path.basename(hooked.transcriptPath).replace(/\.jsonl$/, '') : null));
46
+ const ctx = sid ? readClaudeContext(sid) : null;
47
+ return res.json({ model: (ctx && ctx.model) || null, usedPercent: (ctx && typeof ctx.usedPercent === 'number') ? ctx.usedPercent : null });
48
+ } catch (e) { next(e); }
49
+ });
50
+
51
+ r.get('/transcript', async (req, res, next) => {
52
+ if (!isPaneId(req.query.pane)) return res.status(400).json({ error: 'bad pane id' });
53
+ const limit = Math.min(Math.max(Number(req.query.limit) || 10, 1), 100);
54
+ const before = req.query.before != null && req.query.before !== '' ? Number(req.query.before) : null;
55
+ try {
56
+ // Bind pane→session via the hook state's per-pane transcript_path (authoritative — see claudeEvents
57
+ // .paneSession) when available; only a pane with no hook state (hooks off / not a Claude pane) falls
58
+ // back to cwd→newest-jsonl, which can't tell apart two sessions that share a cwd.
59
+ let file = null;
60
+ let sessionId = null;
61
+ const hooked = claudeEvents && claudeEvents.paneSession ? claudeEvents.paneSession(req.query.pane) : null;
62
+ if (hooked && hooked.transcriptPath) {
63
+ file = hooked.transcriptPath;
64
+ sessionId = hooked.sessionId || path.basename(file).replace(/\.jsonl$/, '');
65
+ }
66
+ if (!file) {
67
+ const cwd = await commands.paneCurrentPath(req.query.pane);
68
+ const dir = projectsDir();
69
+ const resolved = await resolveEncodedDirSession(dir, cwd);
70
+ if (resolved.sessionId) {
71
+ file = path.join(dir, encodeProjectDir(cwd), resolved.sessionId + '.jsonl');
72
+ sessionId = resolved.sessionId;
73
+ }
74
+ }
75
+ const empty = { messages: [], hash: '', session: sessionId || null, hasMore: false, firstSeq: null };
76
+ if (!file) return res.json(empty);
77
+ let text;
78
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return res.json(empty); }
79
+ const all = parseTranscript(text.split('\n')).map((m, k) => ({ ...m, k })); // k = stable global ordinal
80
+ const pool = before == null ? all : all.filter((m) => m.k < before);
81
+ const messages = pool.slice(-limit);
82
+ const firstSeq = messages.length ? messages[0].k : null;
83
+ const hasMore = pool.length > messages.length;
84
+ if (before == null) {
85
+ const hash = createHash('sha1').update(JSON.stringify(messages)).digest('hex').slice(0, 16);
86
+ if (req.query.since === hash) return res.status(204).end();
87
+ return res.json({ messages, hash, session: sessionId, hasMore, firstSeq });
88
+ }
89
+ return res.json({ messages, session: sessionId, hasMore, firstSeq });
90
+ } catch (e) { next(e); }
91
+ });
92
+ return r;
93
+ }
package/src/server.js CHANGED
@@ -7,6 +7,8 @@ import { loadToken } from './auth.js';
7
7
  import { createApiRouter } from './httpApi.js';
8
8
  import { loadUploadExts } from './uploadTypes.js';
9
9
  import { createClaudeEvents } from './claudeEvents.js';
10
+ import { syncHooks } from './cli/claudeHooks.js';
11
+ import { claudeStatePath } from './cli/state.js';
10
12
  import * as commands from './tmux/commands.js';
11
13
  import * as push from './push.js';
12
14
  import { cacheControlFor } from './staticCache.js';
@@ -32,6 +34,17 @@ const uploadExts = loadUploadExts();
32
34
  const events = createClaudeEvents({ commands, push });
33
35
  events.start();
34
36
 
37
+ // Keep an already-opted-in user's Claude hooks in step with this handmux version on restart: newly-added
38
+ // lifecycle events (e.g. SessionStart, which rebinds the 对话 lens after /clear) and a refreshed
39
+ // handmux-write.cjs land via `./deploy.sh` alone — no phone re-enable. A strict no-op unless our hooks are
40
+ // already installed; best-effort and must never block or crash startup (pure fs, no subprocess).
41
+ try {
42
+ syncHooks(homedir(), {
43
+ srcDir: path.resolve(here, '../hooks'),
44
+ stateFile: process.env.CLAUDE_STATE_FILE || claudeStatePath(homedir()),
45
+ });
46
+ } catch { /* best effort — hook sync never fails startup */ }
47
+
35
48
  // Static-site + dynamic preview. The dynamic side is enabled by HANDMUX_PREVIEW_DOMAIN (the wildcard
36
49
  // base domain, e.g. preview.example.com); unset → static only. One registry instance is shared by the
37
50
  // API (register/list/remove), the /preview static layer, and the Host-based dynamic proxy.
@@ -78,10 +78,10 @@ export async function listPaneIds(target) {
78
78
  // per-pane display-message. The hook only records the pane id; location comes from here, always fresh.
79
79
  export async function listLivePanes() {
80
80
  return lines(await runTmux(['list-panes', '-a', '-F',
81
- '#{pane_id}\t#{pane_current_command}\t#{session_name}\t#{window_id}\t#{window_name}']))
81
+ '#{pane_id}\t#{pane_current_command}\t#{pane_tty}\t#{session_name}\t#{window_id}\t#{window_name}']))
82
82
  .map((l) => {
83
- const [id, cmd, session, window, windowName] = l.split('\t');
84
- return { id, cmd, session, window, windowName };
83
+ const [id, cmd, tty, session, window, windowName] = l.split('\t');
84
+ return { id, cmd, tty, session, window, windowName };
85
85
  });
86
86
  }
87
87
 
@@ -94,6 +94,12 @@ export async function capturePane(paneId, linesBack) {
94
94
  return runTmux(['capture-pane', '-p', '-e', '-N', '-S', String(-Math.abs(linesBack)), '-t', paneId]);
95
95
  }
96
96
 
97
+ // Plain visible-screen capture — NO SGR escapes (unlike capturePane's `-e`), so the text parses cleanly.
98
+ // Used to scrape a pending prompt/menu off the screen (see pendingPrompt.js).
99
+ export async function capturePlain(paneId) {
100
+ return runTmux(['capture-pane', '-p', '-t', paneId]);
101
+ }
102
+
97
103
  // Size AND cursor in one display-message (capture-pane carries neither the cursor position nor its
98
104
  // visibility — it snapshots cells only — so we read them here for the client to re-place xterm's own
99
105
  // cursor onto Claude's input cell). cursor_x/cursor_y are 0-based, relative to the visible screen;
@@ -0,0 +1,169 @@
1
+ // Pure: a Claude Code jsonl session log (array of lines) → normalized chat messages. No I/O.
2
+ // Only user/assistant turns become bubbles; every other top-level `type` (attachment, system, last-prompt,
3
+ // mode, permission-mode, ai-title, file-history-snapshot, queue-operation, …) is skipped. tool_use and its
4
+ // later tool_result are folded into ONE tool message (paired by tool_use_id); an unmatched tool_result is
5
+ // dropped (its tool_use is in an earlier, not-yet-loaded chunk). Bad/blank lines are skipped, never thrown.
6
+ //
7
+ // Meta scaffolding is DROPPED so the 对话 lens shows only real turns; the reliable signal is the top-level
8
+ // boolean flags — `isMeta` (skill/workflow injections, "Base directory for this skill…", caveats) and
9
+ // `isCompactSummary` (the "session is being continued…" wall) — NOT text matching.
10
+ //
11
+ // Slash commands (`/compact`, `/model`, `/clear`, …) are NOT dropped — they surface as a quiet 'slash'
12
+ // marker (a centered system row), because a command the user ran IS part of the conversation and hiding it
13
+ // leaves the phone with no feedback. Claude Code logs each as a `<command-name>/x</command-name>` USER turn
14
+ // (the canonical form; the bare "/x" input is a separate queue-operation / plain user line we still drop, to
15
+ // avoid a double). The command's `<local-command-stdout>` echo is folded onto the preceding marker as its
16
+ // `.result` (ANSI-stripped, capped) — and its PRESENCE means the command COMPLETED. An interactive picker
17
+ // (bare `/model`, `/plugin`, …) still open in the terminal has written no result yet, which is exactly how
18
+ // the UI tells a finished command from one that needs the user to drop to the terminal lens and pick.
19
+ // Matching is a tag prefix ANCHORED at the start of a USER turn's text: an assistant reply that merely
20
+ // mentions `<command-name>` in prose (e.g. discussing this very code) must NOT be caught.
21
+ const KEEP = new Set(['user', 'assistant']);
22
+ const SCAFFOLD_RE = /^\s*<(?:command-name|command-message|command-args|local-command-stdout|local-command-caveat|bash-input|bash-stdout|bash-stderr)>/;
23
+ // Besides the <command-name> scaffold form, Claude Code ALSO logs the raw slash-command input as a plain,
24
+ // flagless user turn (content exactly "/compact", "/model sonnet", …) — redundant with the scaffold marker,
25
+ // and after a /compact it's the LAST turn, so leaving it in would make the 对话 lens read a trailing user
26
+ // bubble and light the "reply coming" typing wave forever. Drop it — anchored to a single leading /command
27
+ // token followed by whitespace/EOL, so a path-like message ("/Users/demo/foo.js …") or prose is NOT eaten.
28
+ const SLASH_CMD_RE = /^\s*\/[a-z][\w-]*(?:\s|$)/i;
29
+ // The canonical scaffold form of a slash command: capture the name and any args, so the marker can tell a
30
+ // bare `/model` (may open a picker) from `/model sonnet` (applies directly). The stdout echo is a separate
31
+ // user turn right after; its inner text (closing tag stripped, ANSI stripped, capped) becomes the result.
32
+ const CMD_NAME_RE = /^\s*<command-name>\s*\/?([\w-]+)\s*<\/command-name>/i;
33
+ const CMD_ARGS_RE = /<command-args>([\s\S]*?)<\/command-args>/i;
34
+ const STDOUT_RE = /^\s*<local-command-stdout>([\s\S]*)$/i;
35
+ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
36
+ const SLASH_RESULT_CAP = 140;
37
+ const stripAnsi = (s) => (typeof s === 'string' ? s.replace(ANSI_RE, '') : '');
38
+
39
+ // A file-edit diff from a jsonl line's top-level `toolUseResult`, or null if the tool didn't edit a file.
40
+ // Non-empty structuredPatch → count +/- lines and keep the hunks (for the expandable coloured view). Empty
41
+ // patch but a create → every content line is an addition. Anything else (Bash/Read/…) → null.
42
+ function extractDiff(r) {
43
+ if (!r || typeof r !== 'object') return null;
44
+ const patch = Array.isArray(r.structuredPatch) ? r.structuredPatch : null;
45
+ if (patch && patch.length) {
46
+ let added = 0, removed = 0;
47
+ const hunks = [];
48
+ for (const h of patch) {
49
+ const lines = Array.isArray(h.lines) ? h.lines : [];
50
+ for (const ln of lines) { const c = typeof ln === 'string' ? ln[0] : ''; if (c === '+') added++; else if (c === '-') removed++; }
51
+ hunks.push({ oldStart: h.oldStart, newStart: h.newStart, lines });
52
+ }
53
+ return { added, removed, hunks };
54
+ }
55
+ if (r.type === 'create' && typeof r.content === 'string') {
56
+ return { added: r.content ? r.content.split('\n').length : 0, removed: 0, hunks: null, created: true };
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function resultText(content) {
62
+ if (typeof content === 'string') return content;
63
+ if (Array.isArray(content)) return content.map((c) => (c && c.type === 'text' ? (c.text || '') : '')).join('');
64
+ return '';
65
+ }
66
+
67
+ // Leading text of a message's content (string as-is, or the first text item of an array) — used only to
68
+ // probe for a scaffolding tag at the very start. tool_result-only user turns yield '' and are never matched.
69
+ function leadingText(content) {
70
+ if (typeof content === 'string') return content;
71
+ if (Array.isArray(content)) {
72
+ const t = content.find((c) => c && c.type === 'text');
73
+ return t ? (t.text || '') : '';
74
+ }
75
+ return '';
76
+ }
77
+
78
+ export function parseTranscript(lines) {
79
+ const msgs = [];
80
+ const byToolId = new Map(); // tool_use_id → the tool message awaiting its result
81
+ let i = 0;
82
+ for (const raw of lines) {
83
+ const s = typeof raw === 'string' ? raw.trim() : '';
84
+ if (!s) { i++; continue; }
85
+ let o;
86
+ try { o = JSON.parse(s); } catch { i++; continue; }
87
+ const m = o && o.message;
88
+ if (!KEEP.has(o && o.type) || !m || typeof m !== 'object') { i++; continue; }
89
+ if (o.isMeta === true) { i++; continue; }
90
+ // A compaction wall (isCompactSummary): don't render the (huge) summary text as a bubble, but DO leave a
91
+ // quiet divider marker where it happened, so the 对话 lens shows "上下文已压缩" between the old and new
92
+ // context instead of the conversation silently jumping. (A no-op /compact writes no such entry → no
93
+ // divider, correctly.) Rendered centered like the interrupt marker.
94
+ if (o.isCompactSummary === true) {
95
+ msgs.push({ i, type: 'compact', ts: typeof o.timestamp === 'string' ? o.timestamp : undefined });
96
+ i++; continue;
97
+ }
98
+ // The jsonl line's wall-clock (ISO string) — carried onto each message so the 对话 lens can show a
99
+ // time separator between turns. Absent on some lines (older logs) → undefined; the UI shows nothing
100
+ // rather than a fabricated time ("有地方取就要,没有就不要").
101
+ const ts = typeof o.timestamp === 'string' ? o.timestamp : undefined;
102
+ const role = m.role === 'user' ? 'user' : 'assistant';
103
+ if (role === 'user') {
104
+ const lead = leadingText(m.content);
105
+ // A slash command → a quiet centered 'slash' marker (see the header note). Name + optional args.
106
+ const nameM = CMD_NAME_RE.exec(lead);
107
+ if (nameM) {
108
+ const argsM = CMD_ARGS_RE.exec(lead);
109
+ const args = argsM ? argsM[1].trim() : '';
110
+ const mk = { i, type: 'slash', name: '/' + nameM[1], ts };
111
+ if (args) mk.args = args;
112
+ msgs.push(mk);
113
+ i++; continue;
114
+ }
115
+ // Its stdout echo → fold onto the marker just pushed as `.result` (presence ⇒ the command completed).
116
+ const outM = STDOUT_RE.exec(lead);
117
+ if (outM) {
118
+ const last = msgs[msgs.length - 1];
119
+ if (last && last.type === 'slash' && last.result === undefined) {
120
+ const txt = stripAnsi(outM[1].replace(/<\/local-command-stdout>\s*$/i, '')).trim();
121
+ if (txt) last.result = txt.length > SLASH_RESULT_CAP ? txt.slice(0, SLASH_RESULT_CAP) + '…' : txt;
122
+ }
123
+ i++; continue;
124
+ }
125
+ // Other scaffolding (<command-message>, caveat, bash-*) and the bare "/x" input line: still dropped.
126
+ if (SCAFFOLD_RE.test(lead) || SLASH_CMD_RE.test(lead)) { i++; continue; }
127
+ }
128
+ // ESC-interrupt: Claude Code appends a standalone user line carrying a top-level `interruptedMessageId`
129
+ // (content is just "[Request interrupted by user…]"). It's NOT something the user typed — surface it as a
130
+ // quiet 'interrupt' marker (rendered as a small centered hint), never as a prominent user bubble. Detect
131
+ // by the structural field, falling back to the marker text for older logs.
132
+ if (typeof o.interruptedMessageId === 'string' || (role === 'user' && /^\[Request interrupted by user/.test(leadingText(m.content)))) {
133
+ msgs.push({ i, type: 'interrupt', ts });
134
+ i++; continue;
135
+ }
136
+ const items = typeof m.content === 'string'
137
+ ? [{ type: 'text', text: m.content }]
138
+ : Array.isArray(m.content) ? m.content : [];
139
+ for (const it of items) {
140
+ if (!it || typeof it !== 'object') continue;
141
+ // .trim() here is a truthiness guard only (dropping whitespace-only text items to avoid empty
142
+ // bubbles) — the pushed `it.text` below is the untrimmed original, so real text's internal/
143
+ // leading/trailing whitespace is preserved verbatim.
144
+ if (it.type === 'text' && it.text && it.text.trim()) {
145
+ msgs.push({ i, role, type: 'text', text: it.text, ts });
146
+ } else if (it.type === 'thinking' && it.thinking) {
147
+ msgs.push({ i, role: 'assistant', type: 'thinking', text: it.thinking, ts });
148
+ } else if (it.type === 'tool_use') {
149
+ const tm = { i, role: 'assistant', type: 'tool', ts, tool: { name: it.name || '', input: it.input || {}, result: null, isError: false } };
150
+ if (it.id) byToolId.set(it.id, tm);
151
+ msgs.push(tm);
152
+ } else if (it.type === 'tool_result') {
153
+ const tm = it.tool_use_id && byToolId.get(it.tool_use_id);
154
+ if (tm) {
155
+ tm.tool.result = resultText(it.content);
156
+ tm.tool.isError = !!it.is_error;
157
+ // Claude Code stores a real per-hunk diff for file edits on the SAME line's top-level
158
+ // `toolUseResult.structuredPatch` (hunks of {oldStart,newStart,lines[]}, each line prefixed
159
+ // +/-/space) — the exact data the CLI renders. Fold it into the tool message so the 对话 chip
160
+ // can show +A/−B and open the coloured diff, instead of the bland "…updated successfully" string.
161
+ // A file CREATE has an empty patch but `type:'create'` + `content` → treat every line as added.
162
+ tm.tool.diff = extractDiff(o.toolUseResult);
163
+ }
164
+ }
165
+ }
166
+ i++;
167
+ }
168
+ return msgs;
169
+ }
package/src/usage.js CHANGED
@@ -11,6 +11,19 @@ import { homedir } from 'node:os';
11
11
  import { pocketHome } from './cli/state.js';
12
12
 
13
13
  export function claudeUsagePath(home = homedir()) { return path.join(pocketHome(home), 'claude-usage.json'); }
14
+ export function claudeContextDir(home = homedir()) { return path.join(pocketHome(home), 'context'); }
15
+
16
+ // Per-session context-window snapshot the statusLine capturer writes to ~/.handmux/context/<sessionId>.json
17
+ // ({ model, usedPercent, updatedAt }). null if the capturer isn't wired, the session never rendered, or the
18
+ // id is unsafe. Used to show the CURRENT pane's context % (the global claude-usage.json can't — it's one
19
+ // last-writer-wins snapshot across all sessions). sessionId is sanitised to keep the read inside the dir.
20
+ export function readClaudeContext(sessionId, home = homedir()) {
21
+ if (typeof sessionId !== 'string' || !/^[\w-]+$/.test(sessionId)) return null;
22
+ try {
23
+ const snap = JSON.parse(fs.readFileSync(path.join(claudeContextDir(home), `${sessionId}.json`), 'utf8'));
24
+ return (snap && typeof snap === 'object' && !Array.isArray(snap)) ? snap : null;
25
+ } catch { return null; }
26
+ }
14
27
  export function codexSessionsDir(home = homedir()) { return path.join(home, '.codex', 'sessions'); }
15
28
 
16
29
  // Claude: read the statusLine snapshot. null if the capturer isn't wired / never populated it.