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.
@@ -0,0 +1,473 @@
1
+ import express from 'express';
2
+ import { isPaneId } from '../tmux/commands.js';
3
+ import { getAgent } from '../agents/index.js';
4
+ import { codexExitSessionId } from '../agents/codex.js';
5
+
6
+ const TAKEOVER_TERMINAL_HINT_MS = 10_000;
7
+ const TAKEOVER_TIMEOUT_MS = 30_000;
8
+ const INPUT_SETTLE_MS = 100;
9
+ const EXIT_POLL_MS = 500;
10
+ const EXIT_ATTEMPTS = 10;
11
+ const RECOVERY_OUTPUT_ATTEMPTS = 20;
12
+ const CLEAR_SWITCH_ATTEMPTS = 60;
13
+ const STREAM_BATCH_MS = 60;
14
+ const STREAM_HEARTBEAT_MS = 15_000;
15
+ const PERMISSION_MODES = {
16
+ default: {
17
+ approvalPolicy: 'on-request', approvalsReviewer: 'user',
18
+ sandboxPolicy: { type: 'workspaceWrite' },
19
+ },
20
+ 'auto-review': {
21
+ approvalPolicy: 'on-request', approvalsReviewer: 'auto_review',
22
+ sandboxPolicy: { type: 'workspaceWrite' },
23
+ },
24
+ 'full-access': {
25
+ approvalPolicy: 'never', approvalsReviewer: 'user',
26
+ sandboxPolicy: { type: 'dangerFullAccess' },
27
+ },
28
+ };
29
+
30
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
31
+
32
+ async function paneAgent(commands, claudeEvents, pane) {
33
+ const panes = await commands?.listLivePanes?.();
34
+ const live = Array.isArray(panes) ? panes.find((candidate) => candidate.id === pane) : null;
35
+ if (!live) return { live: null, agent: null };
36
+ const agents = await claudeEvents?.identifyPaneAgents?.(panes) || {};
37
+ return { live, agent: agents[pane] || null };
38
+ }
39
+
40
+ async function exitCurrentCodex(commands, claudeEvents, pane) {
41
+ // During an active turn Codex can spend several seconds writing interruption details, token usage and
42
+ // its recovery command. Give each Ctrl+C a full five-second window; only send a second one if the pane
43
+ // still proves Codex is the foreground process after that wait.
44
+ for (let press = 0; press < 2; press += 1) {
45
+ await commands.sendKey(pane, 'C-c');
46
+ for (let attempt = 0; attempt < EXIT_ATTEMPTS; attempt += 1) {
47
+ await pause(EXIT_POLL_MS);
48
+ const current = await paneAgent(commands, claudeEvents, pane);
49
+ if (!current.live || current.agent !== 'codex') return current.live;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ async function waitForRecoverySession(commands, pane, isId) {
56
+ // tmux can report pane_current_command=the shell before the last buffered Codex output has been painted.
57
+ // Poll the visible pane briefly instead of treating the first incomplete frame as a permanent failure.
58
+ for (let attempt = 0; attempt < RECOVERY_OUTPUT_ATTEMPTS; attempt += 1) {
59
+ try {
60
+ const sessionId = codexExitSessionId(await commands.capturePlain(pane));
61
+ if (isId(sessionId)) return sessionId;
62
+ } catch { return null; } // a direct-command pane may have closed with Codex
63
+ await pause(INPUT_SETTLE_MS);
64
+ }
65
+ return null;
66
+ }
67
+
68
+ async function clearCurrentCodexThroughTui(codexApp, commands, pane, previousThreadId, wait) {
69
+ if (typeof commands?.exitCopyModeIfActive !== 'function'
70
+ || typeof commands?.sendKey !== 'function'
71
+ || typeof commands?.sendText !== 'function'
72
+ || typeof commands?.sendEnter !== 'function') {
73
+ throw new Error('Codex terminal control is unavailable');
74
+ }
75
+ // /clear is not just thread/start: the remote TUI also replaces its active ChatWidget and redraws the
76
+ // terminal. Drive that native action so terminal and chat remain one session. C-u makes the command exact
77
+ // even if the hidden terminal composer retained a draft; if a modal owns input, confirmation below times
78
+ // out instead of letting Handmux split onto a different thread.
79
+ await commands.exitCopyModeIfActive(pane);
80
+ await commands.sendKey(pane, 'C-u');
81
+ await wait(INPUT_SETTLE_MS);
82
+ await commands.sendText(pane, '/clear');
83
+ await wait(INPUT_SETTLE_MS);
84
+ await commands.sendEnter(pane);
85
+
86
+ for (let attempt = 0; attempt < CLEAR_SWITCH_ATTEMPTS; attempt += 1) {
87
+ const discovered = await codexApp.discover(pane);
88
+ if (!discovered?.managed) throw new Error('Codex session is no longer managed by Handmux');
89
+ if (discovered.threadId && discovered.threadId !== previousThreadId) {
90
+ return { threadId: discovered.threadId };
91
+ }
92
+ await wait(INPUT_SETTLE_MS);
93
+ }
94
+ throw new Error('Codex terminal did not accept /clear; switch to the terminal, close any open panel, and try again');
95
+ }
96
+
97
+ async function binding(codexApp, pane) {
98
+ if (!isPaneId(pane)) return { error: 'bad pane id', status: 400 };
99
+ try {
100
+ const discovered = await codexApp?.discover?.(pane);
101
+ if (!discovered?.managed) return { error: 'Codex session is not managed by Handmux', status: 409 };
102
+ if (discovered.threadId) return { pane, threadId: discovered.threadId };
103
+ } catch (error) {
104
+ return { error: error?.message || String(error), status: 503 };
105
+ }
106
+ return { error: 'Codex session is not bound yet', status: 409 };
107
+ }
108
+
109
+ function routeError(res, result) {
110
+ if (!result.error) return false;
111
+ res.status(result.status).json({ error: result.error });
112
+ return true;
113
+ }
114
+
115
+ function codexError(res, error) {
116
+ const message = error?.message || String(error);
117
+ const conflict = /not managed|session changed|no longer pending|already being sent|being edited|edit is no longer active|queue is full|decision is unavailable|bad user input response/i.test(message);
118
+ return res.status(conflict ? 409 : 503).json({ error: message });
119
+ }
120
+
121
+ // App Server can emit many tiny token deltas. Keep their order, but fold adjacent chunks for the same
122
+ // message before crossing the tunnel to a phone; lifecycle events always flush immediately.
123
+ export function appendCodexStreamEvent(queue, event) {
124
+ const last = queue.at(-1);
125
+ if (event?.type === 'delta' && last?.type === 'delta'
126
+ && last.threadId === event.threadId && last.turnId === event.turnId && last.itemId === event.itemId) {
127
+ last.delta += event.delta || '';
128
+ } else if (event) queue.push({ ...event });
129
+ return queue;
130
+ }
131
+
132
+ export function codexRoutes({ codexApp, commands, claudeEvents, wait = pause }) {
133
+ const r = express.Router();
134
+ if (!codexApp) return r;
135
+ // A takeover is pane-scoped and deliberately kept in server memory. Besides making repeated taps
136
+ // idempotent, this gates discovery while the replacement process starts: App Server may briefly list
137
+ // another historical thread, but the UI must not enter chat until the exact pre-takeover thread appears.
138
+ const takeovers = new Map(); // pane -> { threadId, startedAt }
139
+ const takeoverView = (takeover) => {
140
+ const elapsed = Date.now() - takeover.startedAt;
141
+ return {
142
+ state: elapsed >= TAKEOVER_TIMEOUT_MS ? 'timed-out' : 'starting',
143
+ needsTerminal: elapsed >= TAKEOVER_TERMINAL_HINT_MS,
144
+ };
145
+ };
146
+
147
+ r.get('/codex/session', async (req, res) => {
148
+ const pane = req.query.pane;
149
+ if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
150
+ const takeover = takeovers.get(pane);
151
+ try {
152
+ const discovered = await codexApp.discover(pane);
153
+ if (takeover && (!takeover.threadId || discovered?.threadId !== takeover.threadId)) {
154
+ return res.json({ managed: false, threadId: null, takeover: takeoverView(takeover) });
155
+ }
156
+ if (takeover) takeovers.delete(pane);
157
+ if (!discovered?.managed || !discovered.threadId) {
158
+ return res.json({ managed: !!discovered?.managed, threadId: discovered?.threadId || null });
159
+ }
160
+ return res.json(await codexApp.status(pane, discovered.threadId));
161
+ } catch (error) {
162
+ // A killed plain Codex and its replacement App Server have a normal short disconnect window. Keep
163
+ // the takeover page stable through it instead of flashing a generic connection failure.
164
+ if (takeover) return res.json({ managed: false, threadId: null, takeover: takeoverView(takeover) });
165
+ return codexError(res, error);
166
+ }
167
+ });
168
+
169
+ r.get('/codex/stream', async (req, res) => {
170
+ const target = await binding(codexApp, req.query.pane);
171
+ if (routeError(res, target)) return;
172
+
173
+ res.status(200);
174
+ res.set({
175
+ 'Content-Type': 'text/event-stream; charset=utf-8',
176
+ 'Cache-Control': 'no-cache, no-transform',
177
+ Connection: 'keep-alive',
178
+ 'X-Accel-Buffering': 'no',
179
+ });
180
+ res.flushHeaders?.();
181
+ res.write(`data: ${JSON.stringify({ type: 'ready', threadId: target.threadId })}\n\n`);
182
+
183
+ let closed = false;
184
+ let unsubscribe = null;
185
+ let flushTimer = null;
186
+ let heartbeat = null;
187
+ const pending = [];
188
+ const flush = () => {
189
+ if (flushTimer) clearTimeout(flushTimer);
190
+ flushTimer = null;
191
+ if (closed || !pending.length) return;
192
+ const events = pending.splice(0);
193
+ res.write(`data: ${JSON.stringify({ type: 'events', events })}\n\n`);
194
+ res.flush?.();
195
+ };
196
+ const cleanup = () => {
197
+ if (closed) return;
198
+ closed = true;
199
+ if (flushTimer) clearTimeout(flushTimer);
200
+ if (heartbeat) clearInterval(heartbeat);
201
+ flushTimer = null;
202
+ heartbeat = null;
203
+ unsubscribe?.();
204
+ unsubscribe = null;
205
+ };
206
+ const enqueue = (event) => {
207
+ if (closed) return;
208
+ if (event?.type === 'disconnected') {
209
+ appendCodexStreamEvent(pending, event);
210
+ flush();
211
+ cleanup();
212
+ res.end();
213
+ return;
214
+ }
215
+ appendCodexStreamEvent(pending, event);
216
+ if (event?.type === 'completed' || event?.type === 'turnCompleted') flush();
217
+ else if (!flushTimer) {
218
+ flushTimer = setTimeout(flush, STREAM_BATCH_MS);
219
+ flushTimer.unref?.();
220
+ }
221
+ };
222
+
223
+ req.once('aborted', cleanup);
224
+ res.once('close', cleanup);
225
+ try {
226
+ unsubscribe = await codexApp.subscribe(target.pane, target.threadId, enqueue);
227
+ if (closed) { unsubscribe(); unsubscribe = null; return; }
228
+ heartbeat = setInterval(() => {
229
+ if (!closed) res.write(`: keepalive ${Date.now()}\n\n`);
230
+ }, STREAM_HEARTBEAT_MS);
231
+ heartbeat.unref?.();
232
+ } catch (error) {
233
+ if (!closed) {
234
+ res.write(`data: ${JSON.stringify({ type: 'error', message: error?.message || String(error) })}\n\n`);
235
+ res.end();
236
+ }
237
+ cleanup();
238
+ }
239
+ });
240
+
241
+ r.post('/codex/takeover', async (req, res) => {
242
+ const pane = req.body?.pane;
243
+ if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
244
+ const pending = takeovers.get(pane);
245
+ if (pending) return res.json({ started: true, takeover: takeoverView(pending) });
246
+ try {
247
+ const discovered = await codexApp.discover(pane);
248
+ if (discovered?.managed && discovered.threadId) {
249
+ return res.json({ started: false, managed: true, threadId: discovered.threadId });
250
+ }
251
+ if (discovered?.managed) return res.status(409).json({ error: 'codex-session-starting' });
252
+
253
+ // Re-check live process identity immediately before interacting with the pane. A stale client must
254
+ // never send slash commands to a pane whose Codex has already exited or changed.
255
+ const current = await paneAgent(commands, claudeEvents, pane);
256
+ if (!current.live) return res.status(404).json({ error: 'codex-pane-gone' });
257
+ if (current.agent !== 'codex') return res.status(409).json({ error: 'codex-pane-changed' });
258
+
259
+ const driver = getAgent('codex');
260
+ const takeover = { threadId: null, startedAt: Date.now() };
261
+ takeovers.set(pane, takeover);
262
+ try {
263
+ const exited = await exitCurrentCodex(commands, claudeEvents, pane);
264
+ if (!exited) {
265
+ takeovers.delete(pane);
266
+ return res.status(409).json({ error: 'codex-exit-blocked' });
267
+ }
268
+ const sessionId = await waitForRecoverySession(commands, pane, driver.sessions.isId);
269
+ if (!sessionId) {
270
+ takeovers.delete(pane);
271
+ return res.status(409).json({ error: 'codex-session-unconfirmed' });
272
+ }
273
+ takeover.threadId = sessionId;
274
+ await pause(INPUT_SETTLE_MS); // let the restored shell finish drawing its prompt
275
+ await commands.runPaneCommand(pane, driver.sessions.managedResumeCmd(sessionId));
276
+ } catch (error) {
277
+ takeovers.delete(pane);
278
+ throw error;
279
+ }
280
+ return res.json({ started: true, takeover: takeoverView(takeover) });
281
+ } catch (error) { return codexError(res, error); }
282
+ });
283
+
284
+ r.post('/codex/send', async (req, res) => {
285
+ const target = await binding(codexApp, req.body?.pane);
286
+ if (routeError(res, target)) return;
287
+ const text = typeof req.body?.text === 'string' ? req.body.text.trim() : '';
288
+ if (!text) return res.status(400).json({ error: 'message is empty' });
289
+ const requestId = typeof req.body?.requestId === 'string' ? req.body.requestId.trim() : '';
290
+ if (requestId && (requestId.length > 128 || !/^[a-zA-Z0-9._:-]+$/.test(requestId))) {
291
+ return res.status(400).json({ error: 'bad Codex request id' });
292
+ }
293
+ try { res.json(await codexApp.send(target.pane, target.threadId, text, requestId || null)); }
294
+ catch (error) { codexError(res, error); }
295
+ });
296
+
297
+ for (const [path, method] of [['/codex/queue/steer', 'steerQueued'], ['/codex/queue/remove', 'removeQueued']]) {
298
+ r.post(path, async (req, res) => {
299
+ const target = await binding(codexApp, req.body?.pane);
300
+ if (routeError(res, target)) return;
301
+ const id = typeof req.body?.id === 'string' ? req.body.id.trim() : '';
302
+ if (!id || id.length > 128) return res.status(400).json({ error: 'bad queued message id' });
303
+ try { res.json(await codexApp[method](target.pane, target.threadId, id)); }
304
+ catch (error) { codexError(res, error); }
305
+ });
306
+ }
307
+
308
+ r.post('/codex/queue/edit/begin', async (req, res) => {
309
+ const target = await binding(codexApp, req.body?.pane);
310
+ if (routeError(res, target)) return;
311
+ const id = typeof req.body?.id === 'string' ? req.body.id.trim() : '';
312
+ if (!id || id.length > 128) return res.status(400).json({ error: 'bad queued message id' });
313
+ try { res.json(await codexApp.beginQueuedEdit(target.pane, target.threadId, id)); }
314
+ catch (error) { codexError(res, error); }
315
+ });
316
+
317
+ for (const [action, method] of [
318
+ ['renew', 'renewQueuedEdit'], ['commit', 'commitQueuedEdit'], ['cancel', 'cancelQueuedEdit'],
319
+ ]) {
320
+ r.post(`/codex/queue/edit/${action}`, async (req, res) => {
321
+ const target = await binding(codexApp, req.body?.pane);
322
+ if (routeError(res, target)) return;
323
+ const id = typeof req.body?.id === 'string' ? req.body.id.trim() : '';
324
+ const token = typeof req.body?.token === 'string' ? req.body.token.trim() : '';
325
+ if (!id || id.length > 128) return res.status(400).json({ error: 'bad queued message id' });
326
+ if (!token || token.length > 128) return res.status(400).json({ error: 'bad queued message edit token' });
327
+ if (action === 'commit') {
328
+ const text = typeof req.body?.text === 'string' ? req.body.text.trim() : '';
329
+ if (!text) return res.status(400).json({ error: 'message is empty' });
330
+ try { return res.json(await codexApp[method](target.pane, target.threadId, id, token, text)); }
331
+ catch (error) { return codexError(res, error); }
332
+ }
333
+ try { return res.json(await codexApp[method](target.pane, target.threadId, id, token)); }
334
+ catch (error) { return codexError(res, error); }
335
+ });
336
+ }
337
+
338
+ r.post('/codex/compact', async (req, res) => {
339
+ const target = await binding(codexApp, req.body?.pane);
340
+ if (routeError(res, target)) return;
341
+ try { res.json(await codexApp.compact(target.pane, target.threadId)); }
342
+ catch (error) { codexError(res, error); }
343
+ });
344
+
345
+ r.post('/codex/clear', async (req, res) => {
346
+ const target = await binding(codexApp, req.body?.pane);
347
+ if (routeError(res, target)) return;
348
+ try {
349
+ res.json(await clearCurrentCodexThroughTui(
350
+ codexApp, commands, target.pane, target.threadId, wait,
351
+ ));
352
+ }
353
+ catch (error) { codexError(res, error); }
354
+ });
355
+
356
+ r.get('/codex/models', async (req, res) => {
357
+ const target = await binding(codexApp, req.query.pane);
358
+ if (routeError(res, target)) return;
359
+ try { res.json({ models: await codexApp.models(target.pane, target.threadId) }); }
360
+ catch (error) { codexError(res, error); }
361
+ });
362
+
363
+ r.get('/codex/goal', async (req, res) => {
364
+ const target = await binding(codexApp, req.query.pane);
365
+ if (routeError(res, target)) return;
366
+ try { res.json({ goal: await codexApp.getGoal(target.pane, target.threadId) }); }
367
+ catch (error) { codexError(res, error); }
368
+ });
369
+
370
+ r.post('/codex/goal', async (req, res) => {
371
+ const target = await binding(codexApp, req.body?.pane);
372
+ if (routeError(res, target)) return;
373
+ const updates = {};
374
+ if (Object.hasOwn(req.body || {}, 'objective')) {
375
+ const objective = req.body.objective;
376
+ const trimmed = typeof objective === 'string' ? objective.trim() : '';
377
+ if (!trimmed || trimmed.length > 4_000) {
378
+ return res.status(400).json({ error: 'bad goal objective' });
379
+ }
380
+ updates.objective = trimmed;
381
+ }
382
+ if (Object.hasOwn(req.body || {}, 'status')) {
383
+ const status = req.body.status;
384
+ if (!['active', 'paused'].includes(status)) return res.status(400).json({ error: 'bad goal status' });
385
+ updates.status = status;
386
+ }
387
+ if (!Object.keys(updates).length) return res.status(400).json({ error: 'no goal update supplied' });
388
+ try { res.json({ goal: await codexApp.updateGoal(target.pane, target.threadId, updates) }); }
389
+ catch (error) { codexError(res, error); }
390
+ });
391
+
392
+ r.post('/codex/goal/clear', async (req, res) => {
393
+ const target = await binding(codexApp, req.body?.pane);
394
+ if (routeError(res, target)) return;
395
+ try { res.json(await codexApp.clearGoal(target.pane, target.threadId)); }
396
+ catch (error) { codexError(res, error); }
397
+ });
398
+
399
+ r.post('/codex/settings', async (req, res) => {
400
+ const target = await binding(codexApp, req.body?.pane);
401
+ if (routeError(res, target)) return;
402
+ const updates = {};
403
+ for (const key of ['model', 'effort']) {
404
+ const value = req.body?.[key];
405
+ if (value == null) continue;
406
+ if (typeof value !== 'string' || !value.trim() || value.length > 128) {
407
+ return res.status(400).json({ error: `bad ${key}` });
408
+ }
409
+ updates[key] = value.trim();
410
+ }
411
+ if (Object.hasOwn(req.body || {}, 'serviceTier')) {
412
+ const value = req.body.serviceTier;
413
+ if (value !== null && (typeof value !== 'string' || !value.trim() || value.length > 128)) {
414
+ return res.status(400).json({ error: 'bad serviceTier' });
415
+ }
416
+ updates.serviceTier = value === null ? null : value.trim();
417
+ }
418
+ const approvalPolicy = req.body?.approvalPolicy;
419
+ if (approvalPolicy != null) {
420
+ if (!['untrusted', 'on-request', 'never'].includes(approvalPolicy)) {
421
+ return res.status(400).json({ error: 'bad approvalPolicy' });
422
+ }
423
+ updates.approvalPolicy = approvalPolicy;
424
+ }
425
+ const permissionMode = req.body?.permissionMode;
426
+ if (permissionMode != null) {
427
+ if (typeof permissionMode !== 'string' || !PERMISSION_MODES[permissionMode]) {
428
+ return res.status(400).json({ error: 'bad permissionMode' });
429
+ }
430
+ Object.assign(updates, PERMISSION_MODES[permissionMode]);
431
+ }
432
+ if (!Object.keys(updates).length) return res.status(400).json({ error: 'no settings supplied' });
433
+ try { res.json({ settings: await codexApp.updateSettings(target.pane, target.threadId, updates) }); }
434
+ catch (error) { codexError(res, error); }
435
+ });
436
+
437
+ r.post('/codex/interrupt', async (req, res) => {
438
+ const target = await binding(codexApp, req.body?.pane);
439
+ if (routeError(res, target)) return;
440
+ try { res.json(await codexApp.interrupt(target.pane, target.threadId)); }
441
+ catch (error) { codexError(res, error); }
442
+ });
443
+
444
+ r.post('/codex/approval', async (req, res) => {
445
+ const target = await binding(codexApp, req.body?.pane);
446
+ if (routeError(res, target)) return;
447
+ const requestId = req.body?.requestId;
448
+ const decision = req.body?.decision;
449
+ if ((typeof requestId !== 'string' && typeof requestId !== 'number') || typeof decision !== 'string') {
450
+ return res.status(400).json({ error: 'bad approval response' });
451
+ }
452
+ try { res.json(await codexApp.decide(target.pane, target.threadId, requestId, decision)); }
453
+ catch (error) { codexError(res, error); }
454
+ });
455
+
456
+ r.post('/codex/input', async (req, res) => {
457
+ const target = await binding(codexApp, req.body?.pane);
458
+ if (routeError(res, target)) return;
459
+ const requestId = req.body?.requestId;
460
+ const answers = req.body?.answers;
461
+ const entries = answers && typeof answers === 'object' && !Array.isArray(answers)
462
+ ? Object.entries(answers) : [];
463
+ if ((typeof requestId !== 'string' && typeof requestId !== 'number') || !entries.length
464
+ || entries.some(([questionId, value]) => !questionId || !Array.isArray(value) || !value.length
465
+ || value.some((answer) => typeof answer !== 'string' || !answer.trim()))) {
466
+ return res.status(400).json({ error: 'bad user input response' });
467
+ }
468
+ try { res.json(await codexApp.answerInput(target.pane, target.threadId, requestId, answers)); }
469
+ catch (error) { codexError(res, error); }
470
+ });
471
+
472
+ return r;
473
+ }
@@ -3,7 +3,7 @@
3
3
  import express from 'express';
4
4
  import { isPaneId, isWindowId, isSessionId, isValidSessionName, isValidStartupCmd } from '../tmux/commands.js';
5
5
 
6
- export function sessionRoutes({ commands, docs, workspace }) {
6
+ export function sessionRoutes({ commands, docs, workspace, claudeEvents }) {
7
7
  const r = express.Router();
8
8
 
9
9
  function notify(method) {
@@ -121,7 +121,12 @@ export function sessionRoutes({ commands, docs, workspace }) {
121
121
 
122
122
  r.get('/panes', async (req, res, next) => {
123
123
  if (!isWindowId(req.query.window)) return res.status(400).json({ error: 'bad window id' });
124
- try { res.json(await commands.listPanes(req.query.window)); } catch (e) { next(e); }
124
+ try {
125
+ const panes = await commands.listPanes(req.query.window);
126
+ let agents = {};
127
+ try { agents = await claudeEvents?.identifyPaneAgents?.(panes) || {}; } catch { /* identity is additive */ }
128
+ res.json(panes.map(({ tty: _tty, ...pane }) => ({ ...pane, agent: agents[pane.id] || null })));
129
+ } catch (e) { next(e); }
125
130
  });
126
131
 
127
132
  // A pane's current working directory — the file browser uses it to land on (and "jump to") the
@@ -9,7 +9,6 @@ import { isSessionId } from '../tmux/commands.js';
9
9
  import { buildIatSignedUrl } from '../asr/iflySign.js';
10
10
  import { asrConfig, isAsrConfigured } from '../asr/iflyConfig.js';
11
11
  import { hooksStatus, installHooks } from '../cli/claudeHooks.js';
12
- import { codexHooksStatus, installCodexHooks } from '../cli/codexHooks.js';
13
12
  import { scanOrphans, takeoverOrphan, defaultProjectsDir } from '../orphans.js';
14
13
  import { getUsageCached } from '../usage.js';
15
14
  import { readCache, isNewer, shouldRefresh, refreshLatestAsync } from '../cli/updateCheck.js';
@@ -25,17 +24,6 @@ const PKG_VERSION = (() => {
25
24
  catch { return null; }
26
25
  })();
27
26
 
28
- // Summarize inbox-hook state across every coding agent for the phone: 'installed' if any agent is wired,
29
- // 'absent' if an agent is present but none wired (→ offer the one-tap enable), 'no-claude' if there's no
30
- // agent at all (→ hide the prompt).
31
- export function combinedHooksStatus(home) {
32
- const c = hooksStatus(home); // 'no-claude' | 'installed' | 'absent'
33
- const x = codexHooksStatus(home); // 'no-codex' | 'installed' | 'absent'
34
- if (c === 'installed' || x === 'installed') return 'installed';
35
- if (c !== 'no-claude' || x !== 'no-codex') return 'absent';
36
- return 'no-claude';
37
- }
38
-
39
27
  export function systemRoutes({ commands, claudeEvents, asrEnv, shortcuts, home, stateFile, previewDomain }) {
40
28
  const r = express.Router();
41
29
  let activeShortcuts = normalizeShortcuts(shortcuts);
@@ -44,12 +32,13 @@ export function systemRoutes({ commands, claudeEvents, asrEnv, shortcuts, home,
44
32
  // Optional integrations are configured per-install (open-source installs ship without keys), so the
45
33
  // client asks what's actually available and hides controls that can't work — e.g. the mic when no
46
34
  // ASR engine is configured. Add more flags here as optional integrations land.
47
- // `claudeHooks` (name kept for web back-compat) now summarizes EVERY coding agent: 'installed' if any is
48
- // wired, 'absent' if an agent is present but none wired (→ offer enable), 'no-claude' if no agent at all.
35
+ // `claudeHooks` is intentionally Claude-only. Codex is always App Server-backed and never affects
36
+ // this setup prompt or endpoint.
49
37
  r.get('/config', (req, res) => {
50
38
  res.json({
51
39
  asr: isAsrConfigured(asrEnv),
52
- claudeHooks: combinedHooksStatus(home),
40
+ claudeHooks: hooksStatus(home),
41
+ managedCodex: true,
53
42
  shortcuts: activeShortcuts,
54
43
  browserProxy: !!previewDomain,
55
44
  });
@@ -82,15 +71,13 @@ export function systemRoutes({ commands, claudeEvents, asrEnv, shortcuts, home,
82
71
  res.json({ current: PKG_VERSION, latest, updateAvailable, whatsNew });
83
72
  });
84
73
 
85
- // One-tap enable from the phone: install the hooks for every present agent (Claude Code, Codex) on the
86
- // host (token-gated, like every API here). Opt-in — the inbox only offers this when status is 'absent'.
87
- // Never creates ~/.claude or ~/.codex; a user's own Codex `notify` is left untouched (see codexHooks.js).
74
+ // One-tap enable from the phone installs Claude Code hooks only. Codex uses App Server and must never
75
+ // write to ~/.codex as part of this endpoint.
88
76
  r.post('/hooks/install', (req, res) => {
89
77
  try {
90
- let installed = 0;
91
- if (hooksStatus(home) !== 'no-claude') { installHooks(home, { srcDir: HOOKS_SRC, stateFile }); installed++; }
92
- if (codexHooksStatus(home) !== 'no-codex') { installCodexHooks(home, { srcDir: HOOKS_SRC, stateFile }); installed++; }
93
- res.json({ ok: installed > 0, status: combinedHooksStatus(home) });
78
+ if (hooksStatus(home) === 'no-claude') return res.json({ ok: false, status: 'no-claude' });
79
+ installHooks(home, { srcDir: HOOKS_SRC, stateFile });
80
+ res.json({ ok: true, status: hooksStatus(home) });
94
81
  } catch (e) { res.status(500).json({ ok: false, error: String(e) }); }
95
82
  });
96
83