handmux 0.17.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/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-Ddagv-Lc.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)) {
@@ -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