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
@@ -0,0 +1,1620 @@
1
+ import fs from 'node:fs';
2
+ import net from 'node:net';
3
+ import WebSocket from 'ws';
4
+ import { codexAppSocketPath } from './cli/codexManaged.js';
5
+ import { isCodexSyntheticUserText } from './codexTranscriptParse.js';
6
+
7
+ const RPC_TIMEOUT_MS = 8_000;
8
+ const SOCKET_SCAN_MS = 2_000;
9
+ const MAX_QUEUED_MESSAGES = 20;
10
+ const MAX_SUBMISSION_RECEIPTS = 256;
11
+ const QUEUE_EDIT_LEASE_MS = 30_000;
12
+ const APPROVAL_METHODS = new Set([
13
+ 'item/commandExecution/requestApproval',
14
+ 'item/fileChange/requestApproval',
15
+ 'item/permissions/requestApproval',
16
+ ]);
17
+ const USER_INPUT_METHOD = 'item/tool/requestUserInput';
18
+ const SIMPLE_DECISIONS = new Set(['accept', 'acceptForSession', 'decline', 'cancel']);
19
+
20
+ function structuredDecision(value, index) {
21
+ const execpolicy = value?.acceptWithExecpolicyAmendment?.execpolicy_amendment;
22
+ if (Array.isArray(execpolicy) && execpolicy.length && execpolicy.every((part) => typeof part === 'string')) {
23
+ return { id: `structured:${index}`, type: 'execpolicy', rule: execpolicy };
24
+ }
25
+ const network = value?.applyNetworkPolicyAmendment?.network_policy_amendment;
26
+ if (network && typeof network.host === 'string' && ['allow', 'deny'].includes(network.action)) {
27
+ return { id: `structured:${index}`, type: 'networkPolicy', host: network.host, action: network.action };
28
+ }
29
+ return null;
30
+ }
31
+
32
+ function approvalDecision(value, index) {
33
+ return typeof value === 'string' && SIMPLE_DECISIONS.has(value)
34
+ ? value
35
+ : structuredDecision(value, index);
36
+ }
37
+
38
+ function asError(error) {
39
+ if (error instanceof Error) return error;
40
+ if (error?.message) return new Error(error.message);
41
+ return new Error(typeof error === 'string' ? error : JSON.stringify(error));
42
+ }
43
+
44
+ function inputText(content) {
45
+ if (!Array.isArray(content)) return '';
46
+ return content.map((item) => {
47
+ if (!item || typeof item !== 'object') return '';
48
+ if (item.type === 'text' || item.type === 'inputText') return item.text || '';
49
+ if (item.type === 'localImage') return item.path || '';
50
+ if (item.type === 'image') return item.url || '';
51
+ return '';
52
+ }).filter(Boolean).join('\n');
53
+ }
54
+
55
+ function userPromptFromItem(item) {
56
+ if (item?.type !== 'userMessage') return '';
57
+ const text = inputText(item.content).trim();
58
+ return text && !isCodexSyntheticUserText(text) ? text : '';
59
+ }
60
+
61
+ function diffInfo(change) {
62
+ const lines = String(change?.diff || '').split('\n');
63
+ let added = 0;
64
+ let removed = 0;
65
+ for (const line of lines) {
66
+ if (line.startsWith('+') && !line.startsWith('+++')) added++;
67
+ else if (line.startsWith('-') && !line.startsWith('---')) removed++;
68
+ }
69
+ const kind = change?.kind?.type;
70
+ return {
71
+ added,
72
+ removed,
73
+ hunks: change?.diff ? [{ oldStart: 0, newStart: 0, lines }] : null,
74
+ ...(kind === 'add' ? { created: true } : {}),
75
+ };
76
+ }
77
+
78
+ function jsonText(value) {
79
+ if (value == null) return '';
80
+ if (typeof value === 'string') return value;
81
+ return JSON.stringify(value, null, 2);
82
+ }
83
+
84
+ function collabToolName(tool) {
85
+ return ({
86
+ spawnAgent: 'spawn_agent',
87
+ sendInput: 'send_message',
88
+ resumeAgent: 'followup_task',
89
+ wait: 'wait_agent',
90
+ closeAgent: 'interrupt_agent',
91
+ })[tool] || `collaboration:${tool || 'agent'}`;
92
+ }
93
+
94
+ function toolFromItem(item, fileChange = item.changes?.[0] || {}) {
95
+ if (item.type === 'commandExecution') {
96
+ return {
97
+ name: 'exec_command',
98
+ input: { cmd: item.command, cwd: item.cwd },
99
+ result: item.status === 'inProgress' ? null : (item.aggregatedOutput || ''),
100
+ isError: item.status === 'failed',
101
+ outcome: item.status === 'declined' ? 'declined' : item.status === 'failed' ? 'failed'
102
+ : item.status === 'completed' ? 'success' : 'running',
103
+ };
104
+ }
105
+ if (item.type === 'fileChange') {
106
+ return {
107
+ name: 'apply_patch',
108
+ input: { file_path: fileChange.path || '', patch: fileChange.diff || '' },
109
+ result: item.status === 'inProgress' ? null : (item.status === 'completed' ? '' : item.status),
110
+ isError: item.status === 'failed',
111
+ outcome: item.status === 'declined' ? 'declined' : item.status === 'failed' ? 'failed'
112
+ : item.status === 'completed' ? 'success' : 'running',
113
+ diff: diffInfo(fileChange),
114
+ };
115
+ }
116
+ if (item.type === 'mcpToolCall') {
117
+ return {
118
+ name: `${item.server || 'mcp'}:${item.tool || 'tool'}`,
119
+ input: item.arguments && typeof item.arguments === 'object' ? item.arguments : { value: item.arguments },
120
+ result: item.status === 'inProgress' ? null : (item.error?.message || JSON.stringify(item.result || '', null, 2)),
121
+ isError: item.status === 'failed',
122
+ };
123
+ }
124
+ if (item.type === 'dynamicToolCall') {
125
+ return {
126
+ name: item.tool || 'tool',
127
+ input: item.arguments && typeof item.arguments === 'object' ? item.arguments : { value: item.arguments },
128
+ result: item.status === 'inProgress' ? null : (item.contentItems || []).map((part) => part?.text || part?.imageUrl || '').filter(Boolean).join('\n'),
129
+ isError: item.status === 'failed' || item.success === false,
130
+ outcome: item.status === 'inProgress' ? 'running'
131
+ : item.status === 'failed' || item.success === false ? 'failed'
132
+ : item.success === true ? 'success' : 'completed',
133
+ };
134
+ }
135
+ if (item.type === 'collabAgentToolCall') {
136
+ return {
137
+ name: collabToolName(item.tool),
138
+ input: {
139
+ target: item.receiverThreadIds?.join(', ') || undefined,
140
+ prompt: item.prompt || undefined,
141
+ model: item.model || undefined,
142
+ reasoning_effort: item.reasoningEffort || undefined,
143
+ },
144
+ result: item.status === 'inProgress' ? null : jsonText(item.agentsStates),
145
+ isError: item.status === 'failed',
146
+ };
147
+ }
148
+ if (item.type === 'webSearch') {
149
+ return {
150
+ name: 'web__run',
151
+ input: { query: item.query, action: item.action || undefined },
152
+ result: item.results == null ? null : jsonText(item.results),
153
+ isError: false,
154
+ };
155
+ }
156
+ if (item.type === 'imageView') {
157
+ return { name: 'view_image', input: { path: item.path }, result: '', isError: false };
158
+ }
159
+ if (item.type === 'sleep') {
160
+ return { name: 'wait', input: { duration_ms: item.durationMs }, result: '', isError: false };
161
+ }
162
+ if (item.type === 'imageGeneration') {
163
+ return {
164
+ name: 'image_gen__imagegen',
165
+ input: { prompt: item.revisedPrompt || undefined },
166
+ result: item.status === 'inProgress' ? null : (item.savedPath || item.result || ''),
167
+ isError: item.status === 'failed',
168
+ };
169
+ }
170
+ return null;
171
+ }
172
+
173
+ function projectedMessageId(turn, turnIndex, item, itemIndex) {
174
+ const turnId = turn?.id || `turn-${turnIndex}`;
175
+ // When a completed snapshot canonicalizes a live notification under a different item id, retain the
176
+ // connection-local first id. It is identity metadata only; all content/order still comes from snapshot.
177
+ const itemId = item?._handmuxId || item?.id || `item-${itemIndex}`;
178
+ return `codex:${turnId}:${itemId}`;
179
+ }
180
+
181
+ // Project App Server's partial item snapshot for connection-level tests/debugging. This is deliberately not
182
+ // the conversation transcript source: current App Server snapshots can omit completed tools. The durable
183
+ // transcript route reads Codex's exact rollout instead.
184
+ export function projectCodexThread(thread) {
185
+ const messages = [];
186
+ for (const [turnIndex, turn] of (thread?.turns || []).entries()) {
187
+ const ts = typeof turn.startedAt === 'number' ? new Date(turn.startedAt * 1000).toISOString() : undefined;
188
+ for (const [itemIndex, item] of (turn.items || []).entries()) {
189
+ const id = projectedMessageId(turn, turnIndex, item, itemIndex);
190
+ if (item.type === 'userMessage') {
191
+ const text = inputText(item.content);
192
+ if (text.trim()) messages.push({ id, type: 'text', role: 'user', text, ts });
193
+ } else if (item.type === 'agentMessage') {
194
+ if (item.text?.trim()) messages.push({ id, type: 'text', role: 'assistant', text: item.text, ts });
195
+ } else if (item.type === 'reasoning') {
196
+ const text = [...(item.summary || []), ...(item.content || [])].join('\n');
197
+ if (text.trim()) messages.push({ id, type: 'thinking', role: 'assistant', text, ts });
198
+ } else if (item.type === 'contextCompaction') {
199
+ messages.push({ id, type: 'compact', ts });
200
+ } else if (item.type === 'fileChange') {
201
+ const changes = item.changes?.length ? item.changes : [{}];
202
+ const pathCounts = new Map();
203
+ for (const [changeIndex, change] of changes.entries()) {
204
+ const path = change?.path || '';
205
+ const occurrence = pathCounts.get(path) || 0;
206
+ pathCounts.set(path, occurrence + 1);
207
+ const changeId = path
208
+ ? `${id}:change-${encodeURIComponent(path)}-${occurrence}`
209
+ : `${id}:change-${changeIndex}`;
210
+ messages.push({ id: changeId, type: 'tool', role: 'assistant', tool: toolFromItem(item, change), ts });
211
+ }
212
+ } else {
213
+ const tool = toolFromItem(item);
214
+ if (tool) messages.push({ id, type: 'tool', role: 'assistant', tool, ts });
215
+ }
216
+ }
217
+ if (turn.status === 'interrupted') {
218
+ messages.push({ id: `codex:${turn?.id || `turn-${turnIndex}`}:interrupt`, type: 'interrupt', ts });
219
+ }
220
+ }
221
+ return messages.map((message, k) => ({ ...message, k }));
222
+ }
223
+
224
+ function normalizeApproval(message) {
225
+ const { params = {} } = message;
226
+ const permissions = message.method === 'item/permissions/requestApproval';
227
+ const supplied = Array.isArray(params.availableDecisions)
228
+ ? params.availableDecisions.map(approvalDecision).filter(Boolean)
229
+ : null;
230
+ return {
231
+ id: String(message.id),
232
+ type: permissions ? 'permissions' : message.method === 'item/fileChange/requestApproval' ? 'file' : 'command',
233
+ threadId: params.threadId,
234
+ turnId: params.turnId,
235
+ itemId: params.itemId,
236
+ command: params.command || null,
237
+ cwd: params.cwd || null,
238
+ reason: params.reason || null,
239
+ decisions: permissions
240
+ ? ['accept', 'acceptForSession', 'decline']
241
+ : supplied || ['accept', 'acceptForSession', 'decline', 'cancel'],
242
+ };
243
+ }
244
+
245
+ function resolveApprovalDecision(request, selected) {
246
+ const available = request.params?.availableDecisions;
247
+ if (!Array.isArray(available)) return SIMPLE_DECISIONS.has(selected) ? selected : null;
248
+ if (SIMPLE_DECISIONS.has(selected)) {
249
+ return available.includes(selected) ? selected : null;
250
+ }
251
+ const match = /^structured:(\d+)$/.exec(selected);
252
+ if (!match) return null;
253
+ const index = Number(match[1]);
254
+ const value = available[index];
255
+ return structuredDecision(value, index)?.id === selected ? value : null;
256
+ }
257
+
258
+ function permissionResponse(request, decision) {
259
+ const requested = request.params?.permissions || {};
260
+ const permissions = {};
261
+ if (decision !== 'decline') {
262
+ if (requested.network != null) permissions.network = requested.network;
263
+ if (requested.fileSystem != null) permissions.fileSystem = requested.fileSystem;
264
+ }
265
+ return { permissions, scope: decision === 'acceptForSession' ? 'session' : 'turn' };
266
+ }
267
+
268
+ function normalizeUserInput(message) {
269
+ const { params = {} } = message;
270
+ return {
271
+ id: String(message.id),
272
+ threadId: params.threadId,
273
+ turnId: params.turnId,
274
+ itemId: params.itemId,
275
+ autoResolutionMs: params.autoResolutionMs ?? null,
276
+ questions: Array.isArray(params.questions) ? params.questions.map((question) => ({
277
+ id: String(question.id || ''),
278
+ header: String(question.header || ''),
279
+ question: String(question.question || ''),
280
+ isOther: !!question.isOther,
281
+ isSecret: !!question.isSecret,
282
+ options: Array.isArray(question.options) ? question.options.map((option) => ({
283
+ label: String(option.label || ''),
284
+ description: String(option.description || ''),
285
+ })) : null,
286
+ })).filter((question) => question.id && question.question) : [],
287
+ };
288
+ }
289
+
290
+ function turnSummary(turn) {
291
+ const items = Array.isArray(turn?.items) ? turn.items : [];
292
+ const message = [...items].reverse().find((item) => item?.type === 'agentMessage' && item.text?.trim());
293
+ return message?.text || turn?.error?.message || '';
294
+ }
295
+
296
+ function turnPrompt(turn) {
297
+ const items = Array.isArray(turn?.items) ? turn.items : [];
298
+ for (let index = items.length - 1; index >= 0; index--) {
299
+ const prompt = userPromptFromItem(items[index]);
300
+ if (prompt) return prompt;
301
+ }
302
+ return '';
303
+ }
304
+
305
+ function activeTurnPrompt(state) {
306
+ if (state?.activePrompt) return state.activePrompt;
307
+ const turns = Array.isArray(state?.thread?.turns) ? state.thread.turns : [];
308
+ const active = state?.activeTurnId
309
+ ? turns.find((turn) => turn.id === state.activeTurnId)
310
+ : [...turns].reverse().find((turn) => turn.status === 'inProgress');
311
+ return turnPrompt(active);
312
+ }
313
+
314
+ function activeKind(status) {
315
+ if (status?.type !== 'active') return null;
316
+ const flags = Array.isArray(status.activeFlags) ? status.activeFlags : [];
317
+ return flags.includes('waitingOnApproval') || flags.includes('waitingOnUserInput') ? 'permission' : 'working';
318
+ }
319
+
320
+ function liveItemSignature(item) {
321
+ if (item?.type === 'userMessage') {
322
+ const text = inputText(item.content);
323
+ return text ? `userMessage\0${text}` : null;
324
+ }
325
+ if (item?.type === 'agentMessage') {
326
+ return item.text ? `agentMessage\0${item.text}` : null;
327
+ }
328
+ if (item?.type === 'commandExecution') return `commandExecution\0${jsonText(item.command)}\0${item.cwd || ''}`;
329
+ if (item?.type === 'fileChange') {
330
+ return `fileChange\0${(item.changes || []).map((change) => change?.path || '').join('\0')}`;
331
+ }
332
+ if (item?.type === 'mcpToolCall') return `mcpToolCall\0${item.server || ''}\0${item.tool || ''}\0${jsonText(item.arguments)}`;
333
+ if (item?.type === 'dynamicToolCall') return `dynamicToolCall\0${item.tool || ''}\0${jsonText(item.arguments)}`;
334
+ if (item?.type === 'collabAgentToolCall') {
335
+ return `collabAgentToolCall\0${item.tool || ''}\0${item.prompt || ''}\0${jsonText(item.receiverThreadIds)}`;
336
+ }
337
+ if (item?.type === 'webSearch') return `webSearch\0${item.query || ''}\0${jsonText(item.action)}`;
338
+ if (item?.type === 'imageView') return `imageView\0${item.path || ''}`;
339
+ if (item?.type === 'sleep') return `sleep\0${item.durationMs ?? ''}`;
340
+ if (item?.type === 'imageGeneration') return `imageGeneration\0${item.revisedPrompt || ''}`;
341
+ return null;
342
+ }
343
+
344
+ // Keep a best-effort connection snapshot for status/debugging only. Item notifications temporarily overlay
345
+ // thread/read so this internal view remains useful; neither channel feeds the conversation transcript.
346
+ function mergeTurnWithLive(previous, fresh, liveIds) {
347
+ if (!previous) return fresh;
348
+ const previousById = new Map((previous.items || []).filter((item) => item?.id).map((item) => [item.id, item]));
349
+ const freshItems = (Array.isArray(fresh?.items) ? fresh.items : []).map((item) => {
350
+ const stableId = previousById.get(item?.id)?._handmuxId;
351
+ return stableId && !item._handmuxId ? { ...item, _handmuxId: stableId } : item;
352
+ });
353
+ if (!liveIds?.size) return { ...previous, ...fresh, items: freshItems };
354
+ const freshById = new Map(freshItems.filter((item) => item?.id).map((item) => [item.id, item]));
355
+ const freshMatches = new Map();
356
+ for (const item of freshItems) {
357
+ const signature = liveItemSignature(item);
358
+ if (!signature || !item?.id) continue;
359
+ if (!freshMatches.has(signature)) freshMatches.set(signature, []);
360
+ freshMatches.get(signature).push(item);
361
+ }
362
+ const matchedFreshIds = new Set((previous.items || [])
363
+ .filter((item) => item?.id && !liveIds.has(item.id) && freshById.has(item.id))
364
+ .map((item) => item.id));
365
+ const overlays = [];
366
+ for (const item of previous.items || []) {
367
+ if (!item?.id || !liveIds.has(item.id)) continue;
368
+ const sameId = freshById.get(item.id);
369
+ const signature = sameId ? null : liveItemSignature(item);
370
+ const canonical = sameId || (signature
371
+ ? freshMatches.get(signature)?.find((candidate) => !matchedFreshIds.has(candidate.id))
372
+ : null);
373
+ if (canonical) {
374
+ if (canonical.id !== item.id && !canonical._handmuxId) {
375
+ const index = freshItems.findIndex((candidate) => candidate?.id === canonical.id);
376
+ if (index >= 0) freshItems[index] = { ...canonical, _handmuxId: item._handmuxId || item.id };
377
+ }
378
+ matchedFreshIds.add(canonical.id);
379
+ liveIds.delete(item.id);
380
+ } else {
381
+ overlays.push(item);
382
+ }
383
+ }
384
+ // For this partial internal view, prefer fresh snapshot order and retain event-only tail items.
385
+ const seen = new Set(freshItems.map((item) => item?.id).filter(Boolean));
386
+ return {
387
+ ...previous,
388
+ ...fresh,
389
+ items: [...freshItems, ...overlays.filter((item) => item?.id && !seen.has(item.id))],
390
+ };
391
+ }
392
+
393
+ function mergeThreadWithLive(previous, fresh, liveItemIds) {
394
+ if (!previous || !fresh) return fresh;
395
+ const previousTurns = new Map((previous.turns || []).map((turn) => [turn.id, turn]));
396
+ const seen = new Set();
397
+ const turns = (fresh.turns || []).map((turn) => {
398
+ seen.add(turn.id);
399
+ const merged = mergeTurnWithLive(previousTurns.get(turn.id), turn, liveItemIds.get(turn.id));
400
+ if (liveItemIds.get(turn.id)?.size === 0) liveItemIds.delete(turn.id);
401
+ return merged;
402
+ });
403
+ for (const turn of previous.turns || []) {
404
+ if (!seen.has(turn.id) && liveItemIds.has(turn.id)) turns.push(turn);
405
+ }
406
+ return { ...previous, ...fresh, turns };
407
+ }
408
+
409
+ function settingsFromResume(result) {
410
+ return {
411
+ model: result?.model || null,
412
+ modelProvider: result?.modelProvider || null,
413
+ serviceTier: result?.serviceTier ?? null,
414
+ cwd: result?.cwd || null,
415
+ runtimeWorkspaceRoots: Array.isArray(result?.runtimeWorkspaceRoots) ? result.runtimeWorkspaceRoots : null,
416
+ approvalPolicy: result?.approvalPolicy || null,
417
+ approvalsReviewer: result?.approvalsReviewer || null,
418
+ sandboxPolicy: result?.sandbox || null,
419
+ activePermissionProfile: result?.activePermissionProfile ?? null,
420
+ effort: result?.reasoningEffort ?? null,
421
+ multiAgentMode: result?.multiAgentMode || null,
422
+ };
423
+ }
424
+
425
+ function contextUsageFromNotification(tokenUsage) {
426
+ const usedTokens = tokenUsage?.last?.totalTokens;
427
+ const totalTokens = tokenUsage?.modelContextWindow;
428
+ if (!Number.isFinite(usedTokens) || usedTokens < 0
429
+ || !Number.isFinite(totalTokens) || totalTokens <= 0) return null;
430
+ return { usedTokens, totalTokens };
431
+ }
432
+
433
+ function connectUnixWebSocket(socketPath) {
434
+ return new WebSocket('ws://localhost/rpc', {
435
+ createConnection: () => net.createConnection(socketPath),
436
+ perMessageDeflate: false,
437
+ });
438
+ }
439
+
440
+ class CodexAppConnection {
441
+ constructor({
442
+ pane, socketPath, connect = connectUnixWebSocket, timeoutMs = RPC_TIMEOUT_MS,
443
+ now = () => Date.now(), baseline = true, onStateChange = () => {}, onClose = () => {},
444
+ queueStore = new Map(), submissionStore = new Map(), nextQueueId = () => `${Date.now()}`,
445
+ }) {
446
+ this.pane = pane;
447
+ this.socketPath = socketPath;
448
+ this.connect = connect;
449
+ this.timeoutMs = timeoutMs;
450
+ this.onClose = onClose;
451
+ this.now = now;
452
+ this.baseline = baseline;
453
+ this.onStateChange = onStateChange;
454
+ this.queueStore = queueStore;
455
+ this.submissionStore = submissionStore;
456
+ this.nextQueueId = nextQueueId;
457
+ this.nextId = 1;
458
+ this.pending = new Map();
459
+ this.approvals = new Map();
460
+ this.userInputs = new Map();
461
+ this.streamListeners = new Set();
462
+ this.threadState = new Map();
463
+ this.subscribed = new Set();
464
+ this.lastStartedThreadId = null;
465
+ this.currentThreadId = null;
466
+ this.inbox = { kind: null, msg: '', ts: 0, key: 'idle', suppressPush: false };
467
+ this.opening = null;
468
+ this.closed = false;
469
+ }
470
+
471
+ open() {
472
+ if (this.opening) return this.opening;
473
+ this.opening = this._open().catch((error) => {
474
+ this.fail(error);
475
+ throw error;
476
+ });
477
+ return this.opening;
478
+ }
479
+
480
+ async _open() {
481
+ this.ws = this.connect(this.socketPath);
482
+ this.ws.on('message', (data) => this.onLine(data.toString()));
483
+ this.ws.once('close', () => this.fail(new Error('Codex App Server connection closed')));
484
+ // Keep a lifetime error listener, not only the startup rejector: otherwise a stale socket that errors
485
+ // once during connect and again while closing can become an unhandled EventEmitter error.
486
+ this.ws.on('error', (error) => this.fail(error));
487
+ await new Promise((resolve, reject) => {
488
+ this.ws.once('open', resolve);
489
+ this.ws.once('error', reject);
490
+ });
491
+ await this.rpc('initialize', {
492
+ clientInfo: { name: 'handmux', title: 'Handmux', version: process.env.npm_package_version || 'unknown' },
493
+ capabilities: { experimentalApi: true },
494
+ });
495
+ this.notify('initialized', {});
496
+ return this;
497
+ }
498
+
499
+ onLine(line) {
500
+ let message;
501
+ try { message = JSON.parse(line); } catch { return; }
502
+ if (message.id != null && (Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error'))) {
503
+ const waiter = this.pending.get(message.id);
504
+ if (!waiter) return;
505
+ this.pending.delete(message.id);
506
+ clearTimeout(waiter.timer);
507
+ if (message.error) waiter.reject(asError(message.error));
508
+ else waiter.resolve(message.result);
509
+ return;
510
+ }
511
+ if (message.id != null && APPROVAL_METHODS.has(message.method)) {
512
+ this.approvals.set(String(message.id), message);
513
+ const approval = normalizeApproval(message);
514
+ this.markWaiting(approval.threadId, 'waitingOnApproval');
515
+ if (this.isCurrentThread(approval.threadId)) {
516
+ this.setInbox(
517
+ 'permission',
518
+ approval.reason || approval.command || activeTurnPrompt(this.state(approval.threadId)),
519
+ `approval:${message.id}`,
520
+ );
521
+ }
522
+ this.bump(message.params?.threadId);
523
+ return;
524
+ }
525
+ if (message.id != null && message.method === USER_INPUT_METHOD) {
526
+ this.userInputs.set(String(message.id), message);
527
+ const input = normalizeUserInput(message);
528
+ this.markWaiting(input.threadId, 'waitingOnUserInput');
529
+ if (this.isCurrentThread(input.threadId)) {
530
+ this.setInbox(
531
+ 'permission',
532
+ input.questions[0]?.question || activeTurnPrompt(this.state(input.threadId)),
533
+ `input:${message.id}`,
534
+ );
535
+ }
536
+ this.bump(message.params?.threadId);
537
+ return;
538
+ }
539
+ if (!message.method) return;
540
+ const params = message.params || {};
541
+ if ((message.method === 'item/started' || message.method === 'item/completed') && params.item) {
542
+ this.upsertLiveItem(params.threadId, params.turnId, params.item);
543
+ if (params.item.type === 'agentMessage') {
544
+ const state = this.state(params.threadId);
545
+ const itemKey = `${params.turnId}\0${params.item.id}`;
546
+ if (message.method === 'item/completed') state.completedAgentItemIds.add(itemKey);
547
+ else state.completedAgentItemIds.delete(itemKey);
548
+ this.emitStream({
549
+ type: message.method === 'item/started' ? 'started' : 'completed',
550
+ threadId: params.threadId,
551
+ turnId: params.turnId,
552
+ itemId: params.item.id,
553
+ text: params.item.text || '',
554
+ });
555
+ }
556
+ const state = this.state(params.threadId);
557
+ const prompt = userPromptFromItem(params.item);
558
+ if (prompt && state.activeTurnId === params.turnId) {
559
+ state.activePrompt = prompt;
560
+ if (this.isCurrentThread(params.threadId)
561
+ && this.inbox.kind !== 'permission' && this.inbox.kind !== 'compacting') {
562
+ this.setInbox('working', prompt, `turn:${params.turnId}:started`);
563
+ }
564
+ }
565
+ } else if (message.method === 'item/agentMessage/delta') {
566
+ const delta = typeof params.delta === 'string' ? params.delta : '';
567
+ if (params.threadId && params.turnId && params.itemId && delta) {
568
+ this.appendAgentDelta(params.threadId, params.turnId, params.itemId, delta);
569
+ this.emitStream({
570
+ type: 'delta', threadId: params.threadId, turnId: params.turnId,
571
+ itemId: params.itemId, delta,
572
+ });
573
+ }
574
+ } else if (message.method === 'serverRequest/resolved') {
575
+ this.approvals.delete(String(params.requestId));
576
+ this.userInputs.delete(String(params.requestId));
577
+ this.markWorking(params.threadId);
578
+ if (this.isCurrentThread(params.threadId)) {
579
+ this.setInbox(
580
+ 'working', activeTurnPrompt(this.state(params.threadId)) || this.inbox.msg,
581
+ `resolved:${params.requestId}`,
582
+ );
583
+ }
584
+ } else if (message.method === 'thread/status/changed') {
585
+ const state = this.state(params.threadId);
586
+ state.status = params.status;
587
+ const kind = activeKind(params.status);
588
+ if (!this.isCurrentThread(params.threadId)) {
589
+ /* retain the state for that thread, but never let a late background event rebind this pane */
590
+ } else if (kind === 'working' && this.inbox.kind === 'compacting') {
591
+ /* keep the more specific state until thread/compacted or idle */
592
+ } else if (kind) {
593
+ const pendingMessage = kind === 'permission' && this.inbox.kind === kind ? this.inbox.msg : '';
594
+ const activeMessage = activeTurnPrompt(state)
595
+ || (['working', 'permission'].includes(this.inbox.kind) ? this.inbox.msg : '');
596
+ this.setInbox(kind, pendingMessage || activeMessage, `status:${params.threadId}:${kind}`);
597
+ } else if (params.status?.type === 'idle' && this.inbox.kind === 'compacting') {
598
+ this.setInbox(null, '', `thread:${params.threadId}:compacted`);
599
+ }
600
+ } else if (message.method === 'turn/started') {
601
+ const state = this.state(params.threadId);
602
+ const previousTurnId = state.activeTurnId;
603
+ state.activeTurnId = params.turn?.id || params.turnId || null;
604
+ state.status = { type: 'active', activeFlags: [] };
605
+ const turn = this.upsertTurn(params.threadId, params.turn);
606
+ const prompt = turnPrompt(turn);
607
+ if (prompt) state.activePrompt = prompt;
608
+ else if (previousTurnId && previousTurnId !== state.activeTurnId) state.activePrompt = '';
609
+ if (this.isCurrentThread(params.threadId)) {
610
+ this.setInbox(
611
+ 'working', activeTurnPrompt(state),
612
+ `turn:${params.turn?.id || params.turnId}:started`,
613
+ );
614
+ }
615
+ } else if (message.method === 'turn/completed') {
616
+ const state = this.state(params.threadId);
617
+ const turn = this.upsertTurn(params.threadId, params.turn) || params.turn || null;
618
+ const prompt = activeTurnPrompt(state) || turnPrompt(turn);
619
+ state.activeTurnId = null;
620
+ state.activePrompt = '';
621
+ state.status = { type: 'idle' };
622
+ state.lastTurn = turn;
623
+ const status = turn?.status;
624
+ if (!this.isCurrentThread(params.threadId)) {
625
+ /* stale/background completion: update only its own thread state */
626
+ } else if (status === 'completed' || status === 'failed') {
627
+ const completedAt = typeof turn?.completedAt === 'number' ? turn.completedAt * 1000 : undefined;
628
+ this.setInbox('done', turnSummary(turn) || prompt, `turn:${turn?.id || params.turnId}:${status}`, completedAt);
629
+ } else {
630
+ this.setInbox(null, '', `turn:${params.turn?.id || params.turnId}:${status || 'ended'}`);
631
+ }
632
+ if (status === 'completed') {
633
+ void this.drainQueue(params.threadId).catch(() => {});
634
+ }
635
+ this.emitStream({
636
+ type: 'turnCompleted', threadId: params.threadId,
637
+ turnId: turn?.id || params.turnId || null, status: status || null,
638
+ });
639
+ } else if (message.method === 'thread/settings/updated') {
640
+ const state = this.state(params.threadId);
641
+ state.settings = params.threadSettings
642
+ ? { ...(state.settings || {}), ...params.threadSettings }
643
+ : state.settings;
644
+ } else if (message.method === 'thread/tokenUsage/updated') {
645
+ this.state(params.threadId).contextUsage = contextUsageFromNotification(params.tokenUsage);
646
+ } else if (message.method === 'thread/compacted') {
647
+ this.state(params.threadId).status = { type: 'idle' };
648
+ if (this.isCurrentThread(params.threadId)) this.setInbox(null, '', `thread:${params.threadId}:compacted`);
649
+ void this.drainQueue(params.threadId).catch(() => {});
650
+ } else if (message.method === 'thread/started') {
651
+ const startedThreadId = params.thread?.id || params.threadId || null;
652
+ // Collaboration child threads share this App Server connection. They are independent work, not a
653
+ // replacement for the root TUI conversation represented by this pane.
654
+ if (startedThreadId && params.thread?.parentThreadId == null) {
655
+ const previous = this.currentThreadId;
656
+ this.lastStartedThreadId = startedThreadId;
657
+ this.currentThreadId = startedThreadId;
658
+ if (previous && previous !== startedThreadId) {
659
+ // A TUI-originated /clear switches this pane without going through Handmux's request path. Any
660
+ // pending messages still belong to the old conversation and must never drain after the switch.
661
+ this.discardQueue(previous);
662
+ this.setInbox(null, '', `thread:${startedThreadId}:started`);
663
+ }
664
+ }
665
+ }
666
+ this.bump(params.threadId);
667
+ }
668
+
669
+ setInbox(kind, msg = '', key = `${kind || 'idle'}`, ts = undefined) {
670
+ if (this.inbox.key === key && this.inbox.kind === kind && this.inbox.msg === msg) return;
671
+ this.inbox = { kind, msg, ts: ts ?? this.now(), key, suppressPush: this.baseline };
672
+ queueMicrotask(() => Promise.resolve(this.onStateChange(this.pane)).catch(() => {}));
673
+ }
674
+
675
+ takeInbox() {
676
+ const snapshot = { ...this.inbox };
677
+ this.inbox.suppressPush = false;
678
+ return snapshot;
679
+ }
680
+
681
+ state(threadId) {
682
+ if (!this.threadState.has(threadId)) this.threadState.set(threadId, {
683
+ revision: 0, readRevision: -1, thread: null, status: null, activeTurnId: null, settings: null,
684
+ activePrompt: '', contextUsage: null, lastTurn: null, loadedOnly: false, liveItemIds: new Map(),
685
+ completedAgentItemIds: new Set(),
686
+ });
687
+ return this.threadState.get(threadId);
688
+ }
689
+
690
+ queueKey(threadId) { return `${this.pane}\0${threadId}`; }
691
+
692
+ queueState(threadId, create = true) {
693
+ const key = this.queueKey(threadId);
694
+ let state = this.queueStore.get(key);
695
+ if (!state && create) {
696
+ state = {
697
+ items: [], starting: false, draining: false, steering: new Set(), editing: null, editTimer: null,
698
+ };
699
+ this.queueStore.set(key, state);
700
+ }
701
+ return state || null;
702
+ }
703
+
704
+ clearQueueEditTimer(queue) {
705
+ if (!queue?.editTimer) return;
706
+ clearTimeout(queue.editTimer);
707
+ queue.editTimer = null;
708
+ }
709
+
710
+ scheduleQueueEditExpiry(threadId) {
711
+ const queue = this.queueState(threadId, false);
712
+ this.clearQueueEditTimer(queue);
713
+ if (!queue?.editing) return;
714
+ const token = queue.editing.token;
715
+ const delay = Math.max(1, queue.editing.expiresAt - this.now());
716
+ queue.editTimer = setTimeout(() => {
717
+ queue.editTimer = null;
718
+ if (queue.editing?.token !== token) return;
719
+ if (!this.expireQueueEdit(threadId)) this.scheduleQueueEditExpiry(threadId);
720
+ }, delay);
721
+ queue.editTimer.unref?.();
722
+ }
723
+
724
+ expireQueueEdit(threadId, { resume = true } = {}) {
725
+ const queue = this.queueState(threadId, false);
726
+ if (!queue?.editing || queue.editing.expiresAt > this.now()) return false;
727
+ this.clearQueueEditTimer(queue);
728
+ queue.editing = null;
729
+ this.bump(threadId);
730
+ this.cleanupQueue(threadId);
731
+ if (resume) queueMicrotask(() => { void this.drainQueue(threadId).catch(() => {}); });
732
+ return true;
733
+ }
734
+
735
+ wakeQueue(threadId) {
736
+ this.expireQueueEdit(threadId, { resume: false });
737
+ const queue = this.queueState(threadId, false);
738
+ const state = this.state(threadId);
739
+ if (!queue?.items.length || queue.editing || state.status?.type !== 'idle'
740
+ || state.lastTurn?.status !== 'completed') return;
741
+ queueMicrotask(() => { void this.drainQueue(threadId).catch(() => {}); });
742
+ }
743
+
744
+ queuedFor(threadId) {
745
+ this.wakeQueue(threadId);
746
+ const queue = this.queueState(threadId, false);
747
+ return (queue?.items || []).map((item) => ({
748
+ ...this.queueItemView(item),
749
+ ...(queue.editing?.itemId === item.id ? { editing: true } : {}),
750
+ }));
751
+ }
752
+
753
+ queueItemView(item) {
754
+ const { clientId: _clientId, ...view } = item;
755
+ return view;
756
+ }
757
+
758
+ reconcileQueuedDeliveries(threadId, source) {
759
+ const turns = Array.isArray(source?.turns)
760
+ ? source.turns
761
+ : (Array.isArray(source?.items) ? [source] : []);
762
+ const items = source?.type === 'userMessage'
763
+ ? [source]
764
+ : turns.flatMap((turn) => turn?.items || []);
765
+ const clientIds = new Set(items
766
+ .filter((item) => item?.type === 'userMessage' && typeof item.clientId === 'string' && item.clientId)
767
+ .map((item) => item.clientId));
768
+ if (!clientIds.size) return false;
769
+ const queue = this.queueState(threadId, false);
770
+ if (!queue?.items.length) return false;
771
+ const removedIds = new Set(queue.items
772
+ .filter((item) => clientIds.has(item.clientId))
773
+ .map((item) => item.id));
774
+ if (!removedIds.size) return false;
775
+ queue.items = queue.items.filter((item) => !removedIds.has(item.id));
776
+ if (removedIds.has(queue.editing?.itemId)) {
777
+ this.clearQueueEditTimer(queue);
778
+ queue.editing = null;
779
+ }
780
+ this.bump(threadId);
781
+ this.cleanupQueue(threadId);
782
+ return true;
783
+ }
784
+
785
+ cleanupQueue(threadId) {
786
+ const state = this.queueState(threadId, false);
787
+ if (state && !state.items.length && !state.starting && !state.draining
788
+ && !state.steering.size && !state.editing) {
789
+ this.clearQueueEditTimer(state);
790
+ this.queueStore.delete(this.queueKey(threadId));
791
+ }
792
+ }
793
+
794
+ discardQueue(threadId) {
795
+ this.clearQueueEditTimer(this.queueState(threadId, false));
796
+ this.queueStore.delete(this.queueKey(threadId));
797
+ this.bump(threadId);
798
+ }
799
+
800
+ activeTurn(threadId) {
801
+ const state = this.state(threadId);
802
+ return state.activeTurnId
803
+ || [...(state.thread?.turns || [])].reverse().find((turn) => turn.status === 'inProgress')?.id
804
+ || null;
805
+ }
806
+
807
+ enqueue(threadId, text, requestId = null) {
808
+ const queue = this.queueState(threadId);
809
+ if (queue.items.length >= MAX_QUEUED_MESSAGES) throw new Error('pending message queue is full');
810
+ const id = this.nextQueueId();
811
+ const item = {
812
+ id, text, createdAt: this.now(), clientId: requestId || `handmux-queue:${id}`,
813
+ ...(requestId ? { requestId } : {}),
814
+ };
815
+ queue.items.push(item);
816
+ this.bump(threadId);
817
+ return this.queueItemView(item);
818
+ }
819
+
820
+ async startTurn(threadId, text, clientUserMessageId = null) {
821
+ const queue = this.queueState(threadId);
822
+ queue.starting = true;
823
+ const state = this.state(threadId);
824
+ const previousPrompt = state.activePrompt;
825
+ state.activePrompt = text.trim();
826
+ try {
827
+ const result = await this.rpc('turn/start', {
828
+ threadId, input: [{ type: 'text', text }],
829
+ ...(clientUserMessageId ? { clientUserMessageId } : {}),
830
+ });
831
+ state.activeTurnId = result.turn?.id || null;
832
+ state.status = { type: 'active', activeFlags: [] };
833
+ this.currentThreadId ||= threadId;
834
+ this.setInbox('working', state.activePrompt, `turn:${result.turn?.id || 'starting'}:started`);
835
+ this.bump(threadId);
836
+ if (state.loadedOnly) {
837
+ // turn/start normally persists the first rollout synchronously; attach this observer immediately.
838
+ await this.ensureThread(threadId);
839
+ }
840
+ return result;
841
+ } catch (error) {
842
+ state.activePrompt = previousPrompt;
843
+ throw error;
844
+ } finally {
845
+ queue.starting = false;
846
+ this.cleanupQueue(threadId);
847
+ }
848
+ }
849
+
850
+ async submitOnce(threadId, text, requestId = null) {
851
+ const queue = this.queueState(threadId);
852
+ const knownState = this.state(threadId);
853
+ if (this.activeTurn(threadId) || knownState.status?.type === 'active'
854
+ || queue.items.length || queue.starting || queue.draining) {
855
+ return { queued: true, item: this.enqueue(threadId, text, requestId) };
856
+ }
857
+ const state = await this.ensureThread(threadId);
858
+ if (this.activeTurn(threadId) || state.status?.type === 'active'
859
+ || queue.items.length || queue.starting || queue.draining) {
860
+ return { queued: true, item: this.enqueue(threadId, text, requestId) };
861
+ }
862
+ return this.startTurn(threadId, text, requestId);
863
+ }
864
+
865
+ pruneSubmissionReceipts() {
866
+ if (this.submissionStore.size <= MAX_SUBMISSION_RECEIPTS) return;
867
+ for (const [key, receipt] of this.submissionStore) {
868
+ if (!receipt.settled) continue;
869
+ this.submissionStore.delete(key);
870
+ if (this.submissionStore.size <= MAX_SUBMISSION_RECEIPTS) break;
871
+ }
872
+ }
873
+
874
+ async submit(threadId, text, requestId = null) {
875
+ if (!requestId) return this.submitOnce(threadId, text);
876
+ const key = `${this.pane}\0${threadId}\0${requestId}`;
877
+ const existing = this.submissionStore.get(key);
878
+ if (existing) {
879
+ if (existing.text !== text) throw new Error('Codex request id was already used for another message');
880
+ return existing.promise;
881
+ }
882
+ const receipt = { text, settled: false, promise: null };
883
+ receipt.promise = this.submitOnce(threadId, text, requestId);
884
+ this.submissionStore.set(key, receipt);
885
+ receipt.promise.then(() => {
886
+ receipt.settled = true;
887
+ this.pruneSubmissionReceipts();
888
+ }, () => {
889
+ if (this.submissionStore.get(key) === receipt) this.submissionStore.delete(key);
890
+ });
891
+ this.pruneSubmissionReceipts();
892
+ return receipt.promise;
893
+ }
894
+
895
+ async drainQueue(threadId) {
896
+ await this.assertCurrentThread(threadId);
897
+ this.expireQueueEdit(threadId, { resume: false });
898
+ const queue = this.queueState(threadId, false);
899
+ if (!queue?.items.length || queue.draining || queue.starting || queue.editing || this.activeTurn(threadId)
900
+ || this.state(threadId).status?.type === 'active') return;
901
+ queue.draining = true;
902
+ const item = queue.items[0];
903
+ try {
904
+ await this.startTurn(threadId, item.text, item.clientId);
905
+ if (queue.items[0]?.id === item.id) queue.items.shift();
906
+ else queue.items = queue.items.filter((candidate) => candidate.id !== item.id);
907
+ this.bump(threadId);
908
+ } finally {
909
+ queue.draining = false;
910
+ this.cleanupQueue(threadId);
911
+ }
912
+ }
913
+
914
+ async steerQueued(threadId, itemId) {
915
+ await this.ensureThread(threadId);
916
+ const queue = this.queueState(threadId, false);
917
+ const item = queue?.items.find((candidate) => candidate.id === itemId);
918
+ if (!item) throw new Error('queued message is no longer pending');
919
+ if (queue.draining && queue.items[0]?.id === itemId) {
920
+ throw new Error('queued message is already being sent');
921
+ }
922
+ if (queue.editing?.itemId === itemId) throw new Error('queued message is being edited');
923
+ if (queue.steering.has(itemId)) throw new Error('queued message is already being sent');
924
+ queue.steering.add(itemId);
925
+ try {
926
+ const turnId = this.activeTurn(threadId);
927
+ let result;
928
+ if (turnId) {
929
+ result = await this.rpc('turn/steer', {
930
+ threadId, expectedTurnId: turnId, input: [{ type: 'text', text: item.text }],
931
+ clientUserMessageId: item.clientId,
932
+ });
933
+ } else {
934
+ if (queue.starting || queue.draining) throw new Error('queued message is already being sent');
935
+ result = await this.startTurn(threadId, item.text, item.clientId);
936
+ }
937
+ queue.items = queue.items.filter((candidate) => candidate.id !== itemId);
938
+ this.bump(threadId);
939
+ return { steered: true, item: this.queueItemView(item), result };
940
+ } finally {
941
+ queue.steering.delete(itemId);
942
+ this.cleanupQueue(threadId);
943
+ }
944
+ }
945
+
946
+ removeQueued(threadId, itemId) {
947
+ const queue = this.queueState(threadId, false);
948
+ const index = queue?.items.findIndex((candidate) => candidate.id === itemId) ?? -1;
949
+ if (index < 0) throw new Error('queued message is no longer pending');
950
+ if (queue.draining && index === 0) throw new Error('queued message is already being sent');
951
+ if (queue.editing?.itemId === itemId) throw new Error('queued message is being edited');
952
+ if (queue.steering.has(itemId)) throw new Error('queued message is already being sent');
953
+ queue.items.splice(index, 1);
954
+ this.bump(threadId);
955
+ this.cleanupQueue(threadId);
956
+ return { removed: true };
957
+ }
958
+
959
+ async beginQueuedEdit(threadId, itemId) {
960
+ await this.ensureThread(threadId);
961
+ this.expireQueueEdit(threadId);
962
+ const queue = this.queueState(threadId, false);
963
+ const item = queue?.items.find((candidate) => candidate.id === itemId);
964
+ if (!item) throw new Error('queued message is no longer pending');
965
+ if ((queue.draining && queue.items[0]?.id === itemId) || queue.steering.has(itemId)) {
966
+ throw new Error('queued message is already being sent');
967
+ }
968
+ if (queue.editing && queue.editing.itemId !== itemId) {
969
+ throw new Error('another queued message is being edited');
970
+ }
971
+ // Reopening the same editor replaces its token. This lets a reloaded client recover an in-memory edit
972
+ // hold while ensuring an older dialog can no longer overwrite the newer draft.
973
+ const token = this.nextQueueId();
974
+ queue.editing = { itemId, token, expiresAt: this.now() + QUEUE_EDIT_LEASE_MS };
975
+ this.scheduleQueueEditExpiry(threadId);
976
+ this.bump(threadId);
977
+ return {
978
+ editing: true, token, expiresAt: queue.editing.expiresAt,
979
+ item: { ...this.queueItemView(item), editing: true },
980
+ };
981
+ }
982
+
983
+ renewQueuedEdit(threadId, itemId, token) {
984
+ this.expireQueueEdit(threadId);
985
+ const queue = this.queueState(threadId, false);
986
+ if (!queue?.editing || queue.editing.itemId !== itemId || queue.editing.token !== token) {
987
+ throw new Error('queued message edit is no longer active');
988
+ }
989
+ queue.editing.expiresAt = this.now() + QUEUE_EDIT_LEASE_MS;
990
+ this.scheduleQueueEditExpiry(threadId);
991
+ return { editing: true, expiresAt: queue.editing.expiresAt };
992
+ }
993
+
994
+ commitQueuedEdit(threadId, itemId, token, text) {
995
+ this.expireQueueEdit(threadId);
996
+ const queue = this.queueState(threadId, false);
997
+ if (!queue?.editing || queue.editing.itemId !== itemId || queue.editing.token !== token) {
998
+ throw new Error('queued message edit is no longer active');
999
+ }
1000
+ const item = queue.items.find((candidate) => candidate.id === itemId);
1001
+ if (!item) throw new Error('queued message is no longer pending');
1002
+ item.text = text;
1003
+ this.clearQueueEditTimer(queue);
1004
+ queue.editing = null;
1005
+ this.bump(threadId);
1006
+ this.cleanupQueue(threadId);
1007
+ queueMicrotask(() => { void this.drainQueue(threadId).catch(() => {}); });
1008
+ return { edited: true, item: this.queueItemView(item) };
1009
+ }
1010
+
1011
+ cancelQueuedEdit(threadId, itemId, token) {
1012
+ this.expireQueueEdit(threadId);
1013
+ const queue = this.queueState(threadId, false);
1014
+ if (!queue?.editing || queue.editing.itemId !== itemId || queue.editing.token !== token) {
1015
+ throw new Error('queued message edit is no longer active');
1016
+ }
1017
+ this.clearQueueEditTimer(queue);
1018
+ queue.editing = null;
1019
+ this.bump(threadId);
1020
+ this.cleanupQueue(threadId);
1021
+ queueMicrotask(() => { void this.drainQueue(threadId).catch(() => {}); });
1022
+ return { editing: false };
1023
+ }
1024
+
1025
+ isCurrentThread(threadId) {
1026
+ return !!threadId && this.currentThreadId === threadId;
1027
+ }
1028
+
1029
+ async assertCurrentThread(threadId) {
1030
+ if (!this.currentThreadId) await this.discoverThread();
1031
+ if (!this.isCurrentThread(threadId)) throw new Error('Codex session changed');
1032
+ }
1033
+
1034
+ upsertTurn(threadId, incoming) {
1035
+ if (!threadId || !incoming?.id) return null;
1036
+ const state = this.state(threadId);
1037
+ state.thread ||= { id: threadId, turns: [], status: { type: 'active', activeFlags: [] } };
1038
+ state.thread.turns ||= [];
1039
+ const index = state.thread.turns.findIndex((turn) => turn.id === incoming.id);
1040
+ const previous = index >= 0 ? state.thread.turns[index] : null;
1041
+ const turn = mergeTurnWithLive(previous, incoming, state.liveItemIds.get(incoming.id));
1042
+ if (index >= 0) state.thread.turns[index] = turn;
1043
+ else state.thread.turns.push(turn);
1044
+ this.reconcileQueuedDeliveries(threadId, turn);
1045
+ return turn;
1046
+ }
1047
+
1048
+ upsertLiveItem(threadId, turnId, item) {
1049
+ if (!threadId || !turnId || !item?.id) return;
1050
+ const state = this.state(threadId);
1051
+ let ids = state.liveItemIds.get(turnId);
1052
+ if (!ids) {
1053
+ ids = new Set();
1054
+ state.liveItemIds.set(turnId, ids);
1055
+ if (state.liveItemIds.size > 20) {
1056
+ const oldestTurnId = state.liveItemIds.keys().next().value;
1057
+ state.liveItemIds.delete(oldestTurnId);
1058
+ for (const key of state.completedAgentItemIds) {
1059
+ if (key.startsWith(`${oldestTurnId}\0`)) state.completedAgentItemIds.delete(key);
1060
+ }
1061
+ }
1062
+ }
1063
+ ids.add(item.id);
1064
+ state.thread ||= { id: threadId, turns: [], status: { type: 'active', activeFlags: [] } };
1065
+ state.thread.turns ||= [];
1066
+ let turn = state.thread.turns.find((candidate) => candidate.id === turnId);
1067
+ if (!turn) {
1068
+ turn = { id: turnId, status: 'inProgress', items: [] };
1069
+ state.thread.turns.push(turn);
1070
+ }
1071
+ turn.items ||= [];
1072
+ const index = turn.items.findIndex((candidate) => candidate.id === item.id);
1073
+ if (index >= 0) turn.items[index] = item;
1074
+ else turn.items.push(item);
1075
+ this.reconcileQueuedDeliveries(threadId, item);
1076
+ }
1077
+
1078
+ appendAgentDelta(threadId, turnId, itemId, delta) {
1079
+ const state = this.state(threadId);
1080
+ const turn = state.thread?.turns?.find((candidate) => candidate.id === turnId);
1081
+ const item = turn?.items?.find((candidate) => candidate.id === itemId);
1082
+ this.upsertLiveItem(threadId, turnId, {
1083
+ ...(item || {}), id: itemId, type: 'agentMessage', text: `${item?.text || ''}${delta}`,
1084
+ });
1085
+ }
1086
+
1087
+ subscribeStream(threadId, listener) {
1088
+ const subscription = { threadId, listener };
1089
+ this.streamListeners.add(subscription);
1090
+ const state = this.threadState.get(threadId);
1091
+ for (const turn of state?.thread?.turns || []) {
1092
+ const liveIds = state.liveItemIds.get(turn.id);
1093
+ if (!liveIds?.size) continue;
1094
+ for (const item of turn.items || []) {
1095
+ if (item?.type !== 'agentMessage' || !liveIds.has(item.id) || !item.text) continue;
1096
+ // SSE is only a low-latency overlay for the reply that is still being generated. Completed
1097
+ // messages belong exclusively to the rollout projection; replaying them on reconnect can append
1098
+ // older event-only overlays after the current reply and permanently scramble the visible order.
1099
+ if (turn.status !== 'inProgress'
1100
+ || state.completedAgentItemIds.has(`${turn.id}\0${item.id}`)) continue;
1101
+ try {
1102
+ listener({
1103
+ type: 'snapshot', threadId, turnId: turn.id, itemId: item.id,
1104
+ text: item.text, completed: false,
1105
+ });
1106
+ } catch { /* initial projection is best effort */ }
1107
+ }
1108
+ }
1109
+ return () => this.streamListeners.delete(subscription);
1110
+ }
1111
+
1112
+ emitStream(event) {
1113
+ if (!event?.threadId) return;
1114
+ for (const subscription of this.streamListeners) {
1115
+ if (subscription.threadId !== event.threadId) continue;
1116
+ try { subscription.listener(event); } catch { /* one browser must not break the App Server observer */ }
1117
+ }
1118
+ }
1119
+
1120
+ closeStreamListeners() {
1121
+ for (const subscription of this.streamListeners) {
1122
+ try { subscription.listener({ type: 'disconnected', threadId: subscription.threadId }); } catch { /* closing */ }
1123
+ }
1124
+ this.streamListeners.clear();
1125
+ }
1126
+
1127
+ bump(threadId) {
1128
+ if (threadId) this.state(threadId).revision++;
1129
+ }
1130
+
1131
+ markWaiting(threadId, flag) {
1132
+ if (!threadId) return;
1133
+ this.state(threadId).status = { type: 'active', activeFlags: [flag] };
1134
+ }
1135
+
1136
+ markWorking(threadId) {
1137
+ if (!threadId) return;
1138
+ const state = this.state(threadId);
1139
+ if (state.status?.type === 'active') state.status = { ...state.status, activeFlags: [] };
1140
+ }
1141
+
1142
+ write(message) {
1143
+ if (this.closed || this.ws?.readyState !== WebSocket.OPEN) throw new Error('Codex App Server is unavailable');
1144
+ this.ws.send(JSON.stringify(message));
1145
+ }
1146
+
1147
+ rpc(method, params = {}) {
1148
+ const id = this.nextId++;
1149
+ return new Promise((resolve, reject) => {
1150
+ const timer = setTimeout(() => {
1151
+ this.pending.delete(id);
1152
+ reject(new Error(`Codex App Server timed out: ${method}`));
1153
+ }, this.timeoutMs);
1154
+ timer.unref?.();
1155
+ this.pending.set(id, { resolve, reject, timer });
1156
+ try { this.write({ jsonrpc: '2.0', id, method, params }); }
1157
+ catch (error) { clearTimeout(timer); this.pending.delete(id); reject(error); }
1158
+ });
1159
+ }
1160
+
1161
+ notify(method, params = {}) { this.write({ jsonrpc: '2.0', method, params }); }
1162
+ respond(id, result) { this.write({ jsonrpc: '2.0', id, result }); }
1163
+
1164
+ async ensureThread(threadId) {
1165
+ await this.open();
1166
+ const state = this.state(threadId);
1167
+ if (this.subscribed.has(threadId)) return state;
1168
+ try {
1169
+ const result = await this.rpc('thread/resume', { threadId });
1170
+ this.subscribed.add(threadId);
1171
+ state.thread = result.thread || null;
1172
+ this.reconcileQueuedDeliveries(threadId, state.thread);
1173
+ state.readRevision = state.revision;
1174
+ state.status = result.thread?.status || state.status;
1175
+ state.settings = settingsFromResume(result);
1176
+ state.loadedOnly = false;
1177
+ const previousActiveTurnId = state.activeTurnId;
1178
+ const previousActivePrompt = state.activePrompt;
1179
+ state.activeTurnId = [...(result.thread?.turns || [])].reverse()
1180
+ .find((turn) => turn.status === 'inProgress')?.id || null;
1181
+ const resumedPrompt = state.activeTurnId
1182
+ ? turnPrompt((result.thread?.turns || []).find((turn) => turn.id === state.activeTurnId))
1183
+ : '';
1184
+ const last = [...(result.thread?.turns || [])].reverse().find((turn) => turn.status !== 'inProgress') || null;
1185
+ state.lastTurn = last;
1186
+ const samePendingTurn = state.activeTurnId
1187
+ && (!previousActiveTurnId || previousActiveTurnId === state.activeTurnId);
1188
+ state.activePrompt = resumedPrompt || (samePendingTurn ? previousActivePrompt : '');
1189
+ this.currentThreadId ||= threadId;
1190
+ if (this.isCurrentThread(threadId)) {
1191
+ const kind = activeKind(state.status);
1192
+ if (kind) {
1193
+ this.setInbox(kind, activeTurnPrompt(state), `status:${threadId}:${kind}`);
1194
+ } else {
1195
+ if (last?.status === 'completed' || last?.status === 'failed') {
1196
+ const completedAt = typeof last.completedAt === 'number' ? last.completedAt * 1000 : undefined;
1197
+ this.setInbox('done', turnSummary(last) || turnPrompt(last), `turn:${last.id}:${last.status}`, completedAt);
1198
+ }
1199
+ }
1200
+ }
1201
+ // The completion event may have happened while this WebSocket was disconnected. Resume a retained
1202
+ // next-turn queue only when the durable snapshot proves the previous turn completed successfully.
1203
+ this.wakeQueue(threadId);
1204
+ } catch (error) {
1205
+ // A newly opened TUI thread is authoritative but has no rollout until its first turn. It cannot yet
1206
+ // be resumed by a second client, so verify it against this pane's loaded-thread set and keep an empty
1207
+ // projection. turn/start persists it; the next poll then resumes normally and subscribes to events.
1208
+ if (!/no rollout found/i.test(error?.message || '')) throw error;
1209
+ const loaded = await this.loadedThreads();
1210
+ if (!loaded.includes(threadId)) throw error;
1211
+ state.thread ||= { id: threadId, turns: [], status: { type: 'idle' } };
1212
+ state.status ||= state.thread.status;
1213
+ state.readRevision = state.revision;
1214
+ state.loadedOnly = true;
1215
+ }
1216
+ // Every connection begins by projecting the App Server's resting snapshot. Only events received after
1217
+ // that first projection are new activity; this rule is connection-local and must not depend on when
1218
+ // Handmux itself happened to start.
1219
+ this.baseline = false;
1220
+ return state;
1221
+ }
1222
+
1223
+ async readThread(threadId) {
1224
+ await this.ensureThread(threadId);
1225
+ const state = this.state(threadId);
1226
+ if (state.loadedOnly) return state.thread;
1227
+ // Keep refreshing this internal partial view while it still contains event-only overlays.
1228
+ const hasLiveOverlays = [...state.liveItemIds.values()].some((ids) => ids.size > 0);
1229
+ if (state.thread && state.readRevision === state.revision && !hasLiveOverlays) return state.thread;
1230
+ const requestedRevision = state.revision;
1231
+ const result = await this.rpc('thread/read', { threadId, includeTurns: true });
1232
+ state.thread = mergeThreadWithLive(state.thread, result.thread, state.liveItemIds);
1233
+ this.reconcileQueuedDeliveries(threadId, state.thread);
1234
+ state.readRevision = requestedRevision;
1235
+ state.status = result.thread?.status || state.status;
1236
+ const active = [...(state.thread?.turns || [])].reverse().find((turn) => turn.status === 'inProgress');
1237
+ if (active) state.activeTurnId = active.id;
1238
+ return state.thread;
1239
+ }
1240
+
1241
+ async loadedThreads() {
1242
+ await this.open();
1243
+ const result = await this.rpc('thread/loaded/list', {});
1244
+ return Array.isArray(result?.data) ? result.data.filter((id) => typeof id === 'string') : [];
1245
+ }
1246
+
1247
+ async discoverThread() {
1248
+ if (this.currentThreadId) return this.currentThreadId;
1249
+ const loaded = await this.loadedThreads();
1250
+ if (this.lastStartedThreadId && loaded.includes(this.lastStartedThreadId)) {
1251
+ this.currentThreadId = this.lastStartedThreadId;
1252
+ return this.currentThreadId;
1253
+ }
1254
+ if (!loaded.length) return null;
1255
+ const threads = await Promise.all(loaded.map(async (threadId, index) => {
1256
+ try {
1257
+ const result = await this.rpc('thread/read', { threadId, includeTurns: false });
1258
+ const thread = result?.thread || {};
1259
+ return {
1260
+ threadId,
1261
+ order: Number(thread.updatedAt ?? thread.createdAt ?? index),
1262
+ root: thread.parentThreadId == null,
1263
+ known: true,
1264
+ };
1265
+ } catch { return { threadId, order: index, root: false, known: false }; }
1266
+ }));
1267
+ const roots = threads.filter((thread) => thread.root);
1268
+ const candidates = roots.length ? roots : threads.filter((thread) => !thread.known);
1269
+ candidates.sort((a, b) => b.order - a.order);
1270
+ this.currentThreadId = candidates[0]?.threadId || null;
1271
+ return this.currentThreadId;
1272
+ }
1273
+
1274
+ approvalsFor(threadId) {
1275
+ return [...this.approvals.values()]
1276
+ .filter((request) => request.params?.threadId === threadId)
1277
+ .map(normalizeApproval);
1278
+ }
1279
+
1280
+ userInputsFor(threadId) {
1281
+ return [...this.userInputs.values()]
1282
+ .filter((request) => request.params?.threadId === threadId)
1283
+ .map(normalizeUserInput);
1284
+ }
1285
+
1286
+ decide(threadId, requestId, decision) {
1287
+ if (typeof decision !== 'string') throw new Error('unsupported approval decision');
1288
+ const key = String(requestId);
1289
+ const request = this.approvals.get(key);
1290
+ if (!request || request.params?.threadId !== threadId) throw new Error('approval request is no longer pending');
1291
+ const approval = normalizeApproval(request);
1292
+ const resolved = request.method === 'item/permissions/requestApproval'
1293
+ ? (approval.decisions.includes(decision) ? decision : null)
1294
+ : resolveApprovalDecision(request, decision);
1295
+ if (resolved == null) throw new Error('approval decision is unavailable');
1296
+ this.respond(request.id, request.method === 'item/permissions/requestApproval'
1297
+ ? permissionResponse(request, decision)
1298
+ : { decision: resolved });
1299
+ this.approvals.delete(key);
1300
+ this.markWorking(threadId);
1301
+ this.setInbox('working', activeTurnPrompt(this.state(threadId)) || this.inbox.msg, `approval:${key}:resolved`);
1302
+ this.bump(threadId);
1303
+ }
1304
+
1305
+ answerInput(threadId, requestId, answers) {
1306
+ const key = String(requestId);
1307
+ const request = this.userInputs.get(key);
1308
+ if (!request || request.params?.threadId !== threadId) throw new Error('user input request is no longer pending');
1309
+ const input = normalizeUserInput(request);
1310
+ const expected = new Set(input.questions.map((question) => question.id));
1311
+ const normalized = {};
1312
+ for (const [questionId, value] of Object.entries(answers || {})) {
1313
+ if (!expected.has(questionId) || !Array.isArray(value) || value.some((answer) => typeof answer !== 'string')) {
1314
+ throw new Error('bad user input response');
1315
+ }
1316
+ normalized[questionId] = { answers: value };
1317
+ }
1318
+ if ([...expected].some((questionId) => !normalized[questionId]?.answers.length)) {
1319
+ throw new Error('bad user input response');
1320
+ }
1321
+ this.respond(request.id, { answers: normalized });
1322
+ this.userInputs.delete(key);
1323
+ this.markWorking(threadId);
1324
+ this.setInbox('working', activeTurnPrompt(this.state(threadId)) || this.inbox.msg, `input:${key}:resolved`);
1325
+ this.bump(threadId);
1326
+ }
1327
+
1328
+ fail(error) {
1329
+ if (this.closed) return;
1330
+ this.closed = true;
1331
+ try { this.ws?.close(); } catch { /* already failed */ }
1332
+ for (const waiter of this.pending.values()) {
1333
+ clearTimeout(waiter.timer);
1334
+ waiter.reject(asError(error));
1335
+ }
1336
+ this.pending.clear();
1337
+ this.closeStreamListeners();
1338
+ this.onClose(this);
1339
+ }
1340
+
1341
+ close() {
1342
+ if (this.closed) return;
1343
+ this.closed = true;
1344
+ try { this.ws?.close(); } catch { /* already closing */ }
1345
+ for (const waiter of this.pending.values()) {
1346
+ clearTimeout(waiter.timer);
1347
+ waiter.reject(new Error('Codex App Server connection closed'));
1348
+ }
1349
+ this.pending.clear();
1350
+ this.closeStreamListeners();
1351
+ }
1352
+ }
1353
+
1354
+ export function createCodexAppServer({
1355
+ home,
1356
+ connect = connectUnixWebSocket,
1357
+ exists = fs.existsSync,
1358
+ readdir = fs.readdirSync,
1359
+ now = () => Date.now(),
1360
+ onStateChange = () => {},
1361
+ scanIntervalMs = SOCKET_SCAN_MS,
1362
+ setTimer = setInterval,
1363
+ clearTimer = clearInterval,
1364
+ } = {}) {
1365
+ const connections = new Map();
1366
+ const queues = new Map();
1367
+ const submissions = new Map();
1368
+ let queueSequence = 0;
1369
+ let scanTimer = null;
1370
+ let started = false;
1371
+
1372
+ async function connection(pane) {
1373
+ const socketPath = codexAppSocketPath(pane, home);
1374
+ if (!exists(socketPath)) return null;
1375
+ let current = connections.get(pane);
1376
+ if (!current || current.closed) {
1377
+ current = new CodexAppConnection({
1378
+ pane, socketPath, connect, now, baseline: true, onStateChange,
1379
+ queueStore: queues, submissionStore: submissions,
1380
+ nextQueueId: () => `${now().toString(36)}-${(++queueSequence).toString(36)}`,
1381
+ onClose: (closed) => { if (connections.get(pane) === closed) connections.delete(pane); },
1382
+ });
1383
+ connections.set(pane, current);
1384
+ }
1385
+ await current.open();
1386
+ return current;
1387
+ }
1388
+
1389
+ async function observe(pane) {
1390
+ const client = await connection(pane);
1391
+ if (!client) return null;
1392
+ const threadId = await client.discoverThread();
1393
+ if (threadId) {
1394
+ await client.ensureThread(threadId);
1395
+ client.wakeQueue(threadId);
1396
+ }
1397
+ // An empty loaded-thread list is also a complete baseline. A thread started after this observation is
1398
+ // new work and must not inherit suppression merely because there was no snapshot to resume yet.
1399
+ client.baseline = false;
1400
+ return { client, threadId };
1401
+ }
1402
+
1403
+ async function scan() {
1404
+ const dir = codexAppSocketPath('%0', home).replace(/\/0\.sock$/, '');
1405
+ let names = [];
1406
+ try { names = readdir(dir); } catch { return; }
1407
+ await Promise.all(names.filter((name) => /^\d+\.sock$/.test(name)).map((name) => {
1408
+ const pane = `%${name.slice(0, -5)}`;
1409
+ return observe(pane).catch(() => {});
1410
+ }));
1411
+ }
1412
+
1413
+ return {
1414
+ async read(pane, threadId) {
1415
+ const client = await connection(pane);
1416
+ if (!client) return null;
1417
+ return { client, thread: await client.readThread(threadId) };
1418
+ },
1419
+ async discover(pane) {
1420
+ const observed = await observe(pane);
1421
+ if (!observed) return { managed: false, threadId: null };
1422
+ return { managed: true, threadId: observed.threadId };
1423
+ },
1424
+ async inboxStates(livePanes = []) {
1425
+ const out = {};
1426
+ await Promise.all(livePanes.map(async (pane) => {
1427
+ if (!exists(codexAppSocketPath(pane.id, home))) return;
1428
+ try {
1429
+ const observed = await observe(pane.id);
1430
+ out[pane.id] = observed
1431
+ ? { ...observed.client.takeInbox(), threadId: observed.threadId }
1432
+ : { kind: null, msg: '', ts: 0, suppressPush: false, threadId: null, unavailable: true };
1433
+ } catch {
1434
+ // The pane-owned socket still proves managed ownership. Do not revive a stale Hook row merely
1435
+ // because App Server is reconnecting; chat status exposes the connection error separately.
1436
+ out[pane.id] = { kind: null, msg: '', ts: 0, suppressPush: false, threadId: null, unavailable: true };
1437
+ }
1438
+ }));
1439
+ return out;
1440
+ },
1441
+ async status(pane, threadId) {
1442
+ const client = await connection(pane);
1443
+ if (!client) return { managed: false };
1444
+ await client.assertCurrentThread(threadId);
1445
+ const state = await client.ensureThread(threadId);
1446
+ return {
1447
+ managed: true,
1448
+ threadId,
1449
+ gitBranch: state.thread?.gitInfo?.branch || null,
1450
+ status: state.status || state.thread?.status,
1451
+ activeTurnId: state.activeTurnId,
1452
+ settings: state.settings,
1453
+ contextUsage: state.contextUsage,
1454
+ activityKind: client.inbox.kind,
1455
+ lastTurn: state.lastTurn,
1456
+ approvals: client.approvalsFor(threadId),
1457
+ userInputs: client.userInputsFor(threadId),
1458
+ queue: client.queuedFor(threadId),
1459
+ revision: state.revision,
1460
+ };
1461
+ },
1462
+ async subscribe(pane, threadId, listener) {
1463
+ const client = await connection(pane);
1464
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1465
+ await client.assertCurrentThread(threadId);
1466
+ await client.ensureThread(threadId);
1467
+ return client.subscribeStream(threadId, listener);
1468
+ },
1469
+ async send(pane, threadId, text, requestId = null) {
1470
+ const client = await connection(pane);
1471
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1472
+ await client.assertCurrentThread(threadId);
1473
+ return client.submit(threadId, text, requestId);
1474
+ },
1475
+ async steerQueued(pane, threadId, itemId) {
1476
+ const client = await connection(pane);
1477
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1478
+ await client.assertCurrentThread(threadId);
1479
+ return client.steerQueued(threadId, itemId);
1480
+ },
1481
+ async removeQueued(pane, threadId, itemId) {
1482
+ const client = await connection(pane);
1483
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1484
+ await client.assertCurrentThread(threadId);
1485
+ return client.removeQueued(threadId, itemId);
1486
+ },
1487
+ async beginQueuedEdit(pane, threadId, itemId) {
1488
+ const client = await connection(pane);
1489
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1490
+ await client.assertCurrentThread(threadId);
1491
+ return client.beginQueuedEdit(threadId, itemId);
1492
+ },
1493
+ async renewQueuedEdit(pane, threadId, itemId, token) {
1494
+ const client = await connection(pane);
1495
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1496
+ await client.assertCurrentThread(threadId);
1497
+ return client.renewQueuedEdit(threadId, itemId, token);
1498
+ },
1499
+ async commitQueuedEdit(pane, threadId, itemId, token, text) {
1500
+ const client = await connection(pane);
1501
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1502
+ await client.assertCurrentThread(threadId);
1503
+ return client.commitQueuedEdit(threadId, itemId, token, text);
1504
+ },
1505
+ async cancelQueuedEdit(pane, threadId, itemId, token) {
1506
+ const client = await connection(pane);
1507
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1508
+ await client.assertCurrentThread(threadId);
1509
+ return client.cancelQueuedEdit(threadId, itemId, token);
1510
+ },
1511
+ async compact(pane, threadId) {
1512
+ const client = await connection(pane);
1513
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1514
+ await client.assertCurrentThread(threadId);
1515
+ const state = await client.ensureThread(threadId);
1516
+ const result = await client.rpc('thread/compact/start', { threadId });
1517
+ state.status = { type: 'active', activeFlags: [] };
1518
+ client.setInbox('compacting', '', `thread:${threadId}:compacting`);
1519
+ client.bump(threadId);
1520
+ return result;
1521
+ },
1522
+ async models(pane, threadId) {
1523
+ const client = await connection(pane);
1524
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1525
+ await client.assertCurrentThread(threadId);
1526
+ await client.ensureThread(threadId);
1527
+ const data = [];
1528
+ let cursor = null;
1529
+ do {
1530
+ const result = await client.rpc('model/list', cursor ? { cursor } : {});
1531
+ if (Array.isArray(result?.data)) data.push(...result.data);
1532
+ cursor = result?.nextCursor || null;
1533
+ } while (cursor && data.length < 1_000);
1534
+ return data;
1535
+ },
1536
+ async getGoal(pane, threadId) {
1537
+ const client = await connection(pane);
1538
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1539
+ await client.assertCurrentThread(threadId);
1540
+ await client.ensureThread(threadId);
1541
+ const result = await client.rpc('thread/goal/get', { threadId });
1542
+ return result?.goal || null;
1543
+ },
1544
+ async updateGoal(pane, threadId, updates) {
1545
+ const client = await connection(pane);
1546
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1547
+ await client.assertCurrentThread(threadId);
1548
+ await client.ensureThread(threadId);
1549
+ const result = await client.rpc('thread/goal/set', { threadId, ...updates });
1550
+ return result?.goal || null;
1551
+ },
1552
+ async clearGoal(pane, threadId) {
1553
+ const client = await connection(pane);
1554
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1555
+ await client.assertCurrentThread(threadId);
1556
+ await client.ensureThread(threadId);
1557
+ await client.rpc('thread/goal/clear', { threadId });
1558
+ return { cleared: true };
1559
+ },
1560
+ async updateSettings(pane, threadId, updates) {
1561
+ const client = await connection(pane);
1562
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1563
+ await client.assertCurrentThread(threadId);
1564
+ const state = await client.ensureThread(threadId);
1565
+ await client.rpc('thread/settings/update', { threadId, ...updates });
1566
+ state.settings = { ...(state.settings || {}), ...updates };
1567
+ client.bump(threadId);
1568
+ return state.settings;
1569
+ },
1570
+ async interrupt(pane, threadId) {
1571
+ const client = await connection(pane);
1572
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1573
+ await client.assertCurrentThread(threadId);
1574
+ const state = await client.ensureThread(threadId);
1575
+ let turnId = state.activeTurnId;
1576
+ if (!turnId) {
1577
+ const thread = await client.readThread(threadId);
1578
+ turnId = [...(thread.turns || [])].reverse().find((turn) => turn.status === 'inProgress')?.id;
1579
+ }
1580
+ if (!turnId) return { interrupted: false };
1581
+ await client.rpc('turn/interrupt', { threadId, turnId });
1582
+ return { interrupted: true, turnId };
1583
+ },
1584
+ async decide(pane, threadId, requestId, decision) {
1585
+ const client = await connection(pane);
1586
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1587
+ await client.assertCurrentThread(threadId);
1588
+ await client.ensureThread(threadId);
1589
+ client.decide(threadId, requestId, decision);
1590
+ return { ok: true };
1591
+ },
1592
+ async answerInput(pane, threadId, requestId, answers) {
1593
+ const client = await connection(pane);
1594
+ if (!client) throw new Error('Codex session is not managed by Handmux');
1595
+ await client.assertCurrentThread(threadId);
1596
+ await client.ensureThread(threadId);
1597
+ client.answerInput(threadId, requestId, answers);
1598
+ return { ok: true };
1599
+ },
1600
+ start() {
1601
+ if (started) return;
1602
+ started = true;
1603
+ scan().catch(() => {});
1604
+ scanTimer = setTimer(() => scan().catch(() => {}), scanIntervalMs);
1605
+ scanTimer?.unref?.();
1606
+ },
1607
+ close() {
1608
+ started = false;
1609
+ if (scanTimer) clearTimer(scanTimer);
1610
+ scanTimer = null;
1611
+ for (const client of connections.values()) client.close();
1612
+ connections.clear();
1613
+ for (const queue of queues.values()) {
1614
+ if (queue.editTimer) clearTimeout(queue.editTimer);
1615
+ }
1616
+ queues.clear();
1617
+ submissions.clear();
1618
+ },
1619
+ };
1620
+ }