handmux 0.22.1 → 0.23.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.
package/public/index.html CHANGED
@@ -48,8 +48,8 @@
48
48
  @keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
49
49
  @media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
50
50
  </style>
51
- <script type="module" crossorigin src="/assets/index-ClOByNCn.js"></script>
52
- <link rel="stylesheet" crossorigin href="/assets/index-rjrtUwoJ.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-rjrtUwoJ.css"></noscript>
51
+ <script type="module" crossorigin src="/assets/index-D1DWcC5u.js"></script>
52
+ <link rel="stylesheet" crossorigin href="/assets/index-C3N7OkyP.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-C3N7OkyP.css"></noscript>
53
53
  </head>
54
54
  <body>
55
55
  <div id="boot-splash" aria-hidden="true">
@@ -12,6 +12,7 @@ import path from 'node:path';
12
12
  import os from 'node:os';
13
13
  import { resolveEncodedDirSession, isSessionUuid } from './scanUtils.js';
14
14
  import { resolveByExecutable, executableBasename } from './processIdentity.js';
15
+ import { createTranscriptParser, parseTranscript } from '../transcriptParse.js';
15
16
 
16
17
  // The NATIVE installer names the real binary by version (~/.local/share/claude/versions/2.1.196 —
17
18
  // ~/.local/bin/claude is only a symlink to it), and tmux #{pane_current_command} follows that basename
@@ -142,6 +143,7 @@ export const claude = {
142
143
  procMatch: /^(\S*\/)?claude(\s|$)/,
143
144
  takeoverPrefix: 'cc', // tmux session name prefix for a takeover (cc-<label>-<n>)
144
145
  classify: classifyClaude,
146
+ transcript: { createParser: createTranscriptParser, parse: parseTranscript },
145
147
  sessions: {
146
148
  isId: isSessionUuid,
147
149
  dirOptKey: 'projectsDir', // scanOrphans option that overrides `dir` (back-compat + tests)
@@ -1,15 +1,13 @@
1
- // The OpenAI Codex CLI agent driver — the second agent, added to prove the driver contract generalizes (see
2
- // claude.js for the shape). Codex 0.142+ ships a Claude-parity hook system (verified against the real CLI):
3
- // the SAME lifecycle events (UserPromptSubmit / PermissionRequest / Stop …) delivering the SAME stdin JSON
4
- // fields (prompt / tool_input / last_assistant_message / stop_hook_active …). So handmux wires Codex through
5
- // its config.toml hooks (see cli/codexHooks.js) reusing the shared Claude hook scripts, and Codex CLASSIFIES
6
- // with the very same function as Claude — full working / 需要你 / done parity, not just turn-done.
1
+ // Codex process/session discovery. App Server owns chat, approvals, stopping, inbox state and exact thread
2
+ // identity; plain Codex is identified only as a live process until the user opts into managed mode.
7
3
  import path from 'node:path';
8
4
  import os from 'node:os';
9
5
  import { promises as fsp } from 'node:fs';
10
6
  import { readHead, readTail, firstCwd, isSessionUuid } from './scanUtils.js';
11
- import { classifyClaude } from './claude.js';
12
7
  import { resolveByExecutable, executableBasename } from './processIdentity.js';
8
+ import {
9
+ createCodexTranscriptParser, isCodexSyntheticUserText, parseCodexTranscript,
10
+ } from '../codexTranscriptParse.js';
13
11
 
14
12
  export const sessionsDir = (home = os.homedir()) => path.join(home, '.codex', 'sessions');
15
13
 
@@ -20,10 +18,25 @@ export function rolloutSessionId(name) {
20
18
  return m ? m[1] : null;
21
19
  }
22
20
 
21
+ const UUID_PARTS = '([0-9a-f]{8})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{12})';
22
+
23
+ function lastUuidAfter(text, prefix) {
24
+ const matches = [...String(text || '').matchAll(new RegExp(`${prefix}\\s*${UUID_PARTS}`, 'gi'))];
25
+ const match = matches.at(-1);
26
+ return match ? match.slice(1).join('-').toLowerCase() : null;
27
+ }
28
+
29
+ // A normal Codex exit prints the exact command for the session that just exited. Keep this deliberately
30
+ // stricter than a generic `codex resume` search so conversation text or shell history is not mistaken for
31
+ // the current exit notice.
32
+ export function codexExitSessionId(text) {
33
+ return lastUuidAfter(text, 'To continue this session, run\\s+codex\\s+resume');
34
+ }
35
+
23
36
  // Last user turn out of a Codex rollout tail, for a recognizable one-line label. Codex records turns as
24
37
  // response_item messages: {payload:{type:'message',role:'user',content:[{type:'input_text',text}]}} (and a
25
- // flatter {type:'message',role:'user',...} in some versions). Skips the synthetic first turn Codex injects
26
- // (environment_context / user_instructions, which are XML-ish and start with '<'). Best-effort → '' on miss.
38
+ // flatter {type:'message',role:'user',...} in some versions). Skips the synthetic context turns Codex
39
+ // injects (including repository instructions after a resume). Best-effort → '' on miss.
27
40
  export function codexUserSnippet(tailText, max = 80) {
28
41
  const rows = String(tailText).split('\n');
29
42
  for (let i = rows.length - 1; i >= 0; i--) {
@@ -38,8 +51,9 @@ export function codexUserSnippet(tailText, max = 80) {
38
51
  let text = '';
39
52
  if (typeof c === 'string') text = c;
40
53
  else if (Array.isArray(c)) text = c.filter((b) => b && (b.type === 'input_text' || b.type === 'text')).map((b) => b.text).join(' ');
54
+ if (!text || isCodexSyntheticUserText(text)) continue;
41
55
  text = text.replace(/\s+/g, ' ').trim();
42
- if (!text || text[0] === '<') continue; // synthetic environment/instructions turn → keep scanning
56
+ if (!text) continue;
43
57
  return text.length > max ? `${text.slice(0, max)}…` : text;
44
58
  }
45
59
  return '';
@@ -82,6 +96,39 @@ async function recentRollouts(dir, limit = 80) {
82
96
  return out.slice(0, limit);
83
97
  }
84
98
 
99
+ // Resolve one known App Server thread id to its exact rollout. This never falls back by cwd: several
100
+ // Codex panes can share a project, while the UUID suffix is the durable one-to-one session identity.
101
+ const rolloutPathCache = new Map();
102
+ export async function resolveCodexRollout(dir, sessionId) {
103
+ if (!isSessionUuid(sessionId)) return null;
104
+ const key = `${dir}\0${sessionId}`;
105
+ const cached = rolloutPathCache.get(key);
106
+ if (cached) {
107
+ try { await fsp.access(cached); return cached; } catch { rolloutPathCache.delete(key); }
108
+ }
109
+ const suffix = `-${sessionId}.jsonl`;
110
+ let found = null;
111
+ async function descend(current, depth) {
112
+ if (found) return;
113
+ let entries;
114
+ try { entries = await fsp.readdir(current, { withFileTypes: true }); } catch { return; }
115
+ if (depth < 3) {
116
+ for (const entry of entries) {
117
+ if (!entry.isDirectory()) continue;
118
+ await descend(path.join(current, entry.name), depth + 1);
119
+ if (found) return;
120
+ }
121
+ return;
122
+ }
123
+ const match = entries.find((entry) => entry.isFile()
124
+ && entry.name.startsWith('rollout-') && entry.name.endsWith(suffix));
125
+ if (match) found = path.join(current, match.name);
126
+ }
127
+ await descend(dir, 0);
128
+ if (found) rolloutPathCache.set(key, found);
129
+ return found;
130
+ }
131
+
85
132
  // Resolve a live orphan's cwd to its Codex session: the newest rollout whose recorded cwd matches. Same
86
133
  // shape as Claude's resolver ({ sessionId, state, snippet, lastActivity }) so the orphan engine is agnostic.
87
134
  export async function resolveCodexSession(dir, cwd, { busyMs = 8000, now = Date.now } = {}) {
@@ -94,7 +141,7 @@ export async function resolveCodexSession(dir, cwd, { busyMs = 8000, now = Date.
94
141
  if (!sessionId) continue;
95
142
  let snippet = '';
96
143
  try { snippet = codexUserSnippet(await readTail(file)); } catch { /* best effort */ }
97
- return { sessionId, state: now() - mtime < busyMs ? 'busy' : 'idle', snippet, lastActivity: Math.round(mtime) };
144
+ return { sessionId, state: now() - mtime < busyMs ? 'busy' : 'idle', snippet, lastActivity: Math.round(mtime), file };
98
145
  }
99
146
  return {};
100
147
  }
@@ -107,8 +154,7 @@ export const codex = {
107
154
  procNames: ['codex'],
108
155
  procMatch: /^(\S*\/)?codex(\s|$)/,
109
156
  takeoverPrefix: 'cx', // tmux session name prefix for a takeover (cx-<label>-<n>)
110
- classify: classifyClaude, // Codex hook payloads match Claude's field-for-field — same classifier
111
-
157
+ transcript: { createParser: createCodexTranscriptParser, parse: parseCodexTranscript },
112
158
  sessions: {
113
159
  isId: isSessionUuid,
114
160
  dirOptKey: 'sessionsDir', // scanOrphans option that overrides `dir`
@@ -117,5 +163,6 @@ export const codex = {
117
163
  // `codex resume <uuid>` continues the session from its rollout file.
118
164
  resumeArgs: (id) => ['codex', 'resume', id],
119
165
  resumeCmd: (id) => `codex resume ${id}`,
166
+ managedResumeCmd: (id) => `handmux codex resume ${id}`,
120
167
  },
121
168
  };
@@ -1,7 +1,6 @@
1
1
  // The agent registry: the one place that lists which coding agents handmux drives. Adding a third agent is a
2
- // new driver module (see claude.js / codex.js) plus one line here — the inbox pipeline (claudeEvents.js),
3
- // the orphan/takeover engine (orphans.js), and the hook installers all consume drivers through this list,
4
- // so none of them hard-codes an agent name.
2
+ // new driver module (see claude.js / codex.js) plus one line here — the inbox pipeline and orphan/takeover
3
+ // engine consume drivers through this list, so process and session behavior stays centralized.
5
4
  import { claude } from './claude.js';
6
5
  import { codex } from './codex.js';
7
6
 
@@ -13,9 +13,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
13
13
  // Default lives under server/data (gitignored runtime data); override with CLAUDE_STATE_FILE.
14
14
  export const DEFAULT_STATE_FILE = process.env.CLAUDE_STATE_FILE || path.resolve(here, '../data/claude-state.json');
15
15
 
16
- // Classify a Claude hook event → inbox kind. The logic now lives in the Claude driver (agents/claude.js);
17
- // re-exported here because tests and callers import it by this path. Per-pane classification in getStates
18
- // dispatches through getAgent(entry.agent) so Codex (and future agents) classify with their own driver.
16
+ // Classify a Claude hook event → inbox kind. Re-exported because tests and callers import this path.
19
17
  export const classifyEvent = claude.classify;
20
18
 
21
19
  // Which display VIEW a kind pushes as — and so what the device notification fires for. permission→需要你,
@@ -28,10 +26,8 @@ const VIEW_LABEL = { needs: '需要你', done: '已完成' };
28
26
 
29
27
  // The dedup key for a pane's current push view. `needs` is view-only: Claude signals one permission gate via
30
28
  // TWO hooks (permreq then permission_prompt) at different ts, and both must collapse to a SINGLE 需要你. But
31
- // `done` is ts-sensitive: each finished turn is a fresh "已完成 / 该你了". For Claude, two dones are always
32
- // separated by a 进行中 that re-arms the dedup anyway, so keying done by ts is equivalent; for Codex — whose
33
- // ONLY event is turn-complete, with no working/prompt event in between — it's what makes turn 2, 3, … push
34
- // instead of latching on turn 1's done forever.
29
+ // `done` is ts-sensitive: each finished turn is a fresh "已完成 / 该你了". For Claude, two dones are normally
30
+ // separated by a 进行中 that re-arms the dedup; the timestamp also protects unusual missing-start sequences.
35
31
  function pushKey(view, ts) { return view === 'done' ? `done:${ts}` : view; }
36
32
 
37
33
  // A 进行中 (working) is a LATCHED state: set by UserPromptSubmit, normally closed by Stop. But an ESC
@@ -146,6 +142,7 @@ function readStateFile(file) {
146
142
  export function createClaudeEvents({
147
143
  commands,
148
144
  push,
145
+ codexApp = null,
149
146
  file = DEFAULT_STATE_FILE,
150
147
  now = () => Date.now(),
151
148
  statMtime = defaultStatMtime,
@@ -169,6 +166,7 @@ export function createClaudeEvents({
169
166
  function prime() {
170
167
  const recorded = readStateFile(file);
171
168
  for (const [pane, r] of Object.entries(recorded)) {
169
+ if (r?.agent === 'codex') continue; // legacy rows are never a Codex state source
172
170
  const c = r && typeof r.src === 'string' ? getAgent(r.agent).classify(r.src, r.payload || {}) : null;
173
171
  const view = c ? PUSH_VIEW[c.kind] : undefined;
174
172
  if (view) lastPushed[pane] = pushKey(view, r.ts); // resting 需要你/已完成 → treat as seen, don't replay
@@ -190,6 +188,37 @@ export function createClaudeEvents({
190
188
  try { await push.sendToSession(lp.session, payload, opts); } catch { /* best effort */ }
191
189
  }
192
190
 
191
+ async function updatePush(pane, c, ts, lp, { gone = false, suppressPush = false } = {}) {
192
+ const kind = c ? c.kind : null;
193
+ const view = PUSH_VIEW[kind];
194
+ const key = view ? pushKey(view, ts) : undefined;
195
+ if (kind === 'idle') return;
196
+ if (gone || !view) {
197
+ lastPushed[pane] = null;
198
+ } else if (suppressPush) {
199
+ lastPushed[pane] = key;
200
+ } else if (lastPushed[pane] !== key && lp?.session) {
201
+ lastPushed[pane] = key;
202
+ await sendPush(pane, view, c, lp);
203
+ }
204
+ }
205
+
206
+ // Agent identity is part of pane navigation, not inbox state. Resolve it from the pane's live foreground
207
+ // process so the phone can choose terminal/chat during the initial /panes load instead of waiting for the
208
+ // slower /states reconciliation pass. Work on copies because executable corroboration normalizes `cmd`.
209
+ async function identifyPaneAgents(panes = []) {
210
+ const livePanes = panes.map((pane) => ({
211
+ ...pane,
212
+ cmd: typeof pane?.cmd === 'string' ? pane.cmd : (pane?.command || ''),
213
+ }));
214
+ try { await resolveVersionedComms(livePanes, run, commVerdicts); } catch { /* exact names still work */ }
215
+ try { await resolveCodexComms(livePanes, run, commVerdicts); } catch { /* exact names still work */ }
216
+ return Object.fromEntries(livePanes.flatMap((pane) => {
217
+ const agent = agentForProc(pane.cmd);
218
+ return agent ? [[pane.id, agent.id]] : [];
219
+ }));
220
+ }
221
+
193
222
  // Read the state file, reconcile every recorded pane against live tmux, and in ONE pass: (1) fire push
194
223
  // for 需要你/已完成 transitions (deduped, global — independent of any caller's session filter), and
195
224
  // (2) build the pane→state roster the inbox shows. A pane is dropped from the roster when its latest
@@ -201,17 +230,21 @@ export function createClaudeEvents({
201
230
  const allow = allowedSessions == null ? null : new Set(allowedSessions);
202
231
  const recorded = readStateFile(file);
203
232
  let live = null;
233
+ let managedCodex = {};
204
234
  try {
205
235
  const panes = await commands.listLivePanes();
206
- // Normalize ambiguous commands BEFORE identity/liveness matching: native Claude may report a bare
207
- // version and npm-installed Codex reports node. Both require foreground-TTY + real-executable proof.
208
- await resolveVersionedComms(panes, run, commVerdicts);
209
- await resolveCodexComms(panes, run, commVerdicts);
236
+ const agents = await identifyPaneAgents(panes);
237
+ for (const pane of panes) if (agents[pane.id]) pane.cmd = agents[pane.id];
210
238
  live = new Map(panes.map((p) => [p.id, p]));
239
+ managedCodex = codexApp?.inboxStates ? await codexApp.inboxStates(panes) : {};
211
240
  } catch { /* tmux down */ }
212
241
 
213
242
  const out = {};
214
243
  for (const [pane, rec] of Object.entries(recorded)) {
244
+ // Older Handmux versions wrote Codex events into this Claude Hook state file. Codex is now strictly
245
+ // App Server-backed, so even an unmanaged pane must ignore those stale rows instead of reviving an
246
+ // approximate status or notification.
247
+ if (rec?.agent === 'codex') continue;
215
248
  const agent = getAgent(rec && rec.agent);
216
249
  let c = rec && typeof rec.src === 'string' ? agent.classify(rec.src, rec.payload || {}) : null;
217
250
  // A 需要你 the user already resolved leaves no closing hook (see PERM_RESOLVED_GUARD_MS). statMtime
@@ -247,17 +280,7 @@ export function createClaudeEvents({
247
280
  // 60s "still waiting" idle neither pushes nor disturbs the dedup (the pane is still in the same
248
281
  // resting state). working / end / gone / unclassifiable re-arm the dedup for the next entry.
249
282
  if (live) {
250
- const kind = c ? c.kind : null;
251
- const view = PUSH_VIEW[kind]; // 'needs' | 'done' | undefined
252
- const key = view ? pushKey(view, rec.ts) : undefined;
253
- if (kind === 'idle') {
254
- /* trailing idle reminder — no push, keep the dedup as the preceding done left it */
255
- } else if (gone || !view) {
256
- lastPushed[pane] = null; // 进行中 / 结束 / gone → re-arm for the next 需要你 / 已完成
257
- } else if (lastPushed[pane] !== key && lp.session) {
258
- lastPushed[pane] = key;
259
- await sendPush(pane, view, c, lp);
260
- }
283
+ await updatePush(pane, c, rec.ts, lp, { gone });
261
284
  }
262
285
 
263
286
  // (2) roster — drop ended / dead / claude-exited panes; resolve location from the live tmux row.
@@ -271,6 +294,22 @@ export function createClaudeEvents({
271
294
  out[pane] = { ...loc, kind: c.kind, msg: c.msg || '', ts: rec.ts || 0, agent: agent.id };
272
295
  }
273
296
 
297
+ // App Server is the sole Codex state source. A neutral state still leaves process presence below to
298
+ // identify the pane as Codex without guessing its activity.
299
+ if (live) {
300
+ for (const [pane, state] of Object.entries(managedCodex)) {
301
+ const lp = live.get(pane);
302
+ if (!lp) continue;
303
+ const c = state.kind ? { kind: state.kind, msg: state.msg || '' } : null;
304
+ await updatePush(pane, c, state.ts || 0, lp, { suppressPush: state.suppressPush });
305
+ if (allow && !allow.has(lp.session)) continue;
306
+ out[pane] = {
307
+ session: lp.session, window: lp.window, windowName: lp.windowName,
308
+ kind: c?.kind || null, msg: c?.msg || '', ...(c ? { ts: state.ts || 0 } : {}), agent: 'codex',
309
+ };
310
+ }
311
+ }
312
+
274
313
  // (3) process presence — a pane whose FOREGROUND program IS a coding agent reads as "agent here" even
275
314
  // with no active turn on record: a fresh session that hasn't prompted yet, or one right after `/clear`
276
315
  // (whose SessionEnd dropped its roster entry above though the agent is still running). This is what the
@@ -334,23 +373,27 @@ export function createClaudeEvents({
334
373
  // isn't a Claude pane / the recorded payload carries no session info, so callers can fall back cleanly.
335
374
  function paneSession(pane) {
336
375
  const rec = readStateFile(file)[pane];
376
+ if (rec?.agent === 'codex') return null;
337
377
  const p = rec && rec.payload;
338
378
  if (!p || typeof p !== 'object') return null;
339
379
  const transcriptPath = typeof p.transcript_path === 'string' ? p.transcript_path : null;
340
380
  const sessionId = typeof p.session_id === 'string' ? p.session_id : null;
341
381
  const cwd = typeof p.cwd === 'string' ? p.cwd : null;
342
382
  if (!transcriptPath && !sessionId) return null;
343
- return { sessionId, transcriptPath, cwd };
383
+ return {
384
+ sessionId, transcriptPath, cwd,
385
+ agent: typeof rec.agent === 'string' ? rec.agent : undefined,
386
+ };
344
387
  }
345
388
 
346
- // Agent identity is needed independently of session metadata: a Codex pane may not have a Claude-style
347
- // transcript path, but the current Claude-only chat lens must still fail closed instead of cwd-falling
348
- // back to an unrelated Claude jsonl. Untagged legacy hook rows are Claude.
389
+ // Agent identity is needed independently of session metadata so the chat lens chooses the matching
390
+ // backend (Claude transcript or Codex App Server). Untagged legacy hook rows are Claude.
349
391
  function paneAgent(pane) {
350
392
  const rec = readStateFile(file)[pane];
351
393
  if (!rec || typeof rec !== 'object') return null;
394
+ if (rec.agent === 'codex') return null;
352
395
  return typeof rec.agent === 'string' && rec.agent ? rec.agent : 'claude';
353
396
  }
354
397
 
355
- return { getStates, start, stop, paneSession, paneAgent };
398
+ return { getStates, identifyPaneAgents, start, stop, paneSession, paneAgent };
356
399
  }
@@ -0,0 +1,77 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { spawn as spawnChild } from 'node:child_process';
5
+
6
+ const PANE_RE = /^%\d+$/;
7
+
8
+ export function codexAppSocketPath(pane, home = os.homedir()) {
9
+ if (!PANE_RE.test(pane || '')) throw new Error('managed Codex requires a tmux pane');
10
+ return path.join(home, '.handmux', 'codex-app', `${pane.slice(1)}.sock`);
11
+ }
12
+
13
+ function waitForSocket(socketPath, child, {
14
+ exists = fs.existsSync,
15
+ wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
16
+ timeoutMs = 5_000,
17
+ now = Date.now,
18
+ } = {}) {
19
+ return new Promise((resolve, reject) => {
20
+ let exited = false;
21
+ let exitCode = null;
22
+ child.once('exit', (code) => { exited = true; exitCode = code; });
23
+ child.once('error', reject);
24
+ (async () => {
25
+ const started = now();
26
+ while (!exists(socketPath)) {
27
+ if (exited) throw new Error(`Codex App Server exited before startup (${exitCode ?? 'unknown'})`);
28
+ if (now() - started >= timeoutMs) throw new Error('Codex App Server startup timed out');
29
+ await wait(25);
30
+ }
31
+ resolve();
32
+ })().catch(reject);
33
+ });
34
+ }
35
+
36
+ function finish(child) {
37
+ return new Promise((resolve, reject) => {
38
+ child.once('error', reject);
39
+ child.once('exit', (code, signal) => resolve({ code, signal }));
40
+ });
41
+ }
42
+
43
+ // One App Server belongs to one tmux pane. The TUI and Handmux connect to that pane-owned Unix socket,
44
+ // so thread identity, messages, approvals, settings, interrupts and inbox state share one runtime.
45
+ export async function runManagedCodex(args = [], {
46
+ home = os.homedir(),
47
+ env = process.env,
48
+ spawn = spawnChild,
49
+ mkdir = (dir) => fs.mkdirSync(dir, { recursive: true, mode: 0o700 }),
50
+ unlink = (file) => { try { fs.unlinkSync(file); } catch (error) { if (error?.code !== 'ENOENT') throw error; } },
51
+ waitOptions,
52
+ } = {}) {
53
+ const pane = env.TMUX_PANE;
54
+ const socketPath = codexAppSocketPath(pane, home);
55
+ mkdir(path.dirname(socketPath));
56
+ unlink(socketPath);
57
+
58
+ const managedEnv = { ...env, HANDMUX_CODEX_MANAGED: '1' };
59
+ const appServer = spawn('codex', ['app-server', '--listen', `unix://${socketPath}`], {
60
+ env: managedEnv,
61
+ stdio: ['ignore', 'ignore', 'ignore'],
62
+ });
63
+ let tui = null;
64
+ try {
65
+ await waitForSocket(socketPath, appServer, waitOptions);
66
+ tui = spawn('codex', ['--remote', `unix://${socketPath}`, ...args], {
67
+ env: managedEnv,
68
+ stdio: 'inherit',
69
+ });
70
+ const result = await finish(tui);
71
+ return Number.isInteger(result.code) ? result.code : 1;
72
+ } finally {
73
+ if (tui && tui.exitCode == null && tui.signalCode == null) tui.kill('SIGTERM');
74
+ if (appServer.exitCode == null && appServer.signalCode == null) appServer.kill('SIGTERM');
75
+ unlink(socketPath);
76
+ }
77
+ }
@@ -1,13 +1,9 @@
1
- // Shared IO scaffolding for the coding-agent hook installers (claudeHooks.js, codexHooks.js) and the
2
- // statusLine capturer. The FORMAT each installer merges into differs (settings.json vs config.toml's marked
3
- // region vs the single statusLine slot) and stays in its own module — but the plumbing underneath is
4
- // identical: deploy the shared scripts, write the env, remove them on uninstall, and write atomically.
1
+ // Shared IO scaffolding for the Claude Code hook installer and statusLine capturer.
5
2
  import fs from 'node:fs';
6
3
  import path from 'node:path';
7
4
 
8
- // Scripts every agent hook installer deploys. The notify/write pair is shared because Codex mirrors
9
- // Claude's hook contract; the small Codex usage reader is inert unless handmux-write receives agent=codex.
10
- export const HOOK_SCRIPTS = ['handmux-notify.sh', 'handmux-write.cjs', 'handmux-codex-usage.cjs'];
5
+ export const HOOK_SCRIPTS = ['handmux-notify.sh', 'handmux-write.cjs'];
6
+ const LEGACY_HOOK_SCRIPTS = ['handmux-codex-usage.cjs'];
11
7
 
12
8
  // Atomic write (tmp + rename) so a crash can't leave a half-written config file. Text in, text out — callers
13
9
  // pass raw TOML for config.toml, or use writeJsonAtomic for pretty-printed settings.json.
@@ -18,18 +14,20 @@ export function writeFileAtomic(file, text) {
18
14
  }
19
15
  export const writeJsonAtomic = (file, obj) => writeFileAtomic(file, JSON.stringify(obj, null, 2));
20
16
 
21
- // Deploy the shared notify/write scripts into `hooksDir` and point their env at the shared state file. Same
22
- // bytes for every agent — only the registration target (settings.json vs config.toml) differs per installer.
17
+ // Deploy the Claude notify/write scripts into `hooksDir` and point their env at the shared state file.
23
18
  export function deployHookScripts(hooksDir, srcDir, stateFile) {
24
19
  fs.mkdirSync(hooksDir, { recursive: true });
25
20
  for (const f of HOOK_SCRIPTS) fs.copyFileSync(path.join(srcDir, f), path.join(hooksDir, f));
21
+ for (const f of LEGACY_HOOK_SCRIPTS) {
22
+ try { fs.unlinkSync(path.join(hooksDir, f)); } catch { /* absent */ }
23
+ }
26
24
  fs.chmodSync(path.join(hooksDir, 'handmux-notify.sh'), 0o755);
27
25
  fs.writeFileSync(path.join(hooksDir, 'handmux-notify.env'), `HANDMUX_STATE=${stateFile}\n`, { mode: 0o600 });
28
26
  }
29
27
 
30
28
  // Remove the deployed scripts + env (uninstall). Best-effort: a missing file is fine.
31
29
  export function removeHookScripts(hooksDir) {
32
- for (const f of [...HOOK_SCRIPTS, 'handmux-notify.env']) {
30
+ for (const f of [...HOOK_SCRIPTS, ...LEGACY_HOOK_SCRIPTS, 'handmux-notify.env']) {
33
31
  try { fs.unlinkSync(path.join(hooksDir, f)); } catch { /* already gone */ }
34
32
  }
35
33
  }
@@ -126,15 +126,11 @@ export default {
126
126
  'access.hint': ' handmux status | stop',
127
127
 
128
128
  // hooks
129
- 'hooks.confirmEnable': 'Enable coding-agent notifications (inbox)?',
130
- 'hooks.installedShort': '✓ Agent hooks installed.',
129
+ 'hooks.confirmEnable': 'Enable Claude Code states and notifications (inbox)?',
131
130
  'hooks.noClaude': 'Claude Code not detected (~/.claude missing) — nothing to install.',
132
- 'hooks.noAgents': 'No coding agent detected (no ~/.claude, and codex not on PATH) — nothing to install.',
133
- 'hooks.installed': '✓ Claude hooks installed → ~/.claude/settings.json',
134
131
  'hooks.installedClaude': '✓ Claude Code hooks installed → ~/.claude/settings.json',
135
- 'hooks.installedCodex': '✓ Codex hooks installed → ~/.codex/config.toml',
136
- 'hooks.installedHint': ' Restart or open a new agent session to load them; the inbox lights up as panes report.',
137
- 'hooks.removed': '✓ Agent hooks removed.',
132
+ 'hooks.installedHint': ' Restart or open a new Claude Code session to load them; the inbox lights up as panes report.',
133
+ 'hooks.removed': '✓ Claude Code hooks removed.',
138
134
  'hooks.usage': 'usage: handmux hooks install|uninstall',
139
135
 
140
136
  // Claude statusLine usage capturer (powers the phone Usage page's 5h/weekly bars)
@@ -341,6 +337,7 @@ export default {
341
337
  handmux stop | restart | status
342
338
  handmux logs [--follow] [--lines N]
343
339
  handmux push <title> <body> notify your phone from a script (--session X · --device K · --tag T · --url U)
340
+ handmux codex [args...] launch a Codex TUI synchronized with chat view
344
341
  handmux restore [--dry-run] [--checkpoint <id>] [--session <name>]
345
342
  handmux restore --list
346
343
  handmux update upgrade to the latest published version (npm i -g handmux@latest)
@@ -350,7 +347,7 @@ The model: 'start' runs · 'setup' configures (writes ~/.handmux/config.json) ·
350
347
 
351
348
  more:
352
349
  handmux config show the effective config + where each value came from
353
- handmux hooks install|uninstall enable/disable agent notifications (inbox)
350
+ handmux hooks install|uninstall enable/disable Claude Code notifications (inbox)
354
351
  handmux service install|uninstall start at login (launchd/systemd)
355
352
  handmux help flags one-run flags + env vars (scripting / headless / Docker)
356
353
  --config PATH · --lang en|zh · --version, -v
@@ -125,15 +125,11 @@ export default {
125
125
  'access.hint': ' handmux status | stop',
126
126
 
127
127
  // hooks
128
- 'hooks.confirmEnable': '启用编程 agent 通知(收件箱)?',
129
- 'hooks.installedShort': '✓ agent hooks 已安装。',
128
+ 'hooks.confirmEnable': '启用 Claude Code 状态与通知(收件箱)?',
130
129
  'hooks.noClaude': '未检测到 Claude Code(缺少 ~/.claude)—— 无需安装。',
131
- 'hooks.noAgents': '未检测到编程 agent(无 ~/.claude,且 PATH 上没有 codex)—— 无需安装。',
132
- 'hooks.installed': '✓ Claude hooks 已安装 → ~/.claude/settings.json',
133
130
  'hooks.installedClaude': '✓ Claude Code hooks 已安装 → ~/.claude/settings.json',
134
- 'hooks.installedCodex': '✓ Codex hooks 已安装 → ~/.codex/config.toml',
135
- 'hooks.installedHint': ' 重启或新开一个 agent 会话以加载;窗格上报后收件箱就会亮起。',
136
- 'hooks.removed': '✓ agent hooks 已移除。',
131
+ 'hooks.installedHint': ' 重启或新开一个 Claude Code 会话以加载;窗格上报后收件箱就会亮起。',
132
+ 'hooks.removed': '✓ Claude Code hooks 已移除。',
137
133
  'hooks.usage': '用法:handmux hooks install|uninstall',
138
134
 
139
135
  // Claude statusLine 用量捕获(点亮手机用量页的 5h/周额度条)
@@ -340,6 +336,7 @@ export default {
340
336
  handmux stop | restart | status
341
337
  handmux logs [--follow] [--lines N]
342
338
  handmux push <标题> <正文> 从脚本推一条通知到手机(--session 会话 · --device 设备key · --tag · --url)
339
+ handmux codex [参数...] 启动与对话视图同步的 Codex TUI
343
340
  handmux restore [--dry-run] [--checkpoint <id>] [--session <name>]
344
341
  handmux restore --list
345
342
  handmux update 升级到最新发布版本(npm i -g handmux@latest)
@@ -349,7 +346,7 @@ export default {
349
346
 
350
347
  更多:
351
348
  handmux config 显示生效的配置 + 每一项来自哪里
352
- handmux hooks install|uninstall 启用/停用 agent 通知(收件箱)
349
+ handmux hooks install|uninstall 启用/停用 Claude Code 通知(收件箱)
353
350
  handmux service install|uninstall 开机自启(launchd/systemd)
354
351
  handmux help flags 一次性 flag + 环境变量(脚本 / 无头 / Docker)
355
352
  --config PATH · --lang en|zh · --version, -v
@@ -0,0 +1,49 @@
1
+ // Cleanup for Handmux versions that used to install Codex lifecycle hooks. Current Codex integration is
2
+ // App Server-only; this module can only remove Handmux's exact marked region and files, never install or
3
+ // synchronize hooks and never touch user-owned Codex configuration outside the marker.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import { writeFileAtomic } from './hookScaffold.js';
8
+
9
+ const BEGIN = '# >>> handmux codex-hooks >>>';
10
+ const END = '# <<< handmux codex-hooks <<<';
11
+ const LEGACY_FILES = [
12
+ 'handmux-notify.sh',
13
+ 'handmux-write.cjs',
14
+ 'handmux-codex-usage.cjs',
15
+ 'handmux-notify.env',
16
+ ];
17
+
18
+ export function stripLegacyCodexHooks(toml) {
19
+ const text = toml || '';
20
+ const start = text.indexOf(BEGIN);
21
+ if (start < 0) return text;
22
+ const end = text.indexOf(END, start);
23
+ if (end < 0) return text;
24
+ return (text.slice(0, start) + text.slice(end + END.length)).replace(/\n{3,}/g, '\n\n');
25
+ }
26
+
27
+ export function removeLegacyCodexHooks(home = homedir()) {
28
+ const codexDir = path.join(home, '.codex');
29
+ const configuredPath = path.join(codexDir, 'config.toml');
30
+ let changed = false;
31
+ try {
32
+ const configFile = fs.lstatSync(configuredPath).isSymbolicLink()
33
+ ? fs.realpathSync(configuredPath) : configuredPath;
34
+ const mode = fs.statSync(configFile).mode & 0o777;
35
+ const before = fs.readFileSync(configFile, 'utf8');
36
+ const after = stripLegacyCodexHooks(before);
37
+ if (after !== before) {
38
+ writeFileAtomic(configFile, after);
39
+ fs.chmodSync(configFile, mode);
40
+ changed = true;
41
+ }
42
+ } catch { /* no readable config */ }
43
+
44
+ const hooksDir = path.join(codexDir, 'hooks');
45
+ for (const name of LEGACY_FILES) {
46
+ try { fs.unlinkSync(path.join(hooksDir, name)); changed = true; } catch { /* absent or user-inaccessible */ }
47
+ }
48
+ return { changed };
49
+ }