handmux 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,209 @@
1
+ // Agent-agnostic scan/parse helpers shared by every agent driver and the orphan engine. This is the LEAF
2
+ // layer: it imports nothing from the agent registry, so drivers (claude.js / codex.js) and the engine
3
+ // (orphans.js) can all depend on it without an import cycle.
4
+ //
5
+ // Everything here is about turning `ps`/`tmux`/`lsof` output and session jsonl files into structured data;
6
+ // none of it knows which coding agent it's looking at — the per-agent specifics (which process name, where
7
+ // sessions live, how to resume) live in the driver descriptors that USE these helpers.
8
+ import { execFile } from 'node:child_process';
9
+ import { promises as fsp } from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ // Tolerant promisified execFile: resolves '' on any error (no server, missing binary, non-zero exit).
13
+ // Detection is best-effort and must never throw the whole request.
14
+ export function defaultRun(cmd, args) {
15
+ return new Promise((resolve) => {
16
+ execFile(cmd, args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
17
+ resolve(err ? '' : String(stdout));
18
+ });
19
+ });
20
+ }
21
+
22
+ // A coding-agent session id is a UUID (Claude's jsonl filename; Codex's rollout-file trailing id). Validate
23
+ // strictly: takeover types `<bin> resume <id>` into a shell via send-keys, so a non-UUID id would be a
24
+ // shell-injection vector.
25
+ export const isSessionUuid = (s) =>
26
+ typeof s === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
27
+
28
+ // Strip /dev/ and fold "no controlling terminal" markers (macOS '??', Linux '?') to '' so ps ttys and
29
+ // tmux pane_ttys compare equal: ps 'ttys010' / tmux '/dev/ttys010' → 'ttys010'; ps 'pts/3' / tmux
30
+ // '/dev/pts/3' → 'pts/3'.
31
+ export function normTty(t) {
32
+ const s = String(t || '').trim();
33
+ if (!s || s === '??' || s === '?' || s === '-') return '';
34
+ return s.replace(/^\/dev\//, '');
35
+ }
36
+
37
+ // ps `etime` (elapsed since start) → milliseconds. Formats (macOS + Linux, no spaces): "MM:SS",
38
+ // "HH:MM:SS", "DD-HH:MM:SS". Used only to derive a startedAt for display/recognition (the "A加成"),
39
+ // NOT for session attribution — a resumed session's process starts long after its jsonl's first event.
40
+ export function etimeToMs(etime) {
41
+ const s = String(etime).trim();
42
+ if (!s) return 0;
43
+ let days = 0;
44
+ let rest = s;
45
+ const dash = s.indexOf('-');
46
+ if (dash >= 0) { days = Number(s.slice(0, dash)) || 0; rest = s.slice(dash + 1); }
47
+ let sec = 0;
48
+ for (const p of rest.split(':')) sec = sec * 60 + (Number(p) || 0);
49
+ return (days * 86400 + sec) * 1000;
50
+ }
51
+
52
+ // Parse `ps -Ao pid=,ppid=,stat=,etime=,tty=,args=` → LIVE agent processes only, each tagged with the id of
53
+ // the FIRST driver whose `procMatch` regex matches its argv. `agents` is the driver list; a proc matching
54
+ // none is dropped. args (last column) may contain spaces; etime has none. STOPPED (STAT 'T', a Ctrl-Z-
55
+ // suspended job-control stack — verified real: one terminal can hold 8 suspended `claude`s) and ZOMBIE
56
+ // ('Z') processes are dropped: they aren't active sessions to steer, and a suspended original can't write
57
+ // its jsonl so there's nothing to race.
58
+ export function parseAgentProcs(psOut, agents) {
59
+ const out = [];
60
+ for (const line of String(psOut).split('\n')) {
61
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)$/);
62
+ if (!m) continue;
63
+ const stat = m[3];
64
+ if (stat[0] === 'T' || stat[0] === 'Z') continue;
65
+ const args = m[6].trim();
66
+ const agent = agents.find((a) => a.procMatch.test(args));
67
+ if (!agent) continue;
68
+ out.push({ pid: Number(m[1]), ppid: Number(m[2]), etimeMs: etimeToMs(m[4]), tty: normTty(m[5]), args, agent: agent.id });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ // Parse `tmux list-panes -a -F '#{pane_tty}\t#{pane_pid}'` → the set of pane ttys and pane (shell) pids.
74
+ export function parsePaneMembership(tmuxOut) {
75
+ const ttys = new Set();
76
+ const pids = new Set();
77
+ for (const line of String(tmuxOut).split('\n')) {
78
+ if (!line) continue;
79
+ const [tty, pid] = line.split('\t');
80
+ const nt = normTty(tty);
81
+ if (nt) ttys.add(nt);
82
+ const n = Number(pid);
83
+ if (n) pids.add(n);
84
+ }
85
+ return { ttys, pids };
86
+ }
87
+
88
+ // Orphan = an agent proc WITH a real controlling tty that is neither one of tmux's pane ttys nor a child of
89
+ // a pane's shell. The tty requirement drops background/headless runs (SDK/`-p`/`exec` piped, tty '') —
90
+ // those aren't interactive sessions a user would "take over".
91
+ export function findOrphans(procs, membership) {
92
+ return procs.filter(
93
+ (p) => p.tty && !membership.ttys.has(p.tty) && !membership.pids.has(p.ppid),
94
+ );
95
+ }
96
+
97
+ // A real cwd → its ~/.claude/projects directory name. Claude replaces every non-alphanumeric char with
98
+ // '-' (verified: '/home/user/handmux' → '-home-user-handmux'; both '/'
99
+ // and '_' fold to '-'). The mapping is LOSSY (not reversible), so we only ever encode forward, then
100
+ // confirm each candidate jsonl's recorded `cwd` matches before trusting it.
101
+ export function encodeProjectDir(cwd) {
102
+ return String(cwd).replace(/[^A-Za-z0-9]/g, '-');
103
+ }
104
+
105
+ // Read the last `bytes` of a file (for the trailing conversation). The first line of the chunk may be
106
+ // truncated mid-JSON — callers skip lines that don't parse.
107
+ export async function readTail(file, bytes = 65536) {
108
+ const fh = await fsp.open(file, 'r');
109
+ try {
110
+ const { size } = await fh.stat();
111
+ const start = Math.max(0, size - bytes);
112
+ const len = size - start;
113
+ if (len <= 0) return '';
114
+ const buf = Buffer.alloc(len);
115
+ await fh.read(buf, 0, len, start);
116
+ return buf.toString('utf8');
117
+ } finally {
118
+ await fh.close();
119
+ }
120
+ }
121
+
122
+ // Read the first `bytes` of a file (the session header carries `cwd` early).
123
+ export async function readHead(file, bytes = 65536) {
124
+ const fh = await fsp.open(file, 'r');
125
+ try {
126
+ const buf = Buffer.alloc(bytes);
127
+ const { bytesRead } = await fh.read(buf, 0, bytes, 0);
128
+ return buf.slice(0, bytesRead).toString('utf8');
129
+ } finally {
130
+ await fh.close();
131
+ }
132
+ }
133
+
134
+ // First `"cwd":"..."` anywhere in a chunk (both Claude and Codex record the session cwd early in the file).
135
+ export function firstCwd(headText) {
136
+ const m = String(headText).match(/"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/);
137
+ if (!m) return '';
138
+ try { return JSON.parse(`"${m[1]}"`); } catch { return m[1]; }
139
+ }
140
+
141
+ // Pull the last user-typed message text out of a Claude jsonl tail, for a recognizable one-line label.
142
+ // Entries are line-delimited JSON; conversational turns are type 'user' with message.role 'user', whose
143
+ // content is either a string or an array of blocks ({type:'text',text}). Meta rows (last-prompt/ai-title/
144
+ // mode/attachment/summary) are ignored. Scans newest-first.
145
+ export function lastUserSnippet(tailText, max = 80) {
146
+ const rows = String(tailText).split('\n');
147
+ for (let i = rows.length - 1; i >= 0; i--) {
148
+ const line = rows[i].trim();
149
+ if (!line || line[0] !== '{') continue;
150
+ let d;
151
+ try { d = JSON.parse(line); } catch { continue; }
152
+ if (d.type !== 'user' || !d.message || d.message.role !== 'user') continue;
153
+ const c = d.message.content;
154
+ let text = '';
155
+ if (typeof c === 'string') text = c;
156
+ else if (Array.isArray(c)) text = c.filter((b) => b && b.type === 'text').map((b) => b.text).join(' ');
157
+ text = text.replace(/\s+/g, ' ').trim();
158
+ if (text) return text.length > max ? `${text.slice(0, max)}…` : text;
159
+ }
160
+ return '';
161
+ }
162
+
163
+ // Resolve a cwd to the newest jsonl in its ENCODED project dir whose recorded cwd matches (guards against
164
+ // the lossy encoding colliding two real paths). This is Claude's layout (~/.claude/projects/<enc-cwd>/
165
+ // <uuid>.jsonl); Codex has its own resolver. `snippet` extracts the last user turn (parser injected so
166
+ // each agent's jsonl shape is handled). Returns sessionId, a busy/idle guess (mtime recency), the snippet,
167
+ // and the last-activity timestamp; {} when the dir is absent or nothing matches.
168
+ export async function resolveEncodedDirSession(
169
+ projectsDir, cwd, { busyMs = 8000, now = Date.now, snippet = lastUserSnippet } = {},
170
+ ) {
171
+ const dir = path.join(projectsDir, encodeProjectDir(cwd));
172
+ let names;
173
+ try { names = (await fsp.readdir(dir)).filter((n) => n.endsWith('.jsonl')); } catch { return {}; }
174
+ const stats = [];
175
+ for (const n of names) {
176
+ try { stats.push({ n, mtime: (await fsp.stat(path.join(dir, n))).mtimeMs }); } catch { /* gone */ }
177
+ }
178
+ stats.sort((a, b) => b.mtime - a.mtime);
179
+ for (const { n, mtime } of stats) {
180
+ const file = path.join(dir, n);
181
+ let head;
182
+ try { head = await readHead(file); } catch { continue; }
183
+ if (firstCwd(head) !== cwd) continue; // different real path collided onto the same dir → skip
184
+ let snip = '';
185
+ try { snip = snippet(await readTail(file)); } catch { /* best effort */ }
186
+ return {
187
+ sessionId: n.replace(/\.jsonl$/, ''),
188
+ state: now() - mtime < busyMs ? 'busy' : 'idle',
189
+ snippet: snip,
190
+ lastActivity: Math.round(mtime),
191
+ };
192
+ }
193
+ return {};
194
+ }
195
+
196
+ // A tmux session name derived from a cwd basename, kept within isValidSessionName ([A-Za-z0-9-], ≤16):
197
+ // `<prefix>-<alnum label, ≤8>-<n>`. n disambiguates against existing sessions.
198
+ export function takeoverSessionName(cwdLabel, n, prefix = 'cc') {
199
+ const base = String(cwdLabel || '').replace(/[^A-Za-z0-9]/g, '').slice(0, 8) || prefix;
200
+ return `${prefix}-${base}-${n}`.slice(0, 16);
201
+ }
202
+
203
+ export const isShell = (c) => /^-?(zsh|bash|sh|fish|dash|tcsh|csh|ksh)$/.test(String(c || ''));
204
+
205
+ export async function lsofCwd(run, pid) {
206
+ const out = await run('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn']);
207
+ for (const line of out.split('\n')) if (line[0] === 'n') return line.slice(1).trim();
208
+ return '';
209
+ }
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { getAgent } from './agents/index.js';
5
+ import { claude } from './agents/claude.js';
4
6
 
5
7
  const here = path.dirname(fileURLToPath(import.meta.url));
6
8
  // The hook-maintained state file: ONE JSON object keyed by tmux pane id, each value the pane's latest
@@ -8,59 +10,10 @@ const here = path.dirname(fileURLToPath(import.meta.url));
8
10
  // Default lives under server/data (gitignored runtime data); override with CLAUDE_STATE_FILE.
9
11
  export const DEFAULT_STATE_FILE = process.env.CLAUDE_STATE_FILE || path.resolve(here, '../data/claude-state.json');
10
12
 
11
- // Build the 需要你 one-liner for a PermissionRequest, from the tool it's gating on (PermissionRequest
12
- // carries tool_name + tool_input, unlike the later permission_prompt Notification which only has an
13
- // English message).
14
- function permMsg(body) {
15
- const t = body.tool_name;
16
- if (t === 'AskUserQuestion') {
17
- const q = body.tool_input && body.tool_input.questions && body.tool_input.questions[0];
18
- const text = (q && (q.question || q.header)) || '';
19
- return text ? `需要你回答:${text}` : '需要你回答';
20
- }
21
- if (t === 'ExitPlanMode') return '需要你批准计划';
22
- return t ? `需要你授权:${t}` : '需要你';
23
- }
24
-
25
- // Build the 进行中 one-liner for a resume (PostToolUse after the user answered/approved), surfacing the
26
- // choice they just made — AskUserQuestion stores it in tool_input.answers, keyed by question.
27
- function resumeMsg(body) {
28
- const a = (body.tool_input && body.tool_input.answers) || (body.tool_response && body.tool_response.answers);
29
- if (a && typeof a === 'object') {
30
- const picks = Object.values(a).flat().filter(Boolean).join('、');
31
- if (picks) return `已答:${picks}`;
32
- }
33
- if (body.tool_name === 'ExitPlanMode') return '已批准计划';
34
- return '';
35
- }
36
-
37
- // Map a hook event (src + raw Claude payload) to a notification "kind". Pure — no I/O, easy to test.
38
- // stop → done (turn finished; carries last message)
39
- // prompt → working (UserPromptSubmit; carries the prompt)
40
- // end → end (SessionEnd; the pane's claude is gone)
41
- // notify + idle_prompt → idle (waited ~60s; carries the notification message)
42
- // notify + permission_prompt → permission (blocked on a permission/选择 gate; carries the message)
43
- // resume → working (PostToolUse on AskUserQuestion/ExitPlanMode: the user just
44
- // answered/approved → Claude is working again; un-sticks the
45
- // pane from the `permission` state its prompt left behind, and
46
- // carries the choice the user made, e.g. 已答:Red)
47
- // permreq → permission (PermissionRequest: a real prompt just appeared — fires ~6s
48
- // before the permission_prompt Notification and names the tool,
49
- // so 需要你 shows faster and says what's being asked. Verified
50
- // NOT to fire for auto-approved tools → no false 需要你 in auto)
51
- // anything else → null (ignored: auth_success, elicitation_*, etc.)
52
- export function classifyEvent(src, body = {}) {
53
- if (src === 'stop') return { kind: 'done', msg: body.last_assistant_message || '' };
54
- if (src === 'prompt') return { kind: 'working', msg: body.prompt || '' };
55
- if (src === 'resume') return { kind: 'working', msg: resumeMsg(body) };
56
- if (src === 'permreq') return { kind: 'permission', msg: permMsg(body) };
57
- if (src === 'end') return { kind: 'end' };
58
- if (src === 'notify') {
59
- if (body.notification_type === 'idle_prompt') return { kind: 'idle', msg: body.message || '' };
60
- if (body.notification_type === 'permission_prompt') return { kind: 'permission', msg: body.message || '' };
61
- }
62
- return null;
63
- }
13
+ // Classify a Claude hook event inbox kind. The logic now lives in the Claude driver (agents/claude.js);
14
+ // re-exported here because tests and callers import it by this path. Per-pane classification in getStates
15
+ // dispatches through getAgent(entry.agent) so Codex (and future agents) classify with their own driver.
16
+ export const classifyEvent = claude.classify;
64
17
 
65
18
  // Which display VIEW a kind pushes as — and so what the device notification fires for. permission→需要你,
66
19
  // done→已完成. 已完成 fires at the COMPLETION MOMENT (done) only. The trailing idle reminder (~60s "still
@@ -70,6 +23,14 @@ export function classifyEvent(src, body = {}) {
70
23
  const PUSH_VIEW = { permission: 'needs', done: 'done' };
71
24
  const VIEW_LABEL = { needs: '需要你', done: '已完成' };
72
25
 
26
+ // The dedup key for a pane's current push view. `needs` is view-only: Claude signals one permission gate via
27
+ // TWO hooks (permreq then permission_prompt) at different ts, and both must collapse to a SINGLE 需要你. But
28
+ // `done` is ts-sensitive: each finished turn is a fresh "已完成 / 该你了". For Claude, two dones are always
29
+ // separated by a 进行中 that re-arms the dedup anyway, so keying done by ts is equivalent; for Codex — whose
30
+ // ONLY event is turn-complete, with no working/prompt event in between — it's what makes turn 2, 3, … push
31
+ // instead of latching on turn 1's done forever.
32
+ function pushKey(view, ts) { return view === 'done' ? `done:${ts}` : view; }
33
+
73
34
  // A 进行中 (working) is a LATCHED state: set by UserPromptSubmit, normally closed by Stop. But an ESC
74
35
  // interrupt / walk-away fires NO hook at all (verified across all 26 hook event types), so working never
75
36
  // gets closed and the blue dot would stick forever. There's no event signal for the interrupt — so we
@@ -110,9 +71,9 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
110
71
  function prime() {
111
72
  const recorded = readStateFile(file);
112
73
  for (const [pane, r] of Object.entries(recorded)) {
113
- const c = r && typeof r.src === 'string' ? classifyEvent(r.src, r.payload || {}) : null;
74
+ const c = r && typeof r.src === 'string' ? getAgent(r.agent).classify(r.src, r.payload || {}) : null;
114
75
  const view = c ? PUSH_VIEW[c.kind] : undefined;
115
- if (view) lastPushed[pane] = view; // resting 需要你/已完成 → treat as seen, don't replay
76
+ if (view) lastPushed[pane] = pushKey(view, r.ts); // resting 需要你/已完成 → treat as seen, don't replay
116
77
  }
117
78
  }
118
79
 
@@ -146,9 +107,12 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
146
107
 
147
108
  const out = {};
148
109
  for (const [pane, rec] of Object.entries(recorded)) {
149
- const c = rec && typeof rec.src === 'string' ? classifyEvent(rec.src, rec.payload || {}) : null;
110
+ const agent = getAgent(rec && rec.agent);
111
+ const c = rec && typeof rec.src === 'string' ? agent.classify(rec.src, rec.payload || {}) : null;
150
112
  const lp = live ? live.get(pane) : null;
151
- const gone = live ? (!lp || lp.cmd !== 'claude') : false;
113
+ // Dropped when tmux says the pane is gone or no longer running THIS agent (hard kill / crash /
114
+ // Ctrl-C-out with no clean-exit event). A pane keyed by a legacy entry (no agent field) → Claude.
115
+ const gone = live ? (!lp || !agent.procNames.includes(lp.cmd)) : false;
152
116
 
153
117
  // (1) push side-effect — runs for every pane regardless of the output filter. Push fires on entry
154
118
  // into a 需要你 (permission) / 已完成 (done) view, deduped so a stay-put doesn't re-push. The idle
@@ -158,12 +122,13 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
158
122
  if (live) {
159
123
  const kind = c ? c.kind : null;
160
124
  const view = PUSH_VIEW[kind]; // 'needs' | 'done' | undefined
125
+ const key = view ? pushKey(view, rec.ts) : undefined;
161
126
  if (kind === 'idle') {
162
127
  /* trailing idle reminder — no push, keep the dedup as the preceding done left it */
163
128
  } else if (gone || !view) {
164
129
  lastPushed[pane] = null; // 进行中 / 结束 / gone → re-arm for the next 需要你 / 已完成
165
- } else if (lastPushed[pane] !== view && lp.session) {
166
- lastPushed[pane] = view;
130
+ } else if (lastPushed[pane] !== key && lp.session) {
131
+ lastPushed[pane] = key;
167
132
  await sendPush(pane, view, c, lp);
168
133
  }
169
134
  }
@@ -181,7 +146,7 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
181
146
  }
182
147
  const loc = lp ? { session: lp.session, window: lp.window, windowName: lp.windowName } : {};
183
148
  if (allow && !allow.has(loc.session)) continue;
184
- out[pane] = { ...loc, kind: c.kind, msg: c.msg || '', ts: rec.ts || 0 };
149
+ out[pane] = { ...loc, kind: c.kind, msg: c.msg || '', ts: rec.ts || 0, agent: agent.id };
185
150
  }
186
151
  return out;
187
152
  }
@@ -5,6 +5,52 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { pocketHome } from './state.js';
8
+ import { t } from './i18n/index.js';
9
+
10
+ const fmtMB = (n) => (n / 1048576).toFixed(1);
11
+
12
+ // Stream a fetch response into a Buffer, reporting progress per chunk as onProgress(received, total).
13
+ // `total` is the content-length (0 if the server didn't send one). Falls back to arrayBuffer() when the
14
+ // response isn't a readable stream (older/mock fetch), so callers still get the bytes.
15
+ export async function drain(res, onProgress) {
16
+ const total = Number(res.headers?.get?.('content-length')) || 0;
17
+ if (!res.body?.getReader) {
18
+ const b = Buffer.from(await res.arrayBuffer());
19
+ onProgress?.(b.length, total || b.length);
20
+ return b;
21
+ }
22
+ const reader = res.body.getReader();
23
+ const chunks = [];
24
+ let received = 0;
25
+ for (;;) {
26
+ const { done, value } = await reader.read();
27
+ if (done) break;
28
+ const c = Buffer.from(value);
29
+ chunks.push(c);
30
+ received += c.length;
31
+ onProgress?.(received, total);
32
+ }
33
+ return Buffer.concat(chunks);
34
+ }
35
+
36
+ // Default progress renderer: a single \r-updated line on a TTY (throttled to ~10/s), e.g.
37
+ // ` cloudflared 45% (9.2/20.4 MB)`. Non-TTY (piped/logged) gets nothing — the start line already
38
+ // announced the download and a spammy animation would just fill the log with control chars.
39
+ function defaultProgress(out = process.stdout) {
40
+ if (!out.isTTY) return () => {};
41
+ let last = 0;
42
+ return (received, total) => {
43
+ const done = total && received >= total;
44
+ const now = Date.now();
45
+ if (!done && now - last < 100) return;
46
+ last = now;
47
+ const body = total
48
+ ? `${Math.floor((received / total) * 100)}% (${fmtMB(received)}/${fmtMB(total)} MB)`
49
+ : `${fmtMB(received)} MB`;
50
+ out.write(`\r cloudflared ${body} `);
51
+ if (done) out.write('\n');
52
+ };
53
+ }
8
54
 
9
55
  // Map Node's platform/arch to cloudflared's release asset. Linux/Windows ship a bare binary; macOS
10
56
  // ships a .tgz that contains a `cloudflared` executable.
@@ -21,7 +67,7 @@ export function onPath(exec = 'cloudflared') {
21
67
  return r.status === 0 ? String(r.stdout).trim().split(/\r?\n/)[0] : null;
22
68
  }
23
69
 
24
- export async function resolveCloudflared(home, { which = onPath, fetchImpl, log = console } = {}) {
70
+ export async function resolveCloudflared(home, { which = onPath, fetchImpl, log = console, progress } = {}) {
25
71
  const found = which('cloudflared');
26
72
  if (found) return found;
27
73
 
@@ -34,10 +80,10 @@ export async function resolveCloudflared(home, { which = onPath, fetchImpl, log
34
80
  if (!doFetch) throw new Error('no fetch available to download cloudflared (Node 18+ required)');
35
81
  fs.mkdirSync(dir, { recursive: true });
36
82
  const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset.file}`;
37
- log.log?.(` downloading cloudflared (${asset.file}) …`);
83
+ log.log?.(t('cf.downloading', { file: asset.file }));
38
84
  const res = await doFetch(url, { redirect: 'follow' });
39
85
  if (!res.ok) throw new Error(`cloudflared download failed: HTTP ${res.status} (${url})`);
40
- const buf = Buffer.from(await res.arrayBuffer());
86
+ const buf = await drain(res, progress || defaultProgress(process.stdout));
41
87
 
42
88
  if (asset.archive === 'tgz') {
43
89
  const tmp = path.join(dir, asset.file);
@@ -0,0 +1,125 @@
1
+ // Install/uninstall the handmux Codex lifecycle hooks — the Codex analogue of claudeHooks.js. Codex 0.142+
2
+ // ships a Claude-parity hook system: the SAME event names, the SAME stdin JSON payload fields (session_id,
3
+ // cwd, hook_event_name, prompt, tool_input, last_assistant_message, stop_hook_active…), and the SAME
4
+ // {matcher, command} registration shape. So Codex reuses handmux's Claude hook scripts verbatim — the only
5
+ // thing that differs is WHERE they're registered (Codex's config.toml) and that we pass agent='codex' so
6
+ // the server tags the state entry (classify + liveness dispatch through the codex driver).
7
+ //
8
+ // Wiring: we append a MARKED region of inline `[[hooks.EVENT]]` tables to ~/.codex/config.toml. That inline
9
+ // form is the mechanism verified to parse (vs. a standalone hooks.json whose auto-discovery is unconfirmed).
10
+ // Multiple `[[hooks.X]]` array-of-tables entries merge, so our blocks coexist with the user's own hooks —
11
+ // no single-slot clobber risk (unlike the old `notify` program), hence no 'conflict' state.
12
+ //
13
+ // Presence is gated on the `codex` BINARY on PATH (see codexOnPath), NOT on ~/.codex existing — that dir
14
+ // name isn't unique to Codex CLI. Because the binary (not the dir) gates us, we MAY create ~/.codex.
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { homedir } from 'node:os';
18
+
19
+ // The events we register → the verb passed to handmux-notify.sh (classified by the shared Claude classifier,
20
+ // since Codex's payloads match): UserPromptSubmit→working, Stop→done, PermissionRequest→需要你. PostToolUse
21
+ // fires on every tool call, but its handmux-write.cjs handler for agent=codex is un-stick-ONLY (it flips a
22
+ // pane out of 需要你 back to 进行中 after you approve, and is a no-op otherwise), so it doesn't churn.
23
+ const CODEX_HOOK_EVENTS = [
24
+ { event: 'UserPromptSubmit', src: 'prompt' },
25
+ { event: 'Stop', src: 'stop' },
26
+ { event: 'PermissionRequest', src: 'permreq' },
27
+ { event: 'PostToolUse', src: 'resume' },
28
+ ];
29
+
30
+ // Shared with Claude — the same scripts drive both (stdin payloads are identical).
31
+ const SCRIPTS = ['handmux-notify.sh', 'handmux-write.cjs'];
32
+ const BEGIN = '# >>> handmux codex-hooks >>>';
33
+ const END = '# <<< handmux codex-hooks <<<';
34
+
35
+ // True if an executable `codex` is resolvable on PATH. Windows adds the PATHEXT suffixes.
36
+ function codexOnPath(env = process.env) {
37
+ const exts = process.platform === 'win32' ? (env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : [''];
38
+ for (const dir of (env.PATH || '').split(path.delimiter)) {
39
+ if (!dir) continue;
40
+ for (const ext of exts) {
41
+ try { fs.accessSync(path.join(dir, `codex${ext}`), fs.constants.X_OK); return true; } catch { /* keep looking */ }
42
+ }
43
+ }
44
+ return false;
45
+ }
46
+
47
+ function codexDir(home = homedir()) { return path.join(home, '.codex'); }
48
+ function configPath(home = homedir()) { return path.join(codexDir(home), 'config.toml'); }
49
+ function hooksDir(home = homedir()) { return path.join(codexDir(home), 'hooks'); }
50
+
51
+ // Build the marked config.toml region: one `[[hooks.EVENT]]` + `[[hooks.EVENT.hooks]]` per event, each
52
+ // running the shared notify script with the event's verb and agent='codex'. The command path is single-
53
+ // quoted inside a TOML basic string (JSON.stringify) so a $HOME with spaces stays safe.
54
+ export function codexHooksBlock(home = homedir()) {
55
+ const notify = path.join(hooksDir(home), 'handmux-notify.sh');
56
+ const lines = [BEGIN, '# handmux inbox hooks for Codex — delete this whole region to disable.'];
57
+ for (const e of CODEX_HOOK_EVENTS) {
58
+ const cmd = `'${notify}' ${e.src} codex`;
59
+ lines.push(`[[hooks.${e.event}]]`, `[[hooks.${e.event}.hooks]]`, 'type = "command"', `command = ${JSON.stringify(cmd)}`, '');
60
+ }
61
+ lines.push(END, '');
62
+ return lines.join('\n');
63
+ }
64
+
65
+ // Pure: splice our marked region into config.toml text — replace an existing region in place (idempotent
66
+ // refresh), else append after the user's content. Returns the new text.
67
+ export function mergeCodexHooks(toml, block) {
68
+ const text = toml || '';
69
+ const b = text.indexOf(BEGIN);
70
+ if (b >= 0) {
71
+ const e = text.indexOf(END, b);
72
+ if (e >= 0) return text.slice(0, b) + block.replace(/\n$/, '') + text.slice(e + END.length);
73
+ }
74
+ const prefix = text && !text.endsWith('\n') ? text + '\n' : text;
75
+ return `${prefix}${prefix ? '\n' : ''}${block}`;
76
+ }
77
+
78
+ // Pure: remove our marked region (leaving the user's own hooks/config untouched).
79
+ export function stripCodexHooks(toml) {
80
+ const text = toml || '';
81
+ const b = text.indexOf(BEGIN);
82
+ if (b < 0) return text;
83
+ const e = text.indexOf(END, b);
84
+ if (e < 0) return text;
85
+ return (text.slice(0, b) + text.slice(e + END.length)).replace(/\n{3,}/g, '\n\n');
86
+ }
87
+
88
+ function readConf(home) {
89
+ try { return fs.readFileSync(configPath(home), 'utf8'); } catch { return ''; }
90
+ }
91
+
92
+ // 'no-codex' → Codex CLI not on PATH (don't prompt). 'installed' → our region present. 'absent' → Codex
93
+ // installed, hooks not wired.
94
+ export function codexHooksStatus(home = homedir()) {
95
+ if (!codexOnPath()) return 'no-codex';
96
+ return readConf(home).includes(BEGIN) ? 'installed' : 'absent';
97
+ }
98
+
99
+ function writeAtomic(file, text) {
100
+ const tmp = `${file}.tmp`;
101
+ fs.writeFileSync(tmp, text);
102
+ fs.renameSync(tmp, file);
103
+ }
104
+
105
+ // Install (opt-in): copy the shared hook scripts into ~/.codex/hooks/, point their env at the shared state
106
+ // file, and splice our hook region into config.toml (creating ~/.codex if needed — safe, Codex is on PATH).
107
+ // Returns { status }: 'no-codex' (nothing to do) | 'installed'.
108
+ export function installCodexHooks(home = homedir(), { srcDir, stateFile } = {}) {
109
+ if (!codexOnPath()) return { status: 'no-codex' };
110
+ fs.mkdirSync(hooksDir(home), { recursive: true });
111
+ for (const f of SCRIPTS) fs.copyFileSync(path.join(srcDir, f), path.join(hooksDir(home), f));
112
+ fs.chmodSync(path.join(hooksDir(home), 'handmux-notify.sh'), 0o755);
113
+ fs.writeFileSync(path.join(hooksDir(home), 'handmux-notify.env'), `HANDMUX_STATE=${stateFile}\n`, { mode: 0o600 });
114
+ writeAtomic(configPath(home), mergeCodexHooks(readConf(home), codexHooksBlock(home)));
115
+ return { status: 'installed' };
116
+ }
117
+
118
+ // Uninstall: strip our config.toml region and remove the copied scripts/env. Best-effort.
119
+ export function uninstallCodexHooks(home = homedir()) {
120
+ if (fs.existsSync(configPath(home))) writeAtomic(configPath(home), stripCodexHooks(readConf(home)));
121
+ for (const f of [...SCRIPTS, 'handmux-notify.env']) {
122
+ try { fs.unlinkSync(path.join(hooksDir(home), f)); } catch { /* already gone */ }
123
+ }
124
+ return { status: 'absent' };
125
+ }