polygram 0.21.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 (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +5 -3
  3. package/config.example.json +23 -1
  4. package/lib/codex/binary.js +287 -0
  5. package/lib/codex/delivery-finalizer.js +97 -0
  6. package/lib/codex/diagnostics.js +483 -0
  7. package/lib/codex/event-normalizer.js +50 -0
  8. package/lib/codex/host-identity.js +103 -0
  9. package/lib/codex/model-catalog.js +359 -0
  10. package/lib/codex/retention-scheduler.js +59 -0
  11. package/lib/codex/runtime-controller.js +1292 -0
  12. package/lib/codex/runtime-profile.js +888 -0
  13. package/lib/codex/spawn-context.js +87 -0
  14. package/lib/db/auto-resume.js +77 -9
  15. package/lib/db/codex-reconciliation.js +490 -0
  16. package/lib/db/codex-retention.js +139 -0
  17. package/lib/db/sessions.js +102 -1
  18. package/lib/db.js +3418 -19
  19. package/lib/error/classify.js +140 -0
  20. package/lib/handlers/abort.js +143 -5
  21. package/lib/handlers/autosteer.js +256 -1
  22. package/lib/handlers/codex-reconciliation.js +230 -0
  23. package/lib/handlers/config-callback.js +324 -89
  24. package/lib/handlers/config-ui.js +401 -39
  25. package/lib/handlers/dispatcher.js +71 -8
  26. package/lib/handlers/edit-correction.js +11 -1
  27. package/lib/handlers/edit-redelivery.js +230 -4
  28. package/lib/handlers/gate-inbound.js +7 -0
  29. package/lib/handlers/replay-disposition.js +133 -11
  30. package/lib/handlers/slash-commands.js +443 -37
  31. package/lib/ipc/client.js +22 -12
  32. package/lib/ipc/runtime-directory.js +208 -0
  33. package/lib/ipc/server.js +18 -12
  34. package/lib/model-costs.js +4 -3
  35. package/lib/pm-interface.js +33 -0
  36. package/lib/prompt.js +15 -9
  37. package/lib/rewind/rewind.js +21 -1
  38. package/lib/runtime-config.js +605 -0
  39. package/lib/sdk/build-options.js +4 -1
  40. package/lib/sdk/callbacks.js +46 -3
  41. package/lib/util/claude-session-jsonl.js +3 -2
  42. package/migrations/015-agent-runtime-sessions.sql +50 -0
  43. package/migrations/016-agent-turn-attempts.sql +211 -0
  44. package/migrations/017-config-runtime-changes.sql +46 -0
  45. package/ops/README.md +57 -0
  46. package/package.json +2 -2
  47. package/polygram.js +697 -48
  48. package/scripts/doctor.js +37 -8
@@ -19,6 +19,160 @@
19
19
  const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
20
20
  const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
21
21
 
22
+ function isCodexRuntimeView(runtimeView) {
23
+ return runtimeView?.runtime === 'codex';
24
+ }
25
+
26
+ function uniqueStrings(values) {
27
+ if (!Array.isArray(values)) return [];
28
+ return [...new Set(values.filter(
29
+ (value) => typeof value === 'string' && value.length > 0,
30
+ ))];
31
+ }
32
+
33
+ function getCodexCatalogModels(runtimeView) {
34
+ if (!isCodexRuntimeView(runtimeView) || !Array.isArray(runtimeView.models)) {
35
+ return [];
36
+ }
37
+ const seen = new Set();
38
+ return runtimeView.models.filter((entry) => {
39
+ if (
40
+ !entry
41
+ || typeof entry.model !== 'string'
42
+ || entry.model.length === 0
43
+ || seen.has(entry.model)
44
+ ) {
45
+ return false;
46
+ }
47
+ seen.add(entry.model);
48
+ return true;
49
+ });
50
+ }
51
+
52
+ function getCodexCatalogEfforts(runtimeView, model = runtimeView?.model) {
53
+ if (!isCodexRuntimeView(runtimeView)) return [];
54
+ const catalogEfforts = uniqueStrings(runtimeView.efforts);
55
+ const modelEntry = getCodexCatalogModels(runtimeView)
56
+ .find((entry) => entry.model === model);
57
+ const modelEfforts = uniqueStrings(modelEntry?.supportedReasoningEfforts);
58
+ if (modelEntry) return modelEfforts;
59
+ return model === runtimeView.model ? catalogEfforts : [];
60
+ }
61
+
62
+ function resolveCodexEffortForModel(runtimeView, model, currentEffort) {
63
+ const modelEntry = getCodexCatalogModels(runtimeView)
64
+ .find((entry) => entry.model === model);
65
+ if (!modelEntry) return null;
66
+ const supported = uniqueStrings(modelEntry.supportedReasoningEfforts);
67
+ if (supported.includes(currentEffort)) return currentEffort;
68
+ return supported.includes(modelEntry.defaultReasoningEffort)
69
+ ? modelEntry.defaultReasoningEffort
70
+ : null;
71
+ }
72
+
73
+ function formatSettingsPair(settings) {
74
+ if (
75
+ !settings
76
+ || typeof settings.model !== 'string'
77
+ || typeof settings.effort !== 'string'
78
+ ) {
79
+ return null;
80
+ }
81
+ return `${settings.model}/${settings.effort}`;
82
+ }
83
+
84
+ function sameSettingsPair(left, right) {
85
+ return (
86
+ left?.model === right?.model
87
+ && left?.effort === right?.effort
88
+ );
89
+ }
90
+
91
+ function formatCodexSettingsOutcome(result) {
92
+ const next = formatSettingsPair(result?.nextTurn);
93
+ switch (result?.outcome) {
94
+ case 'updated-live': {
95
+ const current = formatSettingsPair(result.currentTurn);
96
+ return current
97
+ ? ` — current turn ${current} unchanged; next turn ${next}`
98
+ : ' — selected for next turn';
99
+ }
100
+ case 'not-loaded':
101
+ return " — selected for this chat's next session";
102
+ case 'daemon-busy':
103
+ return ' — selected; this chat is not loaded; its next message may be busy';
104
+ case 'unavailable':
105
+ return ' — selected durably, but the current process did not accept it'
106
+ + (result.reason ? ` (${result.reason})` : '');
107
+ case 'live-status-unknown':
108
+ return ' — selected durably; live status is unknown';
109
+ default:
110
+ return ' — selected durably; live status is unknown';
111
+ }
112
+ }
113
+
114
+ function withCodexSettingsOutcome(runtimeView, result) {
115
+ if (!isCodexRuntimeView(runtimeView)) return runtimeView;
116
+ const desiredSettings = result?.nextTurn ?? runtimeView.desiredSettings ?? {
117
+ model: runtimeView.model,
118
+ effort: runtimeView.effort,
119
+ };
120
+ let processStatus = runtimeView.processStatus;
121
+ let activeTurnSettings = runtimeView.activeTurnSettings ?? null;
122
+ let nextTurnSettings = runtimeView.nextTurnSettings ?? null;
123
+ let unavailableReason = runtimeView.unavailableReason ?? null;
124
+ switch (result?.outcome) {
125
+ case 'updated-live':
126
+ processStatus = 'loaded';
127
+ activeTurnSettings = result.currentTurn ?? null;
128
+ nextTurnSettings = result.nextTurn ?? null;
129
+ unavailableReason = null;
130
+ break;
131
+ case 'not-loaded':
132
+ case 'daemon-busy':
133
+ processStatus = result.outcome;
134
+ activeTurnSettings = null;
135
+ nextTurnSettings = null;
136
+ break;
137
+ case 'unavailable':
138
+ processStatus = 'unavailable';
139
+ unavailableReason = result.reason ?? null;
140
+ break;
141
+ case 'live-status-unknown':
142
+ processStatus = 'unknown';
143
+ break;
144
+ default:
145
+ break;
146
+ }
147
+ return Object.freeze({
148
+ ...runtimeView,
149
+ model: desiredSettings.model,
150
+ effort: desiredSettings.effort,
151
+ desiredSettings: Object.freeze({ ...desiredSettings }),
152
+ activeTurnSettings,
153
+ nextTurnSettings,
154
+ processStatus,
155
+ unavailableReason,
156
+ });
157
+ }
158
+
159
+ function configuredRuntimeValue(
160
+ chatConfig,
161
+ topicConfig,
162
+ runtimeView,
163
+ setting,
164
+ ) {
165
+ const codex = isCodexRuntimeView(runtimeView);
166
+ const field = codex
167
+ ? setting === 'model' ? 'codexModel' : 'codexEffort'
168
+ : setting;
169
+ return (
170
+ (topicConfig && topicConfig[field])
171
+ || chatConfig[field]
172
+ || (codex ? runtimeView[setting] : null)
173
+ );
174
+ }
175
+
22
176
  // Runtime callers pass the value resolved from the full config hierarchy.
23
177
  // Direct callers may omit it and retain the local topic/chat behavior.
24
178
  function resolveDisplayRichText(chatConfig, topicConfig, effectiveRichText) {
@@ -33,7 +187,7 @@ function resolveDisplayRichText(chatConfig, topicConfig, effectiveRichText) {
33
187
  // polygram passes the alias (opus / sonnet / haiku) and lets claude
34
188
  // resolve. Bump on Claude release.
35
189
  const MODEL_VERSIONS_DESC = {
36
- opus: 'claude-opus-4-8',
190
+ opus: 'claude-opus-5',
37
191
  sonnet: 'claude-sonnet-4-6',
38
192
  haiku: 'claude-haiku-4-5',
39
193
  };
@@ -45,18 +199,47 @@ const MODEL_VERSIONS_DESC = {
45
199
  * null for the chat-level card) wins over chatConfig so the ✓ matches what a
46
200
  * topic actually runs — mirrors the spawn-path precedence (topic > chat).
47
201
  */
48
- function buildConfigKeyboard(chatConfig, show = 'all', topicConfig = null, effectiveRichText) {
49
- const model = (topicConfig && topicConfig.model) || chatConfig.model;
50
- const effort = (topicConfig && topicConfig.effort) || chatConfig.effort;
202
+ function buildConfigKeyboard(
203
+ chatConfig,
204
+ show = 'all',
205
+ topicConfig = null,
206
+ effectiveRichText,
207
+ runtimeView = null,
208
+ ) {
209
+ const codex = isCodexRuntimeView(runtimeView);
210
+ const model = configuredRuntimeValue(
211
+ chatConfig,
212
+ topicConfig,
213
+ runtimeView,
214
+ 'model',
215
+ );
216
+ const effort = configuredRuntimeValue(
217
+ chatConfig,
218
+ topicConfig,
219
+ runtimeView,
220
+ 'effort',
221
+ );
222
+ const modelOptions = codex
223
+ ? getCodexCatalogModels(runtimeView)
224
+ : MODEL_OPTIONS.map((value) => ({ model: value, displayName: value }));
225
+ const effortOptions = codex
226
+ ? getCodexCatalogEfforts(runtimeView, model)
227
+ : EFFORT_OPTIONS;
51
228
  const rows = [];
52
229
  if (show === 'model' || show === 'all') {
53
- rows.push(MODEL_OPTIONS.map((m) => ({
54
- text: m === model ? `✓ ${m}` : m,
55
- callback_data: `cfg:model:${m}`,
56
- })));
230
+ rows.push(modelOptions.map((entry) => {
231
+ const label = (
232
+ typeof entry.displayName === 'string'
233
+ && entry.displayName.length > 0
234
+ ) ? entry.displayName : entry.model;
235
+ return {
236
+ text: entry.model === model ? `✓ ${label}` : label,
237
+ callback_data: `cfg:model:${entry.model}`,
238
+ };
239
+ }));
57
240
  }
58
241
  if (show === 'effort' || show === 'all') {
59
- rows.push(EFFORT_OPTIONS.map((e) => ({
242
+ rows.push(effortOptions.map((e) => ({
60
243
  text: e === effort ? `✓ ${e}` : e,
61
244
  callback_data: `cfg:effort:${e}`,
62
245
  })));
@@ -82,9 +265,22 @@ function buildConfigKeyboard(chatConfig, show = 'all', topicConfig = null, effec
82
265
  * @param {object} deps.db
83
266
  * @param {(db, sessionKey) => string|null} deps.getClaudeSessionId
84
267
  */
85
- function createFormatConfigInfoText({ pm, db, getClaudeSessionId } = {}) {
86
- return function formatConfigInfoText(chatConfig, show, sessionKey, topicConfig = null, effectiveRichText) {
268
+ function createFormatConfigInfoText({
269
+ pm,
270
+ db,
271
+ getClaudeSessionId,
272
+ resolveRuntimeView = null,
273
+ } = {}) {
274
+ function formatWithRuntimeView(
275
+ chatConfig,
276
+ show,
277
+ sessionKey,
278
+ topicConfig,
279
+ effectiveRichText,
280
+ runtimeView,
281
+ ) {
87
282
  const alive = pm.has(sessionKey) && !pm.get(sessionKey).closed;
283
+ const codex = isCodexRuntimeView(runtimeView);
88
284
  // Per-topic overrides win over chat-level for the displayed values,
89
285
  // mirroring the spawn path (polygram.js: topicConfig.agent ||
90
286
  // chatConfig.agent). Pre-fix the card always read chat-level, so a topic's
@@ -92,11 +288,36 @@ function createFormatConfigInfoText({ pm, db, getClaudeSessionId } = {}) {
92
288
  // "Agent: shumabit" instead of its music-curation:music-curator override
93
289
  // (2026-06-03). topicConfig defaults to null (chat-level) for callers with
94
290
  // no active topic.
95
- const model = (topicConfig && topicConfig.model) || chatConfig.model;
96
- const effort = (topicConfig && topicConfig.effort) || chatConfig.effort;
291
+ const model = configuredRuntimeValue(
292
+ chatConfig,
293
+ topicConfig,
294
+ runtimeView,
295
+ 'model',
296
+ );
297
+ const effort = configuredRuntimeValue(
298
+ chatConfig,
299
+ topicConfig,
300
+ runtimeView,
301
+ 'effort',
302
+ );
97
303
  const agent = (topicConfig && topicConfig.agent) || chatConfig.agent;
98
- const ver = MODEL_VERSIONS_DESC[model] || model;
99
- const sess = getClaudeSessionId(db, sessionKey)?.slice(0, 8) || 'new';
304
+ const codexModelEntry = codex
305
+ ? getCodexCatalogModels(runtimeView)
306
+ .find((entry) => entry.model === model)
307
+ : null;
308
+ const codexDisplayName = codexModelEntry?.displayName;
309
+ const ver = codex
310
+ ? (
311
+ typeof codexDisplayName === 'string'
312
+ && codexDisplayName.length > 0
313
+ && codexDisplayName !== model
314
+ ? codexDisplayName
315
+ : model
316
+ )
317
+ : MODEL_VERSIONS_DESC[model] || model;
318
+ const sess = codex
319
+ ? 'managed by Codex'
320
+ : getClaudeSessionId(db, sessionKey)?.slice(0, 8) || 'new';
100
321
  // Running vs configured: cli can't hot-swap model/effort, so a /model or
101
322
  // /effort change is PENDING until the session reloads (on the next message).
102
323
  // Show the truth — the live proc's spawn-time value (proc.model/proc.effort)
@@ -119,35 +340,169 @@ function createFormatConfigInfoText({ pm, db, getClaudeSessionId } = {}) {
119
340
  const richTextLine = richText
120
341
  ? 'Rich text: on (headings/tables/checklists on qualifying replies; agent authors for it once this session next (re)spawns)'
121
342
  : 'Rich text: off';
122
- const head =
123
- `${modelLine}\n` +
124
- `${effortLine}\n` +
125
- `${richTextLine}\n` +
126
- `Agent: ${agent}\n` +
127
- `Process: ${alive ? 'warm' : 'cold'}\n` +
128
- `Session: ${sess}`;
129
-
130
- const modelHelp = [
131
- '',
132
- '**Models**',
133
- '🧠 **opus** — deep analysis, code refactor, multi-source reconciliation. ~5× sonnet cost.',
134
- '🤖 **sonnet** — default. Most ops, code review, document summary.',
135
- '⚡ **haiku** — quick simple tasks, classification, lookup.',
136
- ].join('\n');
137
-
138
- const effortHelp = [
139
- '',
140
- '**Effort** — ceiling on how much Claude can think. Simple questions get fast replies; hard ones spend more tokens. Safe to set higher — Claude scales down automatically when it doesn\'t need to think.',
141
- '• **low** — fast replies, minimum reasoning. Casual chat, simple lookups.',
142
- '• **medium** — balanced default. Fits most use cases.',
143
- '• **high** — multi-step tasks. Audit, debug, multi-source analysis.',
144
- '• **xhigh** / **max** — heaviest. Hard reasoning, edge cases.',
145
- ].join('\n');
343
+ const identityLine = codex
344
+ ? 'Runtime: Codex app-server'
345
+ : `Agent: ${agent}`;
346
+ let head;
347
+ if (codex) {
348
+ const desired = runtimeView.desiredSettings ?? { model, effort };
349
+ const desiredPair = formatSettingsPair(desired) ?? `${model}/${effort}`;
350
+ const activePair = formatSettingsPair(runtimeView.activeTurnSettings);
351
+ const nextTurn = runtimeView.nextTurnSettings;
352
+ const nextTurnPair = formatSettingsPair(nextTurn);
353
+ const observedPair = formatSettingsPair(
354
+ runtimeView.observedThreadSettings,
355
+ );
356
+ const processStatus = runtimeView.processStatus
357
+ ?? (alive ? 'loaded' : 'not-loaded');
358
+ const settingsLines = [];
359
+ if (processStatus === 'loaded') {
360
+ if (activePair) {
361
+ settingsLines.push(`Current turn: ${activePair}`);
362
+ settingsLines.push(`Next turn: ${nextTurnPair ?? desiredPair}`);
363
+ } else {
364
+ settingsLines.push(
365
+ `Selected for next turn: ${nextTurnPair ?? desiredPair}`,
366
+ );
367
+ }
368
+ if (
369
+ nextTurnPair
370
+ && !sameSettingsPair(nextTurn, desired)
371
+ ) {
372
+ settingsLines.push(
373
+ `Saved selection awaiting live reconciliation: ${desiredPair}`,
374
+ );
375
+ }
376
+ } else if (processStatus === 'not-loaded') {
377
+ settingsLines.push(
378
+ `Selected for this chat's next session: ${desiredPair}`,
379
+ );
380
+ } else {
381
+ settingsLines.push(`Selected: ${desiredPair}`);
382
+ }
383
+ if (observedPair) {
384
+ settingsLines.push(`Observed thread: ${observedPair}`);
385
+ }
386
+ let processLine;
387
+ if (processStatus === 'daemon-busy') {
388
+ processLine = 'Process: not loaded; its next message may be busy';
389
+ } else if (processStatus === 'unavailable') {
390
+ processLine = 'Process: unavailable'
391
+ + (
392
+ runtimeView.unavailableReason
393
+ ? ` (${runtimeView.unavailableReason})`
394
+ : ''
395
+ );
396
+ } else if (processStatus === 'unknown') {
397
+ processLine = 'Process: live status unknown';
398
+ } else {
399
+ processLine = `Process: ${
400
+ processStatus === 'loaded' ? 'warm' : 'not loaded'
401
+ }`;
402
+ }
403
+ head = [
404
+ ...settingsLines,
405
+ richTextLine,
406
+ identityLine,
407
+ processLine,
408
+ `Session: ${sess}`,
409
+ ].join('\n');
410
+ } else {
411
+ head =
412
+ `${modelLine}\n` +
413
+ `${effortLine}\n` +
414
+ `${richTextLine}\n` +
415
+ `${identityLine}\n` +
416
+ `Process: ${alive ? 'warm' : 'cold'}\n` +
417
+ `Session: ${sess}`;
418
+ }
419
+
420
+ const modelHelp = codex
421
+ ? [
422
+ '',
423
+ '**Models**',
424
+ ...getCodexCatalogModels(runtimeView).map((entry) => {
425
+ const displayName = (
426
+ typeof entry.displayName === 'string'
427
+ && entry.displayName.length > 0
428
+ && entry.displayName !== entry.model
429
+ ) ? ` — ${entry.displayName}` : '';
430
+ return `• **${entry.model}**${displayName}`;
431
+ }),
432
+ ].join('\n')
433
+ : [
434
+ '',
435
+ '**Models**',
436
+ '🧠 **opus** — deep analysis, code refactor, multi-source reconciliation. ~1.7× sonnet cost.',
437
+ '🤖 **sonnet** — default. Most ops, code review, document summary.',
438
+ '⚡ **haiku** — quick simple tasks, classification, lookup.',
439
+ ].join('\n');
440
+
441
+ const effortHelp = codex
442
+ ? [
443
+ '',
444
+ `**Effort** — authenticated options for ${model}:`,
445
+ ...getCodexCatalogEfforts(runtimeView, model)
446
+ .map((value) => `• **${value}**`),
447
+ 'Model and effort changes apply to the next turn; an active turn is unchanged.',
448
+ ].join('\n')
449
+ : [
450
+ '',
451
+ '**Effort** — ceiling on how much Claude can think. Simple questions get fast replies; hard ones spend more tokens. Safe to set higher — Claude scales down automatically when it doesn\'t need to think.',
452
+ '• **low** — fast replies, minimum reasoning. Casual chat, simple lookups.',
453
+ '• **medium** — balanced default. Fits most use cases.',
454
+ '• **high** — multi-step tasks. Audit, debug, multi-source analysis.',
455
+ '• **xhigh** / **max** — heaviest. Hard reasoning, edge cases.',
456
+ ].join('\n');
146
457
 
147
458
  let body = head;
459
+ if (codex) {
460
+ body += '\n\nNative macOS beta: command network and web search are disabled. '
461
+ + 'Product MCP tools and interactive approvals are unavailable. '
462
+ + 'Detached/background servers are unsupported and may survive hard runtime loss.';
463
+ }
148
464
  if (show === 'model' || show === 'all') body += '\n' + modelHelp;
149
465
  if (show === 'effort' || show === 'all') body += '\n' + effortHelp;
150
466
  return body;
467
+ }
468
+
469
+ return function formatConfigInfoText(
470
+ chatConfig,
471
+ show,
472
+ sessionKey,
473
+ topicConfig = null,
474
+ effectiveRichText,
475
+ runtimeView = null,
476
+ ) {
477
+ if (typeof resolveRuntimeView !== 'function' || runtimeView != null) {
478
+ return formatWithRuntimeView(
479
+ chatConfig,
480
+ show,
481
+ sessionKey,
482
+ topicConfig,
483
+ effectiveRichText,
484
+ runtimeView,
485
+ );
486
+ }
487
+ const separator = String(sessionKey).indexOf(':');
488
+ const chatId = separator < 0
489
+ ? String(sessionKey)
490
+ : String(sessionKey).slice(0, separator);
491
+ const threadId = separator < 0
492
+ ? null
493
+ : String(sessionKey).slice(separator + 1) || null;
494
+ return Promise.resolve(resolveRuntimeView({
495
+ sessionKey,
496
+ chatId,
497
+ threadId,
498
+ })).then((resolvedView) => formatWithRuntimeView(
499
+ chatConfig,
500
+ show,
501
+ sessionKey,
502
+ topicConfig,
503
+ effectiveRichText,
504
+ resolvedView,
505
+ ));
151
506
  };
152
507
  }
153
508
 
@@ -157,4 +512,11 @@ module.exports = {
157
512
  MODEL_OPTIONS,
158
513
  EFFORT_OPTIONS,
159
514
  MODEL_VERSIONS_DESC,
515
+ configuredRuntimeValue,
516
+ getCodexCatalogEfforts,
517
+ getCodexCatalogModels,
518
+ isCodexRuntimeView,
519
+ formatCodexSettingsOutcome,
520
+ resolveCodexEffortForModel,
521
+ withCodexSettingsOutcome,
160
522
  };
@@ -23,6 +23,7 @@
23
23
  'use strict';
24
24
 
25
25
  const { createAuthDisabledGate } = require('../ops/auth-disabled-gate');
26
+ const { normalizeFinalText } = require('../codex/event-normalizer');
26
27
 
27
28
  const CONCURRENT_WARN_THRESHOLD_DEFAULT = 20;
28
29
 
@@ -43,6 +44,7 @@ function createDispatcher({
43
44
  // Closures that polygram.js owns; passed in:
44
45
  handleMessage, // async (sessionKey, chatId, msg, bot)
45
46
  sendToProcess, // async (sessionKey, prompt, ctx)
47
+ recoverCodex, // async ({ sessionKey, chatId, msg, bot, providerRecovery })
46
48
  // Cross-cutting helpers:
47
49
  classifyError, // (err) → { kind, userMessage, isTransient, autoRecover }
48
50
  isAutoResumable, // ({ error, aborted, replay, shuttingDown }) → boolean
@@ -90,20 +92,52 @@ function createDispatcher({
90
92
  // already torn down the wedged subprocess; getOrSpawnForChat creates
91
93
  // a new entry that picks up the saved session_id and sets
92
94
  // `--resume <id>` on the SDK Options.
93
- async function attemptAutoResume(sessionKey, chatId, originalMsg, bot) {
95
+ async function attemptAutoResume(
96
+ sessionKey,
97
+ chatId,
98
+ originalMsg,
99
+ bot,
100
+ { provider = 'claude', providerRecovery = null } = {},
101
+ ) {
94
102
  const threadId = originalMsg.message_thread_id || null;
103
+ const isCodex = provider === 'codex';
95
104
 
96
105
  // 1. Tell the user we're auto-resuming so they don't think
97
106
  // nothing happened. Threaded under their original message.
98
107
  await tg(bot, 'sendMessage', {
99
108
  chat_id: chatId,
100
- text: '🔁 Auto-resuming after timeout — continuing where the previous turn left off.',
109
+ text: isCodex
110
+ ? '🔁 Retrying because the Codex request was proven not sent.'
111
+ : '🔁 Auto-resuming after timeout — continuing where the previous turn left off.',
101
112
  reply_parameters: { message_id: originalMsg.message_id },
102
113
  ...(threadId && { message_thread_id: threadId }),
103
114
  }, { source: 'auto-resume-indicator', botName }).catch((sendErr) => {
104
115
  logger.error?.(`[${sessionKey}] auto-resume indicator send failed: ${sendErr.message}`);
105
116
  });
106
117
 
118
+ if (isCodex) {
119
+ if (typeof recoverCodex !== 'function') {
120
+ const error = new Error('Exact Codex recovery path is unavailable');
121
+ error.code = 'CODEX_RECOVERY_UNAVAILABLE';
122
+ throw error;
123
+ }
124
+ const recovered = await recoverCodex({
125
+ sessionKey,
126
+ chatId,
127
+ msg: originalMsg,
128
+ bot,
129
+ providerRecovery,
130
+ });
131
+ if (recovered?.ok === false) {
132
+ const error = new Error(
133
+ recovered.reason || 'Exact Codex recovery was not dispatched',
134
+ );
135
+ error.code = 'CODEX_RECOVERY_NOT_DISPATCHED';
136
+ throw error;
137
+ }
138
+ return recovered;
139
+ }
140
+
107
141
  // 2. Continuation prompt. Plain text — the SDK Query resumes
108
142
  // the saved session_id, so Claude has full prior transcript
109
143
  // context including its own partially-streamed text and
@@ -139,7 +173,8 @@ function createDispatcher({
139
173
  if (result?.error) {
140
174
  throw new Error(`auto-resume turn errored: ${String(result.error).slice(0, 200)}`);
141
175
  }
142
- if (!result?.text) {
176
+ const resultText = normalizeFinalText(result);
177
+ if (!resultText) {
143
178
  throw new Error('auto-resume turn produced no text');
144
179
  }
145
180
 
@@ -154,14 +189,14 @@ function createDispatcher({
154
189
  if (result.alreadyDelivered) {
155
190
  logEvent('auto-resume-already-delivered', {
156
191
  chat_id: chatId, session_key: sessionKey, msg_id: originalMsg.message_id,
157
- text_len: result.text.length,
192
+ text_len: resultText.length,
158
193
  });
159
- return result.text;
194
+ return resultText;
160
195
  }
161
196
 
162
197
  // Send the continuation reply as regular Telegram messages, threaded under
163
198
  // the original user message.
164
- const chunks = chunkMarkdownText(result.text, chunkBudget);
199
+ const chunks = chunkMarkdownText(resultText, chunkBudget);
165
200
  await deliverReplies({
166
201
  bot,
167
202
  send: (b, method, params, m) => tg(b, method, params, m),
@@ -173,7 +208,7 @@ function createDispatcher({
173
208
  logger: { error: (m) => logger.error?.(`[${sessionKey}] auto-resume deliver: ${m}`) },
174
209
  });
175
210
 
176
- return result.text;
211
+ return resultText;
177
212
  }
178
213
 
179
214
  function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
@@ -323,6 +358,29 @@ function createDispatcher({
323
358
  // - shutting down ("Process killed" isn't a real error),
324
359
  // - user just /stop'd (already saw their abort ack).
325
360
  if (!wasAborted && !isReplay && !isShuttingDown) {
361
+ let providerRecovery = null;
362
+ if (typeof db.getReplayProviderRecovery === 'function') {
363
+ try {
364
+ providerRecovery = db.getReplayProviderRecovery({
365
+ sessionKey,
366
+ botName,
367
+ telegramChatId: String(chatId),
368
+ telegramMessageId: String(msg.message_id),
369
+ });
370
+ } catch (recoveryError) {
371
+ providerRecovery = {
372
+ provider: 'unknown',
373
+ reason: 'provider-recovery-unavailable',
374
+ };
375
+ logEvent('provider-recovery-unavailable', {
376
+ chat_id: chatId,
377
+ session_key: sessionKey,
378
+ msg_id: msg.message_id,
379
+ code: recoveryError?.code || recoveryError?.name || 'unknown',
380
+ });
381
+ }
382
+ }
383
+ const provider = providerRecovery?.provider;
326
384
  // Startup auto-retry (option a, 2026-06-04). TMUX_SESSION_GONE = claude
327
385
  // exited INSIDE the startup gate, before the dev-channels channel went
328
386
  // live — so the user's message was NEVER delivered to claude. That makes
@@ -360,6 +418,8 @@ function createDispatcher({
360
418
  const resumable = err.code !== 'SESSION_PROCESS_LOST'
361
419
  && isAutoResumable({
362
420
  error: err, aborted: wasAborted, replay: isReplay, shuttingDown: isShuttingDown,
421
+ provider,
422
+ providerRecovery,
363
423
  });
364
424
  if (resumable && !autoResumeTracker.isInCooldown(sessionKey)) {
365
425
  autoResumeTracker.markAttempt(sessionKey);
@@ -367,7 +427,10 @@ function createDispatcher({
367
427
  chat_id: chatId, session_key: sessionKey, msg_id: msg.message_id,
368
428
  original_error: err.message?.slice(0, 200),
369
429
  });
370
- attemptAutoResume(sessionKey, chatId, msg, bot)
430
+ attemptAutoResume(sessionKey, chatId, msg, bot, {
431
+ provider,
432
+ providerRecovery,
433
+ })
371
434
  .then(() => {
372
435
  logEvent('auto-resume-success', {
373
436
  chat_id: chatId, session_key: sessionKey, msg_id: msg.message_id,
@@ -50,7 +50,17 @@ function createEditCorrectionInjector({
50
50
  // 2. session has a turn in flight
51
51
  // 3. the edited msg is still in the dispatched/processing pipeline
52
52
  if (!pm.has(sessionKey)) return false;
53
- if (!pm.get(sessionKey)?.inFlight) return false;
53
+ const proc = pm.get(sessionKey);
54
+ if (!proc?.inFlight) return false;
55
+ const backend = typeof pm.getBackend === 'function'
56
+ ? pm.getBackend(sessionKey)
57
+ : proc.backend ?? proc.runtime;
58
+ // Codex steering is asynchronous and durability-aware. Its edit path
59
+ // defers a normal visible turn until the current generation is idle; this
60
+ // synchronous Claude-only injector cannot establish that ownership proof.
61
+ if (backend === 'codex' || proc.backend === 'codex' || proc.runtime === 'codex') {
62
+ return false;
63
+ }
54
64
  if (!db.isInboundLive({ chat_id: chatId, msg_id: editedMsg.message_id })) return false;
55
65
 
56
66
  const newText = editedMsg.text || editedMsg.caption || '';