handmux 0.22.0 → 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.
Files changed (42) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/handmux.js +29 -28
  4. package/hooks/handmux-notify.sh +2 -4
  5. package/hooks/handmux-write.cjs +2 -24
  6. package/package.json +4 -4
  7. package/public/assets/index-C3N7OkyP.css +32 -0
  8. package/public/assets/index-D1DWcC5u.js +337 -0
  9. package/public/index.html +2 -2
  10. package/src/agents/claude.js +2 -0
  11. package/src/agents/codex.js +60 -13
  12. package/src/agents/index.js +2 -3
  13. package/src/browser/manager.js +37 -11
  14. package/src/browser/publicProxy.js +23 -1
  15. package/src/browser/routes.js +9 -0
  16. package/src/browser/siteVersion.js +124 -0
  17. package/src/claudeEvents.js +70 -27
  18. package/src/cli/codexManaged.js +77 -0
  19. package/src/cli/hookScaffold.js +8 -10
  20. package/src/cli/i18n/en.js +5 -8
  21. package/src/cli/i18n/zh.js +5 -8
  22. package/src/cli/legacyCodexHooks.js +49 -0
  23. package/src/codexAppServer.js +1620 -0
  24. package/src/codexDiff.js +108 -0
  25. package/src/codexTranscriptParse.js +413 -0
  26. package/{hooks/handmux-codex-usage.cjs → src/codexUsageSnapshot.cjs} +3 -15
  27. package/src/httpApi.js +4 -1
  28. package/src/orphans.js +3 -1
  29. package/src/routes/codex.js +473 -0
  30. package/src/routes/sessions.js +7 -2
  31. package/src/routes/system.js +9 -22
  32. package/src/routes/transcript.js +75 -19
  33. package/src/server.js +21 -13
  34. package/src/tmux/commands.js +11 -3
  35. package/src/transcriptReader.js +5 -5
  36. package/src/usage.js +4 -5
  37. package/src/workspace/agentRunner.js +4 -1
  38. package/src/workspace/capture.js +54 -2
  39. package/src/workspace/checkpointer.js +6 -2
  40. package/public/assets/index-CRAlQZDz.css +0 -32
  41. package/public/assets/index-CzlitgRX.js +0 -335
  42. package/src/cli/codexHooks.js +0 -123
@@ -1,14 +1,16 @@
1
- // Read a pane's Claude Code jsonl session and return it as normalized chat messages (the "对话" lens's
2
- // read-projection). Pane → cwd (tmux) → the session file under ~/.claude/projects/<encoded-cwd>/.
1
+ // Read a pane's agent session as normalized chat messages. Claude reads its Hook-bound jsonl and keeps its
2
+ // established cwd fallback. Managed Codex gets its exact thread id from App Server, then reads that
3
+ // thread's durable rollout; App Server's history snapshot does not contain every completed tool.
3
4
  // Server-side paginated — the phone must never receive the whole transcript:
4
5
  // - Recent window (default + polling): ?pane=&limit=10&since=<hash> — the last `limit` messages, with
5
6
  // the same content-hash `since`省流 as /history (unchanged window → 204). `hasMore`/`firstSeq` tell the
6
7
  // client whether/where an older page starts.
7
8
  // - History page (scroll-up, not polled): ?pane=&before=<k>&limit=10 — the last `limit` messages with
8
9
  // `k < before`, no hash.
9
- // `k` = each message's global ordinal in the parsed transcript — stable because the jsonl is append-only,
10
- // so it doubles as the client's dedup key. Only the requested page is copied and decorated with `k`; the
11
- // full parsed transcript is never mapped/filtered on every poll. The underlying reader asynchronously scans once,
10
+ // `k` = each message's current global ordinal and pagination cursor. It is stable because both Claude and
11
+ // Codex transcripts come from their append-only durable logs.
12
+ // Only the requested page is copied and decorated with `k`; the full parsed transcript is never mapped/filtered
13
+ // on every poll. The underlying reader asynchronously scans once,
12
14
  // then parses only appended complete lines; replacement/truncation resets it. `limit` clamps to [1,100],
13
15
  // default 10. Mounted under /api.
14
16
  import express from 'express';
@@ -16,10 +18,13 @@ import path from 'node:path';
16
18
  import { createHash } from 'node:crypto';
17
19
  import { isPaneId } from '../tmux/commands.js';
18
20
  import { projectsDir } from '../agents/claude.js';
21
+ import { AGENTS } from '../agents/index.js';
19
22
  import { resolveEncodedDirSession, encodeProjectDir } from '../agents/scanUtils.js';
20
23
  import { transcriptReader } from '../transcriptReader.js';
21
24
  import { parsePendingPrompt } from '../pendingPrompt.js';
22
25
  import { readClaudeContext } from '../usage.js';
26
+ import { resolveCodexRollout, sessionsDir as codexSessionsDir } from '../agents/codex.js';
27
+ import { enrichCodexFileDiffs } from '../codexDiff.js';
23
28
 
24
29
  // Pure index-based projection: O(page size), not O(transcript size). `before` stays exclusive, including
25
30
  // for unusual fractional cursors (the old `k < before` filter included indices through ceil(before) - 1).
@@ -36,20 +41,39 @@ export function pageTranscript(parsed, before, limit) {
36
41
  };
37
42
  }
38
43
 
39
- export function transcriptRoutes({ commands, claudeEvents, reader = transcriptReader }) {
44
+ export function transcriptRoutes({
45
+ commands, claudeEvents, reader = transcriptReader, codexApp,
46
+ findCodexRollout = resolveCodexRollout, codexSessions = codexSessionsDir(),
47
+ }) {
40
48
  const r = express.Router();
41
49
 
42
- // These three endpoints are one Claude-specific lens surface. Gate them together so a future endpoint
43
- // cannot accidentally skip the invariant: explicit non-Claude requests and panes already bound to a
44
- // non-Claude hook record are rejected before any cwd→newest-Claude-session fallback or screen scraping.
50
+ // Bind every request to the pane's actual agent before touching a session file. A caller cannot claim
51
+ // Claude for a Codex pane (or vice versa) and make cwd fallback expose a different agent's conversation.
45
52
  r.use(['/pending-prompt', '/context', '/transcript'], (req, res, next) => {
46
53
  const pane = req.query.pane;
47
54
  if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
48
- const requested = req.query.agent;
49
- const bound = claudeEvents && claudeEvents.paneAgent ? claudeEvents.paneAgent(pane) : null;
50
- if ((requested != null && requested !== '' && requested !== 'claude') || (bound && bound !== 'claude')) {
55
+ const requested = typeof req.query.agent === 'string' && req.query.agent ? req.query.agent : null;
56
+ const hooked = claudeEvents?.paneSession ? claudeEvents.paneSession(pane) : null;
57
+ const bound = (claudeEvents?.paneAgent ? claudeEvents.paneAgent(pane) : null) || hooked?.agent || null;
58
+ // Managed Codex is verified again through its pane-owned App Server and exact thread id below. Claude
59
+ // still fails closed against any conflicting Hook identity before cwd fallback.
60
+ if (requested && bound && requested !== bound && requested !== 'codex') {
61
+ return res.status(409).json({ error: 'pane agent mismatch' });
62
+ }
63
+ const id = requested === 'codex' ? 'codex' : (bound || requested || 'claude');
64
+ const agent = AGENTS.find((candidate) => candidate.id === id);
65
+ if (!agent || (agent.id !== 'codex' && !agent.transcript?.createParser)) {
51
66
  return res.status(409).json({ error: 'chat lens unsupported for this agent' });
52
67
  }
68
+ req.chatAgent = agent;
69
+ req.chatSession = hooked;
70
+ next();
71
+ });
72
+
73
+ // Claude's pending menus and context meter come from its TUI. Codex exposes both through App Server and
74
+ // therefore never calls these terminal-specific endpoints.
75
+ r.use(['/pending-prompt', '/context'], (req, res, next) => {
76
+ if (req.chatAgent.id !== 'claude') return res.status(409).json({ error: 'interactive chat controls unsupported for this agent' });
53
77
  next();
54
78
  });
55
79
 
@@ -69,7 +93,7 @@ export function transcriptRoutes({ commands, claudeEvents, reader = transcriptRe
69
93
  // The 对话 composer polls this to show a small "模型 · 24%" chip. Best-effort: never 500 on a missing file.
70
94
  r.get('/context', (req, res, next) => {
71
95
  try {
72
- const hooked = claudeEvents && claudeEvents.paneSession ? claudeEvents.paneSession(req.query.pane) : null;
96
+ const hooked = req.chatSession;
73
97
  const sid = hooked && (hooked.sessionId || (hooked.transcriptPath ? path.basename(hooked.transcriptPath).replace(/\.jsonl$/, '') : null));
74
98
  const ctx = sid ? readClaudeContext(sid) : null;
75
99
  return res.json({ model: (ctx && ctx.model) || null, usedPercent: (ctx && typeof ctx.usedPercent === 'number') ? ctx.usedPercent : null });
@@ -80,16 +104,49 @@ export function transcriptRoutes({ commands, claudeEvents, reader = transcriptRe
80
104
  const limit = Math.min(Math.max(Number(req.query.limit) || 10, 1), 100);
81
105
  const before = req.query.before != null && req.query.before !== '' ? Number(req.query.before) : null;
82
106
  try {
83
- // Bind pane→session via the hook state's per-pane transcript_path (authoritative — see claudeEvents
84
- // .paneSession) when available; only a pane with no hook state (hooks off / not a Claude pane) falls
85
- // back to cwd→newest-jsonl, which can't tell apart two sessions that share a cwd.
107
+ if (req.chatAgent.id === 'codex') {
108
+ const empty = { messages: [], hash: '', session: null, hasMore: false, firstSeq: null };
109
+ try {
110
+ const discovered = await codexApp?.discover?.(req.query.pane);
111
+ if (!discovered?.managed) return res.json({ ...empty, unavailable: 'session-unmanaged' });
112
+ const sessionId = discovered?.threadId || null;
113
+ if (!sessionId) return res.json({ ...empty, unavailable: 'session-unbound' });
114
+ const file = await findCodexRollout(codexSessions, sessionId);
115
+ // A newly started thread has no rollout until its first turn. Keep the exact session binding and
116
+ // show an empty conversation; the next poll will discover the file once Codex creates it.
117
+ if (!file) return res.json({ ...empty, session: sessionId });
118
+ const parsed = await reader.read(file, req.chatAgent.transcript.createParser);
119
+ const page = pageTranscript(parsed, before, limit);
120
+ let { messages } = page;
121
+ const { firstSeq, hasMore } = page;
122
+ const needsFileLines = messages.some((message) => message.tool?.name === 'apply_patch'
123
+ && message.tool.diff && !message.tool.diff.hunks?.length);
124
+ if (needsFileLines && codexApp?.read) {
125
+ try {
126
+ const opened = await codexApp.read(req.query.pane, sessionId);
127
+ messages = enrichCodexFileDiffs(messages, opened?.thread);
128
+ } catch { /* The rollout remains readable; an unavailable App Server must not fabricate lines. */ }
129
+ }
130
+ if (before == null) {
131
+ const hash = createHash('sha1').update(JSON.stringify(messages)).digest('hex').slice(0, 16);
132
+ if (req.query.since === hash) return res.status(204).end();
133
+ return res.json({ messages, hash, session: sessionId, hasMore, firstSeq });
134
+ }
135
+ return res.json({ messages, session: sessionId, hasMore, firstSeq });
136
+ } catch (error) {
137
+ return res.json({ ...empty, unavailable: 'app-server-unavailable', detail: error?.message || String(error) });
138
+ }
139
+ }
140
+
141
+ // Claude binds pane→session via Hook transcript_path, with its established cwd fallback.
86
142
  let file = null;
87
143
  let sessionId = null;
88
144
  const hooked = claudeEvents && claudeEvents.paneSession ? claudeEvents.paneSession(req.query.pane) : null;
89
- if (hooked && hooked.transcriptPath) {
145
+ if (hooked?.transcriptPath) {
90
146
  file = hooked.transcriptPath;
91
147
  sessionId = hooked.sessionId || path.basename(file).replace(/\.jsonl$/, '');
92
148
  }
149
+ const empty = { messages: [], hash: '', session: sessionId || null, hasMore: false, firstSeq: null };
93
150
  if (!file) {
94
151
  const cwd = await commands.paneCurrentPath(req.query.pane);
95
152
  const dir = projectsDir();
@@ -99,9 +156,8 @@ export function transcriptRoutes({ commands, claudeEvents, reader = transcriptRe
99
156
  sessionId = resolved.sessionId;
100
157
  }
101
158
  }
102
- const empty = { messages: [], hash: '', session: sessionId || null, hasMore: false, firstSeq: null };
103
159
  if (!file) return res.json(empty);
104
- const parsed = await reader.read(file);
160
+ const parsed = await reader.read(file, req.chatAgent.transcript.createParser);
105
161
  const { messages, firstSeq, hasMore } = pageTranscript(parsed, before, limit);
106
162
  if (before == null) {
107
163
  const hash = createHash('sha1').update(JSON.stringify(messages)).digest('hex').slice(0, 16);
package/src/server.js CHANGED
@@ -8,7 +8,7 @@ import { createApiRouter } from './httpApi.js';
8
8
  import { loadUploadExts } from './uploadTypes.js';
9
9
  import { createClaudeEvents } from './claudeEvents.js';
10
10
  import { syncHooks } from './cli/claudeHooks.js';
11
- import { syncCodexHooks } from './cli/codexHooks.js';
11
+ import { removeLegacyCodexHooks } from './cli/legacyCodexHooks.js';
12
12
  import { claudeStatePath } from './cli/state.js';
13
13
  import * as commands from './tmux/commands.js';
14
14
  import * as push from './push.js';
@@ -26,6 +26,7 @@ import { createGracefulShutdown, createWorkspaceBackground } from './workspace/c
26
26
  import { createWorkspaceRuntime } from './workspace/runtime.js';
27
27
  import { createBrowserWorkerClient } from './browser/workerClient.js';
28
28
  import { createTerminalStream } from './terminalStream.js';
29
+ import { createCodexAppServer } from './codexAppServer.js';
29
30
 
30
31
  const here = path.dirname(fileURLToPath(import.meta.url));
31
32
 
@@ -52,12 +53,14 @@ const observeEnvironment = createEnvironmentProvider({
52
53
  },
53
54
  });
54
55
  const stateFile = process.env.CLAUDE_STATE_FILE || claudeStatePath(home);
56
+ let codexApp = null;
55
57
  const workspaceBackground = createWorkspaceBackground({
56
58
  store: workspaceStore,
57
59
  tmux: workspaceTmux,
58
60
  observeEnvironment,
59
61
  lock: workspaceLock,
60
62
  stateFile,
63
+ getCodexApp: () => codexApp,
61
64
  });
62
65
  const workspace = createWorkspaceRuntime({
63
66
  store: workspaceStore,
@@ -67,11 +70,19 @@ const workspace = createWorkspaceRuntime({
67
70
  home,
68
71
  });
69
72
 
70
- // The inbox is driven by a JSON state file the Claude hooks maintain (server/hooks/handmux-notify.sh →
71
- // handmux-write.js). We only READ it. start() watches it so idle/permission push fires even when no
72
- // client is polling; getStates reads it fresh on each /states.
73
- const events = createClaudeEvents({ commands, push, file: stateFile, onStateChange: workspace.requestReconcile });
73
+ // Claude states come from Hooks; Codex states come only from App Server. Both feed the same inbox and
74
+ // workspace checkpointer, but no Codex Hook state is accepted as a fallback.
75
+ let events = null;
76
+ codexApp = createCodexAppServer({
77
+ home,
78
+ onStateChange: () => {
79
+ if (!events) return;
80
+ events.getStates().then(() => workspace.requestReconcile()).catch(() => {});
81
+ },
82
+ });
83
+ events = createClaudeEvents({ commands, push, codexApp, file: stateFile, onStateChange: workspace.requestReconcile });
74
84
  events.start();
85
+ codexApp.start();
75
86
  workspace.start().catch(() => {});
76
87
 
77
88
  // Keep an already-opted-in user's Claude hooks in step with this handmux version on restart: newly-added
@@ -84,12 +95,9 @@ try {
84
95
  stateFile,
85
96
  });
86
97
  } catch { /* best effort — hook sync never fails startup */ }
87
- try {
88
- syncCodexHooks(home, {
89
- srcDir: path.resolve(here, '../hooks'),
90
- stateFile,
91
- });
92
- } catch { /* best effort — hook sync never fails startup */ }
98
+ // Versions before App Server support installed a marked Handmux block under ~/.codex. Remove only that
99
+ // exact legacy block and Handmux-owned files; all user Codex configuration and third-party hooks remain.
100
+ try { removeLegacyCodexHooks(home); } catch { /* best effort — migration never fails startup */ }
93
101
 
94
102
  // Static directory preview remains for folders without a web server. Arbitrary sites and local ports
95
103
  // use the built-in browser below; previewDomain may provide its dedicated public origin.
@@ -104,14 +112,13 @@ const handmuxOrigin = (() => {
104
112
  }
105
113
  })();
106
114
  const browserWorker = createBrowserWorkerClient({ appToken: token, previewDomain, handmuxOrigin });
107
-
108
115
  const app = express();
109
116
  // Browser proxy leases stay behind normal Handmux auth. Client-owned direct tabs never enter
110
117
  // server state; proxy operations and all claimed Hammerhead paths use the isolated worker.
111
118
  app.use(browserWorker.publicHandler);
112
119
  app.use('/api/browser-proxy', expressAuth(token), express.json(), browserWorker.apiHandler);
113
120
  app.use('/api', createApiRouter({
114
- token, events, uploadExts, previews, shortcuts: cfg.shortcuts, workspace, previewDomain,
121
+ token, events, uploadExts, previews, shortcuts: cfg.shortcuts, workspace, previewDomain, codexApp,
115
122
  }));
116
123
  app.use('/preview', preview.router);
117
124
  app.use(preview.refererFallback);
@@ -172,6 +179,7 @@ server.on('upgrade', (req, socket, head) => {
172
179
  const shutdown = createGracefulShutdown({ events, workspace, browser: browserWorker, server });
173
180
  const handleSignal = () => {
174
181
  terminalStream.close();
182
+ codexApp.close();
175
183
  shutdown().catch(() => {});
176
184
  };
177
185
  process.on('SIGINT', handleSignal);
@@ -53,9 +53,9 @@ export async function listWindows(sessionId) {
53
53
  }
54
54
 
55
55
  export async function listPanes(windowId) {
56
- const out = await runTmux(['list-panes', '-t', windowId, '-F', tmuxFormat(['pane_id', 'pane_active', 'pane_width', 'pane_height', 'pane_current_command', 'pane_current_path', 'pane_left', 'pane_top'])]);
57
- return parseTmuxRows(out, 8, 'pane').map(([id, active, width, height, command, cwd, left, top]) => {
58
- return { id, active: active === '1', width: Number(width), height: Number(height), command, cwd, left: Number(left), top: Number(top) };
56
+ const out = await runTmux(['list-panes', '-t', windowId, '-F', tmuxFormat(['pane_id', 'pane_active', 'pane_width', 'pane_height', 'pane_current_command', 'pane_current_path', 'pane_left', 'pane_top', 'pane_tty'])]);
57
+ return parseTmuxRows(out, 9, 'pane').map(([id, active, width, height, command, cwd, left, top, tty]) => {
58
+ return { id, active: active === '1', width: Number(width), height: Number(height), command, cwd, left: Number(left), top: Number(top), tty };
59
59
  });
60
60
  }
61
61
 
@@ -250,6 +250,14 @@ async function runStartupCmd(target, cmd) {
250
250
  await sendEnter(target);
251
251
  }
252
252
 
253
+ // Type a validated single-line command into an existing pane after its foreground TUI has returned to
254
+ // the shell. This does not kill or replace anything; callers must first prove the previous process exited.
255
+ export async function runPaneCommand(paneId, cmd) {
256
+ if (!isPaneId(paneId)) throw new Error(`invalid pane id: ${JSON.stringify(paneId)}`);
257
+ if (!isValidStartupCmd(cmd)) throw new Error('invalid startup command');
258
+ await runStartupCmd(paneId, cmd);
259
+ }
260
+
253
261
  // Read a pane's working directory, so a new window can open in the dir you're working in.
254
262
  export async function paneCurrentPath(paneId) {
255
263
  const out = await runTmux(['display-message', '-p', '-t', paneId, '#{pane_current_path}']);
@@ -47,13 +47,13 @@ export function createTranscriptReader({ maxEntries = 8, yieldEvery = 500 } = {}
47
47
  }
48
48
  }
49
49
 
50
- async function load(file) {
50
+ async function load(file, createParser) {
51
51
  let st;
52
52
  try { st = await fsp.stat(file); } catch { cache.delete(file); return []; }
53
53
  if (!st.isFile()) { cache.delete(file); return []; }
54
54
 
55
55
  let entry = cache.get(file);
56
- const sameFile = entry && entry.dev === st.dev && entry.ino === st.ino;
56
+ const sameFile = entry && entry.createParser === createParser && entry.dev === st.dev && entry.ino === st.ino;
57
57
  if (sameFile && entry.size === st.size && entry.mtimeMs === st.mtimeMs) {
58
58
  entry.usedAt = Date.now();
59
59
  return entry.parser.messages;
@@ -64,7 +64,7 @@ export function createTranscriptReader({ maxEntries = 8, yieldEvery = 500 } = {}
64
64
  // leak across /clear or log rotation.
65
65
  const append = sameFile && st.size > entry.size;
66
66
  if (!append) {
67
- entry = { parser: createTranscriptParser(), offset: 0, dev: st.dev, ino: st.ino, size: 0, mtimeMs: 0, usedAt: 0 };
67
+ entry = { parser: createParser(), createParser, offset: 0, dev: st.dev, ino: st.ino, size: 0, mtimeMs: 0, usedAt: 0 };
68
68
  }
69
69
 
70
70
  const { lines, offset, size } = await readCompleteLines(file, entry.offset);
@@ -88,9 +88,9 @@ export function createTranscriptReader({ maxEntries = 8, yieldEvery = 500 } = {}
88
88
  return entry.parser.messages;
89
89
  }
90
90
 
91
- async function read(file) {
91
+ async function read(file, createParser = createTranscriptParser) {
92
92
  if (inflight.has(file)) return inflight.get(file);
93
- const run = load(file).finally(() => inflight.delete(file));
93
+ const run = load(file, createParser).finally(() => inflight.delete(file));
94
94
  inflight.set(file, run);
95
95
  return run;
96
96
  }
package/src/usage.js CHANGED
@@ -14,7 +14,7 @@ import { pocketHome } from './cli/state.js';
14
14
  const require = createRequire(import.meta.url);
15
15
  const {
16
16
  readLatestUsage, readSnapshot, writeSnapshot,
17
- } = require('../hooks/handmux-codex-usage.cjs');
17
+ } = require('./codexUsageSnapshot.cjs');
18
18
 
19
19
  export function claudeUsagePath(home = homedir()) { return path.join(pocketHome(home), 'claude-usage.json'); }
20
20
  export function claudeContextDir(home = homedir()) { return path.join(pocketHome(home), 'context'); }
@@ -63,8 +63,7 @@ function rolloutFilesByMtime(dir) {
63
63
  return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
64
64
  }
65
65
 
66
- // Full reconciliation fallback. It remains zero-config, but now runs at most once per calibration interval;
67
- // the normal path reads the persistent snapshot written by Codex hooks.
66
+ // Zero-config reconciliation runs at most once per calibration interval and updates a persistent cache.
68
67
  function scanCodexUsage(home) {
69
68
  let latest = null;
70
69
  for (const { file, mtimeMs } of rolloutFilesByMtime(codexSessionsDir(home))) {
@@ -75,8 +74,8 @@ function scanCodexUsage(home) {
75
74
  return latest;
76
75
  }
77
76
 
78
- // Prefer the machine-wide snapshot. A full rollout scan seeds it and calibrates it once per minute for
79
- // users without hooks; hook updates between calibrations are never rolled back by an older scan result.
77
+ // Prefer the machine-wide cache. A bounded rollout scan refreshes it at most once per minute; App Server
78
+ // supplies live account rate limits separately below.
80
79
  export function readCodexUsage(
81
80
  home = homedir(),
82
81
  { now = Date.now(), calibrationMs = 60_000 } = {},
@@ -8,6 +8,7 @@ import { ensurePrivateDir, writeJsonAtomic } from './atomicJson.js';
8
8
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
9
  const PANE_RE = /^%\d+$/;
10
10
  const RUNNER_FILE = fileURLToPath(import.meta.url);
11
+ const HANDMUX_BIN = fileURLToPath(new URL('../../bin/handmux.js', import.meta.url));
11
12
 
12
13
  function errorText(error) {
13
14
  return error instanceof Error ? error.message : String(error);
@@ -55,7 +56,9 @@ export function launchAgentRequest(requestInput, {
55
56
  settleReady({ status: 'failed', error: earlyFailure(request, error, code) });
56
57
  };
57
58
  try {
58
- child = spawn(request.cmd, request.args, { stdio: 'inherit', shell: false });
59
+ const command = request.cmd === 'codex' ? process.execPath : request.cmd;
60
+ const args = request.cmd === 'codex' ? [HANDMUX_BIN, 'codex', ...request.args] : request.args;
61
+ child = spawn(command, args, { stdio: 'inherit', shell: false });
59
62
  } catch (error) {
60
63
  failReady(error);
61
64
  finishCompletion({ code: null, signal: null, error: errorText(error) });
@@ -1,6 +1,7 @@
1
1
  import fsp from 'node:fs/promises';
2
2
  import { constants } from 'node:fs';
3
3
  import { AGENTS } from '../agents/index.js';
4
+ import { resolveCodexRollout, sessionsDir as codexSessionsDir } from '../agents/codex.js';
4
5
  import { canonicalizeSnapshot, WORKSPACE_SCHEMA_VERSION } from './schema.js';
5
6
 
6
7
  function agentMap(agents) {
@@ -20,8 +21,11 @@ export async function readAgentBindings(stateFile, agents = AGENTS, readFile = f
20
21
  const bindings = new Map();
21
22
  for (const [paneRuntimeId, record] of Object.entries(state)) {
22
23
  if (!record || typeof record !== 'object' || Array.isArray(record)) continue;
23
- const id = record.agent === undefined ? 'claude' : record.agent;
24
- if (typeof id !== 'string' || !known.has(id)) continue;
24
+ // This is the Claude Hook state file. Legacy Codex-tagged rows are deliberately ignored; managed
25
+ // Codex identity belongs to App Server and must not be reconstructed from the removed Hook path.
26
+ if (record.agent !== undefined && record.agent !== 'claude') continue;
27
+ const id = 'claude';
28
+ if (!known.has(id)) continue;
25
29
  const payload = record.payload;
26
30
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) continue;
27
31
  const sessionId = payload.session_id;
@@ -37,6 +41,47 @@ export async function readAgentBindings(stateFile, agents = AGENTS, readFile = f
37
41
  return bindings;
38
42
  }
39
43
 
44
+ function topologyPanes(topology) {
45
+ return topology.windows.flatMap((window) => window.panes);
46
+ }
47
+
48
+ export async function readManagedCodexBindings(topology, {
49
+ codexApp,
50
+ codexSessions = codexSessionsDir(),
51
+ agents = AGENTS,
52
+ findRollout = resolveCodexRollout,
53
+ access = fsp.access,
54
+ stat = fsp.stat,
55
+ } = {}) {
56
+ if (typeof codexApp?.discover !== 'function') return new Map();
57
+ const panes = topologyPanes(topology);
58
+ const discovered = new Map(await Promise.all(panes.map(async (pane) => {
59
+ try {
60
+ const state = await codexApp.discover(pane.runtimeId);
61
+ return [pane.runtimeId, state?.managed ? state.threadId : null];
62
+ } catch {
63
+ return [pane.runtimeId, null];
64
+ }
65
+ })));
66
+
67
+ const driver = agents.find((agent) => agent.id === 'codex');
68
+ if (!driver) return new Map();
69
+ const bindings = new Map();
70
+ for (const pane of panes) {
71
+ const sessionId = discovered.get(pane.runtimeId);
72
+ if (!driver.sessions?.isId(sessionId)) continue;
73
+ let transcriptPath;
74
+ try {
75
+ transcriptPath = await findRollout(codexSessions, sessionId);
76
+ if (typeof transcriptPath !== 'string' || !transcriptPath) continue;
77
+ await access(transcriptPath, constants.R_OK);
78
+ if (!(await stat(transcriptPath)).isFile()) continue;
79
+ } catch { continue; }
80
+ bindings.set(pane.runtimeId, { id: 'codex', sessionId, transcriptPath });
81
+ }
82
+ return bindings;
83
+ }
84
+
40
85
  export function attachBindings(topology, bindings) {
41
86
  return {
42
87
  ...topology,
@@ -59,6 +104,9 @@ export async function captureWorkspace({
59
104
  readFile = fsp.readFile,
60
105
  access = fsp.access,
61
106
  stat = fsp.stat,
107
+ codexApp,
108
+ codexSessions = codexSessionsDir(),
109
+ findCodexRollout = resolveCodexRollout,
62
110
  now = Date.now,
63
111
  }) {
64
112
  try {
@@ -67,6 +115,10 @@ export async function captureWorkspace({
67
115
  const topology = await tmux.captureTopology();
68
116
  if (!topology || topology.status === 'unknown') return { status: 'unknown' };
69
117
  const bindings = await readAgentBindings(stateFile, agents, readFile, access, stat);
118
+ const managedCodex = await readManagedCodexBindings(topology, {
119
+ codexApp, codexSessions, agents, findRollout: findCodexRollout, access, stat,
120
+ });
121
+ for (const [pane, binding] of managedCodex) bindings.set(pane, binding);
70
122
  const after = await tmux.topologyFingerprint();
71
123
  if (isUnknownFingerprint(after)) return { status: 'unknown' };
72
124
  if (before !== after) return { status: 'changed-during-capture' };
@@ -152,13 +152,17 @@ export function createCheckpointer({
152
152
  };
153
153
  }
154
154
 
155
- export function createWorkspaceBackground({ store, tmux, observeEnvironment, lock, stateFile, now = Date.now } = {}) {
155
+ export function createWorkspaceBackground({
156
+ store, tmux, observeEnvironment, lock, stateFile, getCodexApp = () => null, codexSessions, now = Date.now,
157
+ } = {}) {
156
158
  return createCheckpointer({
157
159
  store,
158
160
  observeEnvironment,
159
161
  lock,
160
162
  now,
161
- capture: (environment) => captureWorkspace({ tmux, stateFile, environment, now }),
163
+ capture: (environment) => captureWorkspace({
164
+ tmux, stateFile, environment, codexApp: getCodexApp(), codexSessions, now,
165
+ }),
162
166
  });
163
167
  }
164
168