handmux 0.17.0 → 0.17.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/index.html CHANGED
@@ -48,7 +48,7 @@
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-C8uRJNnP.js"></script>
51
+ <script type="module" crossorigin src="/assets/index-BfJTQWOA.js"></script>
52
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>
@@ -10,7 +10,64 @@
10
10
  // sessions cwd → session resolution + the `resume` command, for orphan takeover
11
11
  import path from 'node:path';
12
12
  import os from 'node:os';
13
- import { resolveEncodedDirSession, isSessionUuid } from './scanUtils.js';
13
+ import { promises as fsp } from 'node:fs';
14
+ import { resolveEncodedDirSession, isSessionUuid, normTty } from './scanUtils.js';
15
+
16
+ // The NATIVE installer names the real binary by version (~/.local/share/claude/versions/2.1.196 —
17
+ // ~/.local/bin/claude is only a symlink to it), and tmux #{pane_current_command} follows that basename
18
+ // ("2_1_196", dots sanitized to underscores) instead of "claude". ps can't tie it: ps `comm` shows the
19
+ // process's self-set title ("claude"), which does NOT match tmux's report (verified live on a native-
20
+ // install machine). Matching any semver-shaped name would misidentify any version-named binary, so we
21
+ // corroborate with the process's REAL executable path (`lsof -d txt` on macOS, /proc/<pid>/exe on
22
+ // Linux): a pid on the pane's tty whose exe BASENAME equals tmux's version comm, and whose path carries
23
+ // "claude" — every official install layout carries it (Caskroom/claude-code@latest, .local/share/
24
+ // claude/versions, node_modules/@anthropic-ai/claude-code, …), version- and layout-proof, while a
25
+ // foreign version-named binary's path doesn't. Only then is the pane's cmd normalized to 'claude', so
26
+ // every downstream match (identity + liveness) stays exact-name. Verdicts are cached per (tty, cmd) in
27
+ // an injected Map — a cache miss costs one ps + one lsof per candidate pane, once per version per pane.
28
+ const VERSION_COMM_RE = /^\d+[._]\d+[._]\d+$/;
29
+ const CLAUDE_PATH_RE = /claude/;
30
+
31
+ // The real executable path of a pid: first txt entry from lsof (resolves symlinks → the actual binary),
32
+ // falling back to /proc on Linux where lsof output may be restricted. '' when unknowable.
33
+ async function exePath(run, pid) {
34
+ const out = await run('lsof', ['-a', '-p', String(pid), '-d', 'txt', '-Fn']);
35
+ for (const line of String(out).split('\n')) if (line[0] === 'n') return line.slice(1).trim();
36
+ try { return await fsp.readlink(`/proc/${pid}/exe`); } catch { return ''; }
37
+ }
38
+
39
+ export async function resolveVersionedComms(panes, run, verdicts = new Map()) {
40
+ const candidates = panes.filter((p) => p && p.tty && VERSION_COMM_RE.test(p.cmd || ''));
41
+ if (!candidates.length) return panes;
42
+ const key = (p) => `${normTty(p.tty)}|${p.cmd}`;
43
+ for (const p of candidates) if (verdicts.get(key(p)) === true) p.cmd = 'claude';
44
+ const pending = candidates.filter((p) => p.cmd !== 'claude' && !verdicts.has(key(p)));
45
+ if (!pending.length) return panes;
46
+
47
+ const out = await run('ps', ['-Ao', 'tty=,pid=,comm=']);
48
+ const procs = []; // [{ tty, pid, comm }]
49
+ for (const line of String(out).split('\n')) {
50
+ const m = line.match(/^\s*(\S+)\s+(\d+)\s+(.*)$/);
51
+ if (!m) continue;
52
+ const tty = normTty(m[1]);
53
+ if (tty) procs.push({ tty, pid: m[2], comm: m[3].trim() });
54
+ }
55
+ for (const p of pending) {
56
+ let ok = false;
57
+ // Tie by tty + exe BASENAME (== tmux's version comm; ps comm is the self-set title and can't be
58
+ // trusted to match), then require the real path to carry "claude" — a random semver-named binary
59
+ // elsewhere is NOT Claude.
60
+ const want = p.cmd.replace(/_/g, '.');
61
+ for (const r of procs.filter((r) => r.tty === normTty(p.tty))) {
62
+ const exe = await exePath(run, r.pid);
63
+ const base = exe.split('/').pop() || '';
64
+ if ((base === p.cmd || base === want) && CLAUDE_PATH_RE.test(exe)) { ok = true; break; }
65
+ }
66
+ verdicts.set(key(p), ok);
67
+ if (ok) p.cmd = 'claude';
68
+ }
69
+ return panes;
70
+ }
14
71
 
15
72
  // Build the 需要你 one-liner for a PermissionRequest, from the tool it's gating on (PermissionRequest
16
73
  // carries tool_name + tool_input, unlike the later permission_prompt Notification which only has an
@@ -103,7 +160,9 @@ export const claude = {
103
160
  label: 'Claude Code',
104
161
  procName: 'claude',
105
162
  // Which tmux #{pane_current_command} values mean "this agent is still the pane's foreground app" (inbox
106
- // 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.
107
166
  procNames: ['claude'],
108
167
  // The `claude` CLI sets its process title to "claude" (verified via ps): match the program token at the
109
168
  // start of argv — bare "claude", "claude --continue", or an absolute path ending in /claude. Anchored so
@@ -16,5 +16,7 @@ const BY_ID = new Map(AGENTS.map((a) => [a.id, a]));
16
16
  export function getAgent(id) { return BY_ID.get(id) || claude; }
17
17
 
18
18
  // The driver whose foreground process this tmux #{pane_current_command} is, or null. Used by the inbox to
19
- // decide a recorded pane is still running its agent (vs. the agent having exited to a shell).
19
+ // decide a recorded pane is still running its agent (vs. the agent having exited to a shell). Matches the
20
+ // canonical procName only — never the ambiguous extras in procNames (e.g. Codex's 'node'); native-install
21
+ // Claude binaries (comm = version string) are normalized to 'claude' at ingest (resolveVersionedComms).
20
22
  export function agentForProc(cmd) { return AGENTS.find((a) => a.procName === cmd) || null; }
@@ -2,6 +2,8 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { getAgent, agentForProc } from './agents/index.js';
5
+ import { resolveVersionedComms } from './agents/claude.js';
6
+ import { defaultRun } from './agents/scanUtils.js';
5
7
  import { claude } from './agents/claude.js';
6
8
 
7
9
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -134,12 +136,16 @@ function readStateFile(file) {
134
136
  }
135
137
 
136
138
  // Per-process event reader. Deps injected for testability:
137
- // commands.listLivePanes() → [{ id, cmd, session, window, windowName }] (one tmux call: liveness+location)
139
+ // commands.listLivePanes() → [{ id, cmd, tty, session, window, windowName }] (one tmux call)
140
+ // run(cmd, args) → stdout (only used by the version-named-comm corroboration; default defaultRun)
138
141
  // push.sendToSession(session, payload, {ttl, urgency, topic})
139
142
  // file: the hook-maintained JSON state file (DEFAULT_STATE_FILE).
140
143
  // The hook is the sole writer; the server reads the file fresh on every getStates and on every file
141
144
  // change (the watcher, for push). No persisted state of our own — the file IS the persistence.
142
- 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();
143
149
  const lastPushed = {}; // pane → 'needs' | 'done' | null (in-process push-transition dedup, by display view)
144
150
  // The dedup above is in-process ONLY: a restart (e.g. ./deploy.sh) wipes it while the hook's state
145
151
  // file on disk keeps every pane's latest 需要你/已完成. Without priming, the first read after boot
@@ -181,7 +187,13 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
181
187
  const allow = allowedSessions == null ? null : new Set(allowedSessions);
182
188
  const recorded = readStateFile(file);
183
189
  let live = null;
184
- try { live = new Map((await commands.listLivePanes()).map((p) => [p.id, p])); } catch { /* tmux down */ }
190
+ try {
191
+ const panes = await commands.listLivePanes();
192
+ // Native-install Claude binaries report a version string as pane_current_command — corroborate via
193
+ // ps and normalize to 'claude' BEFORE any identity/liveness match (see agents/claude.js).
194
+ await resolveVersionedComms(panes, run, commVerdicts);
195
+ live = new Map(panes.map((p) => [p.id, p]));
196
+ } catch { /* tmux down */ }
185
197
 
186
198
  const out = {};
187
199
  for (const [pane, rec] of Object.entries(recorded)) {
@@ -312,5 +324,14 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
312
324
  return { sessionId, transcriptPath, cwd };
313
325
  }
314
326
 
315
- return { getStates, start, stop, paneSession };
327
+ // Agent identity is needed independently of session metadata: a Codex pane may not have a Claude-style
328
+ // transcript path, but the current Claude-only chat lens must still fail closed instead of cwd-falling
329
+ // back to an unrelated Claude jsonl. Untagged legacy hook rows are Claude.
330
+ function paneAgent(pane) {
331
+ const rec = readStateFile(file)[pane];
332
+ if (!rec || typeof rec !== 'object') return null;
333
+ return typeof rec.agent === 'string' && rec.agent ? rec.agent : 'claude';
334
+ }
335
+
336
+ return { getStates, start, stop, paneSession, paneAgent };
316
337
  }
@@ -3,6 +3,7 @@
3
3
  // Scope is mutually exclusive: --device (by key) or --session, else all. Pure parse + injectable
4
4
  // runner so it unit-tests without spawning or real fetch.
5
5
  import { readState } from './state.js';
6
+ import { sanitizeNotificationUrl } from '../urlPolicy.js';
6
7
 
7
8
  const collect = (acc, v) => acc.concat(String(v).split(',').map((s) => s.trim()).filter(Boolean));
8
9
 
@@ -23,11 +24,13 @@ export function parsePushArgs(argv) {
23
24
  const [title, body] = positional;
24
25
  if (!title || !body) return { error: 'usage: handmux push <title> <body> [--session X]... [--device K]... [--tag T] [--url U]' };
25
26
  if (sessions.length && devices.length) return { error: 'use --session or --device, not both' };
27
+ const safeUrl = url == null ? null : sanitizeNotificationUrl(url);
28
+ if (url != null && !safeUrl) return { error: '--url must be an http(s) URL or a relative path' };
26
29
  const out = { title, body };
27
30
  if (sessions.length) out.sessions = sessions;
28
31
  if (devices.length) out.devices = devices;
29
32
  if (tag) out.tag = tag;
30
- if (url) out.url = url;
33
+ if (safeUrl) out.url = safeUrl;
31
34
  return out;
32
35
  }
33
36
 
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import crypto from 'node:crypto';
10
10
  import { readJsonArray, writeJsonAtomic } from './jsonStore.js';
11
+ import { sanitizeNotificationUrl } from './urlPolicy.js';
11
12
 
12
13
  const here = path.dirname(fileURLToPath(import.meta.url));
13
14
  const DIR = process.env.NOTIF_DIR || path.resolve(here, '../data/notifications');
@@ -24,7 +25,8 @@ const load = (file) => readJsonArray(file).filter((n) => n && typeof n.title ===
24
25
  export function record(pushKeys, { title, body, tag, url } = {}) {
25
26
  const rec = { id: genId(), ts: Date.now(), title: String(title ?? ''), body: String(body ?? '') };
26
27
  if (tag) rec.tag = String(tag);
27
- if (url) rec.url = String(url);
28
+ const safeUrl = sanitizeNotificationUrl(url);
29
+ if (safeUrl) rec.url = safeUrl;
28
30
  for (const key of pushKeys || []) {
29
31
  const file = fileFor(key);
30
32
  if (!file) continue;
@@ -5,6 +5,8 @@
5
5
  // Also handles dynamic preview: Host-based dispatch to loopback ports (HTTP + WS upgrade).
6
6
  import net from 'node:net';
7
7
  import http from 'node:http';
8
+ import https from 'node:https';
9
+ import tls from 'node:tls';
8
10
  import express from 'express';
9
11
  import { tokenEquals } from './auth.js';
10
12
  import { safePreviewName } from './previews.js';
@@ -57,7 +59,15 @@ function cookieScope(domain) {
57
59
  // Resolve the on-disk file for a request path under a preview: '' or '<dir>/' → its index.html.
58
60
  function fileFor(rest) { return (!rest || rest.endsWith('/')) ? `${rest}index.html` : rest; }
59
61
 
60
- export function createPreview({ previews, token, domain = null }) {
62
+ export function createPreview({
63
+ previews,
64
+ token,
65
+ domain = null,
66
+ httpRequest = http.request,
67
+ httpsRequest = https.request,
68
+ netConnect = net.connect,
69
+ tlsConnect = tls.connect,
70
+ }) {
61
71
  const router = express.Router();
62
72
  const cookieDomain = cookieScope(domain);
63
73
 
@@ -117,9 +127,16 @@ export function createPreview({ previews, token, domain = null }) {
117
127
  // its own root, so path/method/headers/body forward as-is. `host` is the loopback family the app was
118
128
  // found on at register time ('127.0.0.1' or '::1'); the Host header stays 127.0.0.1:<port> so a dev
119
129
  // server's host check (e.g. Vite, whose allowedHosts include localhost/127.0.0.1) is satisfied.
120
- function proxyHttp(port, host, req, res) {
130
+ function proxyHttp(port, host, protocol, req, res) {
121
131
  const headers = { ...req.headers, host: `127.0.0.1:${port}` };
122
- const up = http.request({ host: host || '127.0.0.1', port, method: req.method, path: req.url, headers }, (upRes) => {
132
+ const secure = protocol === 'https';
133
+ const request = secure ? httpsRequest : httpRequest;
134
+ const options = { host: host || '127.0.0.1', port, method: req.method, path: req.url, headers };
135
+ // Local HTTPS dev servers commonly use a self-signed certificate. The connection target is constrained
136
+ // to loopback by registration/probing, so accepting that certificate does not expand trust to a network
137
+ // host. SNI stays localhost because that is what development certificates are normally issued for.
138
+ if (secure) Object.assign(options, { servername: 'localhost', rejectUnauthorized: false });
139
+ const up = request(options, (upRes) => {
123
140
  res.writeHead(upRes.statusCode, upRes.headers);
124
141
  upRes.on('error', () => res.destroy()); // mid-stream upstream reset (after headers) → tear down the client socket
125
142
  upRes.pipe(res);
@@ -147,7 +164,7 @@ export function createPreview({ previews, token, domain = null }) {
147
164
  if (state === 'missing') return res.status(404).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览不存在</h1>');
148
165
  if (state === 'expired') return res.status(410).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览已过期</h1><p>请回到 app 重新启动预览。</p>');
149
166
  if (entry.kind !== 'dynamic') return res.status(404).end(); // a static name reached via subdomain — not served here
150
- proxyHttp(entry.port, entry.host, req, res);
167
+ proxyHttp(entry.port, entry.host, entry.protocol, req, res);
151
168
  }
152
169
 
153
170
  // WebSocket (and any raw Upgrade) for a dynamic preview: same cookie auth, then a bare TCP pipe to
@@ -165,7 +182,13 @@ export function createPreview({ previews, token, domain = null }) {
165
182
  if (!credOk({ query, headers: req.headers }, token)) return socket.destroy();
166
183
  const { state, entry } = previews.get(name);
167
184
  if (state !== 'active' || entry.kind !== 'dynamic') return socket.destroy();
168
- const up = net.connect(entry.port, entry.host || '127.0.0.1', () => {
185
+ const secure = entry.protocol === 'https';
186
+ const options = { port: entry.port, host: entry.host || '127.0.0.1' };
187
+ if (secure) Object.assign(options, {
188
+ servername: 'localhost', rejectUnauthorized: false, ALPNProtocols: ['http/1.1'],
189
+ });
190
+ const connect = secure ? tlsConnect : netConnect;
191
+ const up = connect(options, () => {
169
192
  const fwd = { ...req.headers, host: `127.0.0.1:${entry.port}` };
170
193
  up.write(`GET ${req.url} HTTP/1.1\r\n`);
171
194
  for (const [k, v] of Object.entries(fwd)) up.write(`${k}: ${v}\r\n`);
package/src/previews.js CHANGED
@@ -69,16 +69,17 @@ export function createPreviews({
69
69
  return { name: entry.name, kind: entry.kind, expiresAt: entry.expiresAt };
70
70
  };
71
71
 
72
- async function register({ name, dir, port }) {
72
+ async function register({ name, dir, port, protocol = 'http' }) {
73
73
  const nm = safePreviewName(name);
74
74
  if (!nm) return { error: 'bad name', status: 400 };
75
75
  if (port !== undefined && port !== null && port !== '') {
76
76
  if (!dynamicEnabled) return { error: 'dynamic disabled', status: 400 };
77
+ if (protocol !== 'http' && protocol !== 'https') return { error: 'bad protocol', status: 400 };
77
78
  const p = Number(port);
78
79
  if (!Number.isInteger(p) || p < 1 || p > 65535) return { error: 'bad port', status: 400 };
79
80
  const host = await probePort(p); // '127.0.0.1' | '::1' | null
80
81
  if (!host) return { error: 'port not listening', status: 400 };
81
- return upsert({ name: nm, kind: 'dynamic', port: p, host });
82
+ return upsert({ name: nm, kind: 'dynamic', port: p, host, protocol });
82
83
  }
83
84
  if (typeof dir !== 'string' || dir[0] !== '/') return { error: 'not absolute', status: 400 };
84
85
  let real;
@@ -94,14 +95,17 @@ export function createPreviews({
94
95
  const entry = entries.find((e) => e && e.name === name);
95
96
  if (!entry) return { state: 'missing' };
96
97
  if (entry.expiresAt <= now()) { entries = entries.filter((e) => e.name !== name); flush(); return { state: 'expired' }; }
97
- return { state: 'active', entry: { kind: 'static', ...entry } }; // legacy rows (no kind) → static
98
+ const normalized = entry.kind === 'dynamic'
99
+ ? { ...entry, kind: 'dynamic', protocol: entry.protocol === 'https' ? 'https' : 'http' }
100
+ : { kind: 'static', ...entry }; // legacy rows (no kind) → static
101
+ return { state: 'active', entry: normalized };
98
102
  }
99
103
 
100
104
  function list() {
101
105
  const active = entries.filter((e) => e && e.expiresAt > now());
102
106
  if (active.length !== entries.length) { entries = active; flush(); }
103
107
  return active.map((e) => (e.kind === 'dynamic'
104
- ? { name: e.name, kind: 'dynamic', port: e.port, expiresAt: e.expiresAt }
108
+ ? { name: e.name, kind: 'dynamic', port: e.port, protocol: e.protocol === 'https' ? 'https' : 'http', expiresAt: e.expiresAt }
105
109
  : { name: e.name, kind: 'static', dir: e.dir, expiresAt: e.expiresAt }));
106
110
  }
107
111
 
@@ -10,12 +10,14 @@ export function previewRoutes({ previews, previewDomain, token }) {
10
10
  // dynamic reverse-proxy reachable at https://<name>.<DOMAIN>/ (only when previewDomain is set).
11
11
  r.post('/previews', async (req, res, next) => {
12
12
  if (!previews) return res.status(503).json({ error: 'previews disabled' });
13
- const { name, dir, port } = req.body || {};
13
+ const { name, dir, port, protocol } = req.body || {};
14
14
  if (typeof name !== 'string' || !name) return res.status(400).json({ error: 'bad request' });
15
15
  const hasPort = port !== undefined && port !== null && port !== '';
16
16
  if (!hasPort && (typeof dir !== 'string' || !dir)) return res.status(400).json({ error: 'bad request' });
17
17
  try {
18
- const out = await previews.register(hasPort ? { name, port } : { name, dir });
18
+ const dynamic = { name, port };
19
+ if (protocol !== undefined) dynamic.protocol = protocol;
20
+ const out = await previews.register(hasPort ? dynamic : { name, dir });
19
21
  if (out.error) return res.status(out.status).json({ error: out.error });
20
22
  const url = out.kind === 'dynamic'
21
23
  ? `https://${encodeURIComponent(out.name)}.${previewDomain}/?token=${encodeURIComponent(token)}`
@@ -1,6 +1,7 @@
1
1
  // Web Push routes: hand the client the VAPID key, store/update/remove a browser PushSubscription, and
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
+ import { sanitizeNotificationUrl } from '../urlPolicy.js';
4
5
 
5
6
  export function pushRoutes({ push, notifications }) {
6
7
  const r = express.Router();
@@ -55,6 +56,8 @@ export function pushRoutes({ push, notifications }) {
55
56
  const { sessions, devices, title, body, tag, url } = req.body || {};
56
57
  if (typeof title !== 'string' || !title.trim()) return res.status(400).json({ error: 'title required' });
57
58
  if (typeof body !== 'string' || !body.trim()) return res.status(400).json({ error: 'body required' });
59
+ const safeUrl = url == null ? null : sanitizeNotificationUrl(url);
60
+ if (url != null && !safeUrl) return res.status(400).json({ error: 'url must be http(s) or relative' });
58
61
  const hasSessions = Array.isArray(sessions) && sessions.length > 0;
59
62
  const hasDevices = Array.isArray(devices) && devices.length > 0;
60
63
  if (hasSessions && hasDevices) return res.status(400).json({ error: 'use --session or --device, not both' });
@@ -66,7 +69,7 @@ export function pushRoutes({ push, notifications }) {
66
69
  // is stored on the record (surfaced in the detail), NOT used as the tap target.
67
70
  if (notifications) {
68
71
  const targetKeys = push.resolveTargetKeys({ devices: hasDevices ? devices : null, sessions: hasSessions ? sessions : null });
69
- const rec = notifications.record(targetKeys, { title, body, tag: payload.tag, url });
72
+ const rec = notifications.record(targetKeys, { title, body, tag: payload.tag, url: safeUrl });
70
73
  payload.data = { inboxId: rec.id };
71
74
  }
72
75
  try {
@@ -23,11 +23,24 @@ import { readClaudeContext } from '../usage.js';
23
23
  export function transcriptRoutes({ commands, claudeEvents }) {
24
24
  const r = express.Router();
25
25
 
26
+ // These three endpoints are one Claude-specific lens surface. Gate them together so a future endpoint
27
+ // cannot accidentally skip the invariant: explicit non-Claude requests and panes already bound to a
28
+ // non-Claude hook record are rejected before any cwd→newest-Claude-session fallback or screen scraping.
29
+ r.use(['/pending-prompt', '/context', '/transcript'], (req, res, next) => {
30
+ const pane = req.query.pane;
31
+ if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
32
+ const requested = req.query.agent;
33
+ const bound = claudeEvents && claudeEvents.paneAgent ? claudeEvents.paneAgent(pane) : null;
34
+ if ((requested != null && requested !== '' && requested !== 'claude') || (bound && bound !== 'claude')) {
35
+ return res.status(409).json({ error: 'chat lens unsupported for this agent' });
36
+ }
37
+ next();
38
+ });
39
+
26
40
  // The pending interactive PROMPT on the pane's screen — an AskUserQuestion menu or a tool-permission
27
41
  // menu — scraped from `capture-pane` (its options are NOT in the .jsonl while pending; see pendingPrompt.js).
28
42
  // Returns { prompt: {kind,title,options,cursor} | null }. Polled by the 对话 lens only while a gate is up.
29
43
  r.get('/pending-prompt', async (req, res, next) => {
30
- if (!isPaneId(req.query.pane)) return res.status(400).json({ error: 'bad pane id' });
31
44
  try {
32
45
  const text = await commands.capturePlain(req.query.pane);
33
46
  return res.json({ prompt: parsePendingPrompt(text) });
@@ -39,7 +52,6 @@ export function transcriptRoutes({ commands, claudeEvents }) {
39
52
  // { model, usedPercent } (either may be null: capturer not opted in / session hasn't rendered / no hooks).
40
53
  // The 对话 composer polls this to show a small "模型 · 24%" chip. Best-effort: never 500 on a missing file.
41
54
  r.get('/context', (req, res, next) => {
42
- if (!isPaneId(req.query.pane)) return res.status(400).json({ error: 'bad pane id' });
43
55
  try {
44
56
  const hooked = claudeEvents && claudeEvents.paneSession ? claudeEvents.paneSession(req.query.pane) : null;
45
57
  const sid = hooked && (hooked.sessionId || (hooked.transcriptPath ? path.basename(hooked.transcriptPath).replace(/\.jsonl$/, '') : null));
@@ -49,7 +61,6 @@ export function transcriptRoutes({ commands, claudeEvents }) {
49
61
  });
50
62
 
51
63
  r.get('/transcript', async (req, res, next) => {
52
- if (!isPaneId(req.query.pane)) return res.status(400).json({ error: 'bad pane id' });
53
64
  const limit = Math.min(Math.max(Number(req.query.limit) || 10, 1), 100);
54
65
  const before = req.query.before != null && req.query.before !== '' ? Number(req.query.before) : null;
55
66
  try {
@@ -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
 
@@ -0,0 +1,14 @@
1
+ // Notification links are user-supplied by `handmux push --url`. Keep relative in-app links, but reject
2
+ // executable/non-web schemes (javascript:, data:, file:, …) before they reach storage or an <a href>.
3
+ export function sanitizeNotificationUrl(value) {
4
+ if (typeof value !== 'string') return null;
5
+ const raw = value.trim();
6
+ if (!raw) return null;
7
+ try {
8
+ const parsed = new URL(raw, 'https://handmux.invalid/');
9
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
10
+ return raw;
11
+ } catch {
12
+ return null;
13
+ }
14
+ }