polygram 0.22.0 → 0.23.1

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 (47) 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/migrations/015-agent-runtime-sessions.sql +50 -0
  42. package/migrations/016-agent-turn-attempts.sql +211 -0
  43. package/migrations/017-config-runtime-changes.sql +46 -0
  44. package/ops/README.md +57 -0
  45. package/package.json +2 -2
  46. package/polygram.js +697 -48
  47. package/scripts/doctor.js +37 -8
package/polygram.js CHANGED
@@ -25,7 +25,8 @@ const path = require('path');
25
25
  const processGuard = require('@shumkov/orchestra').processGuard;
26
26
  const dbClient = require('./lib/db');
27
27
  const {
28
- migrateJsonToDb, getClaudeSessionId, resolveSessionForSpawn,
28
+ migrateJsonToDb, getClaudeSessionId, resolveProviderSessionForSpawn,
29
+ resolveSessionForSpawn,
29
30
  } = require('./lib/db/sessions');
30
31
  const { buildPrompt, resolvePromptBackend } = require('./lib/prompt');
31
32
  const { filterAttachments, resolveFileCaps, resolveMaxFileOverride, MAX_TOTAL_BYTES } = require('./lib/attachments');
@@ -57,6 +58,18 @@ const { sweepTmuxOrphans } = require('@shumkov/orchestra').orphanSweep;
57
58
  const { createAutosteeredRefs } = require('./lib/autosteered-refs');
58
59
  const { createBuildSdkOptions } = require('./lib/sdk/build-options');
59
60
  const { createSdkCallbacks } = require('./lib/sdk/callbacks');
61
+ const {
62
+ createCodexRuntimeController,
63
+ } = require('./lib/codex/runtime-controller');
64
+ const {
65
+ buildCodexSpawnContext,
66
+ } = require('./lib/codex/spawn-context');
67
+ const {
68
+ scheduleCodexRetention,
69
+ } = require('./lib/codex/retention-scheduler');
70
+ const {
71
+ createTelegramDeliveryFinalizer,
72
+ } = require('./lib/codex/delivery-finalizer');
60
73
  const { createQuestionStore } = require('./lib/questions/store');
61
74
  const { createQuestionHandlers } = require('./lib/handlers/questions');
62
75
  const { isRewindCommand, createRewindHandler } = require('./lib/rewind/rewind');
@@ -75,6 +88,10 @@ const { classifyReplay, executeReplayPlan } = require('./lib/handlers/replay-dis
75
88
  const { createDropRedeliverer } = require('./lib/handlers/drop-redeliver');
76
89
  const { createSessionFeedback } = require('./lib/feedback/session-feedback');
77
90
  const { createSlashCommands } = require('./lib/handlers/slash-commands');
91
+ const {
92
+ buildCodexReconciliationView,
93
+ createHandleCodexReconciliationCallback,
94
+ } = require('./lib/handlers/codex-reconciliation');
78
95
  const { createApprovals } = require('./lib/handlers/approvals');
79
96
  const { canonicalizeToolInput } = require('./lib/canonical-json');
80
97
  const {
@@ -480,12 +497,88 @@ async function clearAutosteeredReactions(sessionKey) {
480
497
  // once the runtime context (config, BOT_NAME, makeCanUseTool, logEvent)
481
498
  // is available.
482
499
  let buildSdkOptions = null;
500
+ let codexRuntimeController = null;
501
+
502
+ async function resolveSessionRuntimeView({
503
+ sessionKey,
504
+ chatId,
505
+ threadId = null,
506
+ } = {}) {
507
+ if (codexRuntimeController) {
508
+ const runtimeView = await codexRuntimeController.resolveRuntimeView({
509
+ sessionKey,
510
+ chatId,
511
+ threadId,
512
+ });
513
+ if (!isCodexRuntimeView(runtimeView)) return runtimeView;
514
+ if (typeof pm?.getModelSettingsStatus !== 'function') {
515
+ const error = new Error('Codex model-settings status is unavailable');
516
+ error.code = 'CODEX_RUNTIME_UNAVAILABLE';
517
+ throw error;
518
+ }
519
+ const settingsStatus = await pm.getModelSettingsStatus(sessionKey);
520
+ const processStatus = settingsStatus.outcome;
521
+ const unavailableReason = settingsStatus.reason ?? null;
522
+ const copySettings = (settings) => (
523
+ settings
524
+ && typeof settings.model === 'string'
525
+ && typeof settings.effort === 'string'
526
+ ? Object.freeze({
527
+ model: settings.model,
528
+ effort: settings.effort,
529
+ })
530
+ : null
531
+ );
532
+ return Object.freeze({
533
+ ...runtimeView,
534
+ desiredSettings: Object.freeze({
535
+ model: runtimeView.model,
536
+ effort: runtimeView.effort,
537
+ }),
538
+ nextTurnSettings: copySettings(settingsStatus.nextTurn),
539
+ observedThreadSettings: copySettings(settingsStatus.observedThread),
540
+ activeTurnSettings: copySettings(settingsStatus.currentTurn),
541
+ processStatus,
542
+ unavailableReason,
543
+ });
544
+ }
545
+ if (resolvePromptBackend({ config, chatId, threadId }) === 'codex') {
546
+ const error = new Error('Codex runtime controller is not initialized');
547
+ error.code = 'CODEX_RUNTIME_UNAVAILABLE';
548
+ throw error;
549
+ }
550
+ return Object.freeze({ runtime: 'claude' });
551
+ }
483
552
 
484
- function buildSpawnContext(sessionKey) {
553
+ async function buildSpawnContext(sessionKey) {
485
554
  const chatId = getChatIdFromKey(sessionKey);
486
555
  const chatConfig = config.chats[chatId];
487
556
  if (!chatConfig) return null;
488
557
  const threadId = sessionKey.includes(':') ? sessionKey.split(':')[1] : null;
558
+ const promptBackend = resolvePromptBackend({
559
+ config,
560
+ chatId,
561
+ threadId: threadId || null,
562
+ });
563
+
564
+ if (promptBackend === 'codex') {
565
+ if (!codexRuntimeController) {
566
+ const error = new Error('Codex runtime controller is not initialized');
567
+ error.code = 'CODEX_RUNTIME_UNAVAILABLE';
568
+ throw error;
569
+ }
570
+ return buildCodexSpawnContext({
571
+ sessionKey,
572
+ chatId,
573
+ threadId: threadId || null,
574
+ chatConfig,
575
+ db,
576
+ pm,
577
+ runtimeController: codexRuntimeController,
578
+ getSessionLabel,
579
+ logEvent,
580
+ });
581
+ }
489
582
 
490
583
  // S2: a stored session is valid ONLY for the config it was spawned
491
584
  // under. agent / cwd are spawn-identity — baked into the process at
@@ -557,7 +650,7 @@ function buildSpawnContext(sessionKey) {
557
650
  }
558
651
 
559
652
  async function getOrSpawnForChat(sessionKey) {
560
- const ctx = buildSpawnContext(sessionKey);
653
+ const ctx = await buildSpawnContext(sessionKey);
561
654
  if (!ctx) return null;
562
655
  return pm.getOrSpawn(sessionKey, ctx);
563
656
  }
@@ -574,6 +667,33 @@ async function sendToProcess(sessionKey, prompt, context = {}, { onDispatched }
574
667
  // 90 min. The checkpoint never extends a turn past this.
575
668
  const maxTurnHardMs = (chatConfig.maxTurnHard || config.defaults?.maxTurnHard || 5400) * 1000;
576
669
 
670
+ // CodexProcess owns a bounded cancellable queue. Commit the send into that
671
+ // queue immediately so /stop can reject it; the full-turn stdin lock below
672
+ // exists only to prevent Claude stream-json batching.
673
+ if (
674
+ entry.runtime === 'codex'
675
+ || entry.backend === 'codex'
676
+ ) {
677
+ if (
678
+ context.codexDispatchGenerationId
679
+ && entry.generationId !== context.codexDispatchGenerationId
680
+ ) {
681
+ const error = new Error(
682
+ 'Codex queue authorization belongs to a replaced generation',
683
+ );
684
+ error.code = 'CODEX_DISPATCH_GENERATION_CHANGED';
685
+ throw error;
686
+ }
687
+ const turnP = pm.send(sessionKey, prompt, {
688
+ timeoutMs,
689
+ maxTurnMs,
690
+ maxTurnHardMs,
691
+ context,
692
+ });
693
+ if (typeof onDispatched === 'function') onDispatched();
694
+ return await turnP;
695
+ }
696
+
577
697
  // 0.12 Phase 2.1: HeartbeatReactor binding removed for CliProcess.
578
698
  // 0.11.0-channels needed a random-cycling working-pool reactor because
579
699
  // the channels protocol gave no per-tool visibility. CliProcess (0.12)
@@ -644,6 +764,7 @@ let attemptAutoResume = null;
644
764
  let errorReplyText = null;
645
765
  let queueWarnThreshold = null;
646
766
  let inFlightHandlers = null;
767
+ let recoverCodexRequest = null;
647
768
 
648
769
  // AUTH_DISABLED (docs/AUTH_DISABLED_HANDLING_SPEC.md): dedupe/re-arm gate for
649
770
  // the operator notification (dispatcher.js) + Netdata-visibility heartbeat
@@ -725,12 +846,14 @@ const {
725
846
  MODEL_OPTIONS,
726
847
  EFFORT_OPTIONS,
727
848
  MODEL_VERSIONS_DESC,
849
+ isCodexRuntimeView,
728
850
  } = require('./lib/handlers/config-ui');
729
851
  let formatConfigInfoText = null;
730
852
  // CRITICAL: these placeholders MUST exist or 'use strict' boot fails
731
853
  // with ReferenceError. v4 review (commit 39) found 4 missing — restored.
732
854
  // Each handler is wired in main() once its deps exist.
733
855
  let handleConfigCallback = null;
856
+ let handleCodexReconciliationCallback = null;
734
857
  let handleAbortIfRequested = null;
735
858
  let autosteer = null;
736
859
  let dispatchSlashCommand = null;
@@ -782,6 +905,23 @@ function parsePairCodeArgs(text) {
782
905
  async function handleMessage(sessionKey, chatId, msg, bot) {
783
906
  const chatConfig = config.chats[chatId];
784
907
  if (!chatConfig) return;
908
+ const selectedInboundProvider = resolvePromptBackend({
909
+ config,
910
+ chatId,
911
+ threadId: msg.message_thread_id?.toString() || null,
912
+ }) === 'codex'
913
+ ? 'codex'
914
+ : 'claude';
915
+ if (
916
+ msg._requiredProvider
917
+ && msg._requiredProvider !== selectedInboundProvider
918
+ ) {
919
+ const error = new Error(
920
+ `Recovery requires ${msg._requiredProvider}, but the session now selects ${selectedInboundProvider}`,
921
+ );
922
+ error.code = 'PROVIDER_RECOVERY_SELECTION_CHANGED';
923
+ throw error;
924
+ }
785
925
 
786
926
  // Mark the inbound row as 'dispatched' so the boot replay loop knows
787
927
  // this turn started. Cleared to 'replied' (or 'failed') when done.
@@ -792,6 +932,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
792
932
  dbWrite(() => db.setInboundHandlerStatus({
793
933
  chat_id: chatId, msg_id: msg.message_id, status: 'dispatched',
794
934
  }), 'set handler_status=dispatched');
935
+ // This write is deliberately not best-effort: provider choice is the
936
+ // replay fence. A conflicting duplicate must stop before any provider
937
+ // process, reservation, or RPC observes the input.
938
+ db.recordInboundRuntimeSelection({
939
+ session_key: sessionKey,
940
+ bot_name: BOT_NAME,
941
+ telegram_chat_id: String(chatId),
942
+ telegram_message_id: String(msg.message_id),
943
+ provider: selectedInboundProvider,
944
+ ts: Date.now(),
945
+ });
795
946
  }
796
947
 
797
948
  const text = msg.text || msg.caption || '';
@@ -829,9 +980,15 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
829
980
  // command — which post-compaction lands in a stale-session state and
830
981
  // emits "🗜️ No active session — /compact only works once a turn has
831
982
  // started." Visible duplicate-reply UX bug.
832
- const markReplied = () => dbWrite(() => db.setInboundHandlerStatus({
833
- chat_id: chatId, msg_id: msg.message_id, status: 'replied',
834
- }), 'set handler_status=replied');
983
+ const markHandlerStatus = (status) => dbWrite(
984
+ () => db.setInboundHandlerStatus({
985
+ chat_id: chatId,
986
+ msg_id: msg.message_id,
987
+ status,
988
+ }),
989
+ `set handler_status=${status}`,
990
+ );
991
+ const markReplied = () => markHandlerStatus('replied');
835
992
 
836
993
  // sendReply accepts (text, meta?) with optional extra Telegram params
837
994
  // pulled out via meta.params (kept separate so meta stays for DB tags).
@@ -856,9 +1013,47 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
856
1013
  // (2026-06-03). getTopicConfig returns {} when there's no active topic.
857
1014
  const _cardTopicCfg = getTopicConfig(chatConfig, threadIdStr || null);
858
1015
  const effectiveRichText = resolveRichTextEnabled(config, chatId, threadIdStr || null);
859
- const info = formatConfigInfoText(chatConfig, show, sessionKey, _cardTopicCfg, effectiveRichText);
860
- const reply_markup = buildConfigKeyboard(chatConfig, show, _cardTopicCfg, effectiveRichText);
1016
+ let runtimeView;
1017
+ try {
1018
+ runtimeView = await resolveSessionRuntimeView({
1019
+ sessionKey,
1020
+ chatId,
1021
+ threadId: threadIdStr || null,
1022
+ });
1023
+ } catch (error) {
1024
+ console.error(
1025
+ `[${label}] config card runtime view failed: `
1026
+ + `${error?.code || error?.name || 'unknown'}`,
1027
+ );
1028
+ await sendReply('Configuration is temporarily unavailable.');
1029
+ return;
1030
+ }
1031
+ const info = await formatConfigInfoText(
1032
+ chatConfig,
1033
+ show,
1034
+ sessionKey,
1035
+ _cardTopicCfg,
1036
+ effectiveRichText,
1037
+ runtimeView,
1038
+ );
1039
+ const reply_markup = buildConfigKeyboard(
1040
+ chatConfig,
1041
+ show,
1042
+ _cardTopicCfg,
1043
+ effectiveRichText,
1044
+ runtimeView,
1045
+ );
861
1046
  await sendReply(info, { params: { reply_markup } });
1047
+ if (text === '/config' && isCodexRuntimeView(runtimeView)) {
1048
+ for (const attempt of db.listUnresolvedCodexAttempts({
1049
+ session_key: sessionKey,
1050
+ })) {
1051
+ const view = buildCodexReconciliationView(attempt);
1052
+ await sendReply(view.text, {
1053
+ params: { reply_markup: view.reply_markup },
1054
+ });
1055
+ }
1056
+ }
862
1057
  return;
863
1058
  }
864
1059
  // Slash command dispatch — extracted to lib/handlers/slash-commands.js.
@@ -883,22 +1078,24 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
883
1078
  // read, and a token can expire between an original dispatch and its later
884
1079
  // redelivery, so skipping it there would silently re-wedge exactly the
885
1080
  // messages most likely to hit an expired-auth window (post-restart backlog).
886
- let auth;
887
- try {
888
- auth = checkClaudeAuthHealth();
889
- } catch (err) {
890
- console.error(`[auth] health check failed: ${err.message}`);
891
- auth = { state: 'unknown' };
892
- }
893
- if (auth.state === 'expired') {
894
- const expIso = new Date(auth.refreshTokenExpiresAt).toISOString();
895
- console.error(`[auth] Claude login EXPIRED (refresh token ${expIso}) — refusing turn for ${label}; re-login on the host.`);
896
- logEvent('auth-expired', {
897
- session_key: sessionKey, chat_id: chatId, source: 'dispatch-gate',
898
- refresh_token_expires_at: auth.refreshTokenExpiresAt,
899
- });
900
- await sendReply('🔑 Claude login has expired and needs to be re-authenticated. Messages can\'t be processed until the login is refreshed on the host.');
901
- return;
1081
+ if (selectedInboundProvider === 'claude') {
1082
+ let auth;
1083
+ try {
1084
+ auth = checkClaudeAuthHealth();
1085
+ } catch (err) {
1086
+ console.error(`[auth] health check failed: ${err.message}`);
1087
+ auth = { state: 'unknown' };
1088
+ }
1089
+ if (auth.state === 'expired') {
1090
+ const expIso = new Date(auth.refreshTokenExpiresAt).toISOString();
1091
+ console.error(`[auth] Claude login EXPIRED (refresh token ${expIso}) — refusing turn for ${label}; re-login on the host.`);
1092
+ logEvent('auth-expired', {
1093
+ session_key: sessionKey, chat_id: chatId, source: 'dispatch-gate',
1094
+ refresh_token_expires_at: auth.refreshTokenExpiresAt,
1095
+ });
1096
+ await sendReply('🔑 Claude login has expired and needs to be re-authenticated. Messages can\'t be processed until the login is refreshed on the host.');
1097
+ return;
1098
+ }
902
1099
  }
903
1100
 
904
1101
  const t0 = Date.now();
@@ -1314,7 +1511,21 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1314
1511
  // Skip the 🤔 → ✍ flash for messages that are about to be
1315
1512
  // autosteered. willAutosteer evaluates the same pre-condition
1316
1513
  // tryAutosteer would.
1317
- if (!voiceAck.ackEmitted && !autosteer.willAutosteer(sessionKey, chatConfig)) {
1514
+ const feedbackEntry = pm.get(sessionKey);
1515
+ const pendingCodexFollowup = Boolean(
1516
+ feedbackEntry
1517
+ && !feedbackEntry.closed
1518
+ && feedbackEntry.inFlight
1519
+ && (
1520
+ feedbackEntry.runtime === 'codex'
1521
+ || feedbackEntry.backend === 'codex'
1522
+ ),
1523
+ );
1524
+ if (
1525
+ !voiceAck.ackEmitted
1526
+ && !pendingCodexFollowup
1527
+ && !autosteer.willAutosteer(sessionKey, chatConfig)
1528
+ ) {
1318
1529
  reactor.setState('THINKING');
1319
1530
  }
1320
1531
 
@@ -1347,9 +1558,182 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1347
1558
  const releaseIntent = await intentLock.acquire(sessionKey);
1348
1559
  let steered = { autosteered: false };
1349
1560
  let sendPromise = null;
1561
+ let codexDispatch = null;
1562
+ let codexDispatchDecision = null;
1350
1563
  try {
1351
- steered = autosteer.tryAutosteer({ sessionKey, chatConfig, chatId, msg, prompt });
1352
- if (!steered.autosteered) {
1564
+ const current = pm.get(sessionKey);
1565
+ const liveCodexGeneration = Boolean(
1566
+ current
1567
+ && !current.closed
1568
+ && current.inFlight
1569
+ && typeof current.generationId === 'string'
1570
+ && (
1571
+ current.runtime === 'codex'
1572
+ || current.backend === 'codex'
1573
+ ),
1574
+ );
1575
+ if (liveCodexGeneration) {
1576
+ if (!codexRuntimeController) {
1577
+ codexDispatchDecision = 'unavailable';
1578
+ } else {
1579
+ try {
1580
+ const dispatchClaim = codexRuntimeController.claimDispatchReservation({
1581
+ sessionKey,
1582
+ generationId: current.generationId,
1583
+ botName: BOT_NAME,
1584
+ telegramChatId: String(chatId),
1585
+ telegramMessageId: String(msg.message_id),
1586
+ });
1587
+ codexDispatch = {
1588
+ reservationId: dispatchClaim.reservationId,
1589
+ generationId: dispatchClaim.generationId,
1590
+ state: dispatchClaim.reservation?.state,
1591
+ };
1592
+ if (!dispatchClaim.claimed) {
1593
+ codexDispatchDecision = 'duplicate';
1594
+ } else {
1595
+ steered = await autosteer.tryCodexAutosteer({
1596
+ sessionKey,
1597
+ chatConfig,
1598
+ chatId,
1599
+ msg,
1600
+ prompt,
1601
+ });
1602
+ if (steered.outcome === 'accepted') {
1603
+ try {
1604
+ codexRuntimeController.finalizeAcceptedSteer({
1605
+ sessionKey,
1606
+ generationId: codexDispatch.generationId,
1607
+ reservationId: codexDispatch.reservationId,
1608
+ steerAttemptId: steered.attemptId,
1609
+ targetAttemptId: steered.targetAttemptId,
1610
+ });
1611
+ codexDispatchDecision = 'accepted';
1612
+ } catch (error) {
1613
+ try {
1614
+ codexRuntimeController.markDispatchDisposition({
1615
+ sessionKey,
1616
+ generationId: codexDispatch.generationId,
1617
+ reservationId: codexDispatch.reservationId,
1618
+ disposition: 'ambiguous',
1619
+ });
1620
+ } catch {
1621
+ // The generation may have changed after app-server
1622
+ // acceptance. It is still unsafe to queue the input.
1623
+ }
1624
+ steered = { autosteered: false, outcome: 'ambiguous' };
1625
+ codexDispatchDecision = 'ambiguous';
1626
+ console.error(
1627
+ `[${label}] Codex steer linkage failed closed: `
1628
+ + `${error?.code || error?.name || 'unknown'}`,
1629
+ );
1630
+ }
1631
+ } else if (steered.outcome === 'ambiguous') {
1632
+ try {
1633
+ codexRuntimeController.markDispatchDisposition({
1634
+ sessionKey,
1635
+ generationId: codexDispatch.generationId,
1636
+ reservationId: codexDispatch.reservationId,
1637
+ disposition: 'ambiguous',
1638
+ });
1639
+ } catch {
1640
+ // Generation drift is itself ambiguous. Never queue.
1641
+ }
1642
+ codexDispatchDecision = 'ambiguous';
1643
+ } else if (steered.outcome === 'unavailable') {
1644
+ codexRuntimeController.markDispatchDisposition({
1645
+ sessionKey,
1646
+ generationId: codexDispatch.generationId,
1647
+ reservationId: codexDispatch.reservationId,
1648
+ disposition: 'cancelled',
1649
+ });
1650
+ codexDispatchDecision = 'unavailable';
1651
+ } else {
1652
+ // Queue mode, a definitely-not-sent steer, disabled
1653
+ // autosteer, and a turn that became idle all converge on
1654
+ // the ordinary CodexProcess queue.
1655
+ const queueEntry = pm.get(sessionKey);
1656
+ const sameQueueGeneration = (
1657
+ queueEntry
1658
+ && !queueEntry.closed
1659
+ && queueEntry.generationId === codexDispatch.generationId
1660
+ );
1661
+ if (
1662
+ sameQueueGeneration
1663
+ && ['Active', 'Idle', 'StartingTurn']
1664
+ .includes(queueEntry.state)
1665
+ ) {
1666
+ codexRuntimeController.markDispatchDisposition({
1667
+ sessionKey,
1668
+ generationId: codexDispatch.generationId,
1669
+ reservationId: codexDispatch.reservationId,
1670
+ disposition: 'queue-authorized',
1671
+ });
1672
+ codexDispatchDecision = 'queue';
1673
+ } else if (sameQueueGeneration) {
1674
+ codexRuntimeController.markDispatchDisposition({
1675
+ sessionKey,
1676
+ generationId: codexDispatch.generationId,
1677
+ reservationId: codexDispatch.reservationId,
1678
+ disposition: 'cancelled',
1679
+ });
1680
+ codexDispatchDecision = 'unavailable';
1681
+ } else {
1682
+ try {
1683
+ codexRuntimeController.markDispatchDisposition({
1684
+ sessionKey,
1685
+ generationId: codexDispatch.generationId,
1686
+ reservationId: codexDispatch.reservationId,
1687
+ disposition: 'ambiguous',
1688
+ });
1689
+ } catch {
1690
+ // The stale generation can no longer be mutated safely.
1691
+ }
1692
+ codexDispatchDecision = 'ambiguous';
1693
+ }
1694
+ }
1695
+ }
1696
+ } catch (error) {
1697
+ if (codexDispatch) {
1698
+ try {
1699
+ codexRuntimeController.markDispatchDisposition({
1700
+ sessionKey,
1701
+ generationId: codexDispatch.generationId,
1702
+ reservationId: codexDispatch.reservationId,
1703
+ disposition: 'ambiguous',
1704
+ });
1705
+ } catch {
1706
+ // Preserve the conservative classification even when the
1707
+ // durable owner changed before the transition.
1708
+ }
1709
+ codexDispatchDecision = 'ambiguous';
1710
+ } else {
1711
+ codexDispatchDecision = 'unavailable';
1712
+ }
1713
+ console.error(
1714
+ `[${label}] Codex dispatch reservation failed closed: `
1715
+ + `${error?.code || error?.name || 'unknown'}`,
1716
+ );
1717
+ }
1718
+ }
1719
+ } else {
1720
+ // Preserve Claude's synchronous injection path and timing.
1721
+ steered = autosteer.tryAutosteer({
1722
+ sessionKey,
1723
+ chatConfig,
1724
+ chatId,
1725
+ msg,
1726
+ prompt,
1727
+ });
1728
+ }
1729
+ if (
1730
+ !steered.autosteered
1731
+ && !['duplicate', 'ambiguous', 'unavailable']
1732
+ .includes(codexDispatchDecision)
1733
+ ) {
1734
+ if (codexDispatchDecision === 'queue') {
1735
+ reactor.setState('THINKING');
1736
+ }
1353
1737
  // Primary turn. Kick off the dispatch and hold the latch until
1354
1738
  // pm.send has made the process inFlight (onDispatched). The
1355
1739
  // turn RESULT is awaited only AFTER the latch is released — the
@@ -1363,6 +1747,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1363
1747
  // the question lifecycle (callbacks.js onQuestionAsked/-Resumed) can
1364
1748
  // pause it while the bot waits on the USER and resume on the answer.
1365
1749
  streamer, reactor, typing: stopTyping, sourceMsgId: msg.message_id,
1750
+ codexDispatchReservationId: codexDispatch?.reservationId,
1751
+ codexDispatchGenerationId: codexDispatch?.generationId,
1366
1752
  // 0.7.4 (item B): fire THINKING when Claude actually starts
1367
1753
  // emitting — not the moment we wrote stdin.
1368
1754
  onFirstStream: () => reactor.setState('THINKING'),
@@ -1374,6 +1760,46 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1374
1760
  } finally {
1375
1761
  releaseIntent();
1376
1762
  }
1763
+ if (codexDispatchDecision === 'duplicate') {
1764
+ stopTyping();
1765
+ reactor.stop();
1766
+ const terminalDuplicateStatus = (
1767
+ codexDispatch?.state === 'settled'
1768
+ ? 'replied'
1769
+ : ['failed', 'interrupted', 'cancelled'].includes(codexDispatch?.state)
1770
+ ? 'failed'
1771
+ : codexDispatch?.state === 'ambiguous'
1772
+ ? 'codex-ambiguous'
1773
+ : null
1774
+ );
1775
+ if (terminalDuplicateStatus) {
1776
+ dbWrite(() => db.setInboundHandlerStatus({
1777
+ chat_id: chatId,
1778
+ msg_id: msg.message_id,
1779
+ status: terminalDuplicateStatus,
1780
+ }), 'restore duplicate Codex handler status');
1781
+ }
1782
+ return;
1783
+ }
1784
+ if (['ambiguous', 'unavailable'].includes(codexDispatchDecision)) {
1785
+ stopTyping();
1786
+ await reactor.clear().catch(() => {});
1787
+ reactor.stop();
1788
+ const sendCodexDispatchNotice = (notice) => tg(bot, 'sendMessage', {
1789
+ chat_id: chatId,
1790
+ text: notice,
1791
+ ...replyOpts(threadId),
1792
+ }, {
1793
+ source: 'codex-autosteer-notice',
1794
+ botName: BOT_NAME,
1795
+ });
1796
+ await sendCodexDispatchNotice(
1797
+ codexDispatchDecision === 'ambiguous'
1798
+ ? 'This follow-up may have been incorporated. Please wait for the current turn to finish before retrying it.'
1799
+ : 'I couldn’t add that follow-up while the current turn was stopping. Please send it again after the turn finishes.',
1800
+ );
1801
+ return;
1802
+ }
1377
1803
  if (steered.autosteered) {
1378
1804
  stopTyping();
1379
1805
  // setState('AUTOSTEERED') is terminal — bypasses throttle,
@@ -1382,15 +1808,36 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1382
1808
  // AUTOSTEERED is terminal; stop the reactor's STALL / TIMEOUT
1383
1809
  // timers so they don't pin the closure for up to 30s.
1384
1810
  reactor.stop();
1385
- markReplied();
1386
1811
  return;
1387
1812
  }
1388
1813
 
1814
+ let deliveryFinalizer = null;
1389
1815
  try {
1390
1816
  const result = await sendPromise;
1391
1817
  // sendToProcess failures are captured (not thrown) so the latch
1392
1818
  // always releases; re-throw here into the existing handler.
1393
1819
  if (result && result.__sendError) throw result.__sendError;
1820
+ let telegramDeliveryComplete = true;
1821
+ deliveryFinalizer = createTelegramDeliveryFinalizer({
1822
+ controller: codexRuntimeController,
1823
+ sessionKey,
1824
+ result,
1825
+ queuedDispatch: (
1826
+ codexDispatchDecision === 'queue'
1827
+ && codexDispatch?.reservationId
1828
+ )
1829
+ ? {
1830
+ reservationId: codexDispatch.reservationId,
1831
+ botName: BOT_NAME,
1832
+ telegramChatId: String(chatId),
1833
+ telegramMessageId: String(msg.message_id),
1834
+ }
1835
+ : null,
1836
+ markHandlerStatus,
1837
+ });
1838
+ const finalizeResultDelivery = (deliveryComplete = true) => (
1839
+ deliveryFinalizer.finalize(deliveryComplete)
1840
+ );
1394
1841
  const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
1395
1842
 
1396
1843
  // 0.7.6 (item F): persist per-turn telemetry. Stream-json result
@@ -1560,7 +2007,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1560
2007
  // a placeholder so the user doesn't see silence with no reaction.
1561
2008
  // NO_REPLY is an explicit "stay silent" signal from the agent —
1562
2009
  // those still markReplied silently.
1563
- if (result.text === 'NO_REPLY') { markReplied(); return; }
2010
+ if (result.text === 'NO_REPLY') {
2011
+ await finalizeResultDelivery();
2012
+ return;
2013
+ }
1564
2014
  if (!result.text) {
1565
2015
  // 0.8.0-rc.7: tool-only completion is NOT an error. Under SDK
1566
2016
  // pm, a turn that ends after running tools (no closing text
@@ -1581,7 +2031,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1581
2031
  num_tool_uses: result.metrics?.numToolUses,
1582
2032
  num_assistant_messages: result.metrics?.numAssistantMessages,
1583
2033
  });
1584
- markReplied();
2034
+ await finalizeResultDelivery();
1585
2035
  return;
1586
2036
  }
1587
2037
  // 0.7.1: if the fallback send itself fails, throw rather than
@@ -1615,7 +2065,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1615
2065
  // does NOT remove the visible emoji. Without this clear, the
1616
2066
  // user sees 👀 next to their message indefinitely.
1617
2067
  await reactor.clear().catch(() => {});
1618
- markReplied();
2068
+ await finalizeResultDelivery();
1619
2069
  return;
1620
2070
  }
1621
2071
 
@@ -1640,7 +2090,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1640
2090
  reactor.clear().catch(() => {});
1641
2091
  clearAutosteeredReactions(sessionKey).catch(() => {});
1642
2092
  console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | channels-delivered | ${chatConfig.model}/${chatConfig.effort}`);
1643
- markReplied();
2093
+ await finalizeResultDelivery();
1644
2094
  return;
1645
2095
  }
1646
2096
 
@@ -1781,7 +2231,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1781
2231
  }
1782
2232
  clearAutosteeredReactions(sessionKey).catch(() => {});
1783
2233
  console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1784
- markReplied();
2234
+ await finalizeResultDelivery(
2235
+ !mediaContext.deliveryIncomplete && telegramDeliveryComplete,
2236
+ );
1785
2237
  return;
1786
2238
  }
1787
2239
  // Preview can't hold the final body (overflow OR last edit
@@ -1842,7 +2294,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1842
2294
  }
1843
2295
  clearAutosteeredReactions(sessionKey).catch(() => {});
1844
2296
  console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed-redeliver(${reason}, ${chunks.length} chunks${r.failed.length ? `, ${r.failed.length} failed` : ''}) | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1845
- markReplied();
2297
+ await finalizeResultDelivery(
2298
+ !mediaContext.deliveryIncomplete && telegramDeliveryComplete,
2299
+ );
1846
2300
  return;
1847
2301
  }
1848
2302
  // Not streamed (response too short — never crossed minChars).
@@ -1855,6 +2309,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1855
2309
  message_id: msg.message_id,
1856
2310
  reaction: [{ type: 'emoji', emoji: parsed.reaction }],
1857
2311
  }, outMeta).catch((err) => {
2312
+ telegramDeliveryComplete = false;
1858
2313
  console.error(`[${label}] setMessageReaction failed: ${err.message}`);
1859
2314
  });
1860
2315
  } else if (parsed.sticker) {
@@ -1863,6 +2318,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1863
2318
  sticker: parsed.sticker,
1864
2319
  ...(threadId && { message_thread_id: threadId }),
1865
2320
  }, { ...outMeta, stickerName: parsed.stickerLabel }).catch((err) => {
2321
+ telegramDeliveryComplete = false;
1866
2322
  console.error(`[${label}] sendSticker failed: ${err.message}`);
1867
2323
  });
1868
2324
  } else if (parsed.text) {
@@ -1876,7 +2332,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1876
2332
  ? stripMediaMarkdown(parsed.text)
1877
2333
  : parsed.text;
1878
2334
  const chunks = chunkMarkdownText(shortText, TG_CHUNK_BUDGET);
1879
- await deliverReplies({
2335
+ const deliveryResult = await deliverReplies({
1880
2336
  bot,
1881
2337
  send: (b, method, params, m) => tg(b, method, params, m),
1882
2338
  chatId,
@@ -1886,6 +2342,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1886
2342
  meta: outMeta,
1887
2343
  logger: { error: (m) => console.error(`[${label}] ${m}`) },
1888
2344
  });
2345
+ mediaContext.recordTextFailures(deliveryResult.failed.length);
1889
2346
  }
1890
2347
 
1891
2348
  await mediaContext.flushPartialDeliveryWarning(outMetaBase);
@@ -1912,8 +2369,22 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1912
2369
  // without breaking NEW-TURN.
1913
2370
  clearAutosteeredReactions(sessionKey).catch(() => {});
1914
2371
  console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
1915
- markReplied();
2372
+ await finalizeResultDelivery(
2373
+ !mediaContext.deliveryIncomplete && telegramDeliveryComplete,
2374
+ );
1916
2375
  } catch (err) {
2376
+ let deliverySettlementError = null;
2377
+ try {
2378
+ await deliveryFinalizer?.failIfPending();
2379
+ } catch (settlementError) {
2380
+ deliverySettlementError = settlementError;
2381
+ logEvent('codex-telegram-delivery-failure-settlement-failed', {
2382
+ chat_id: chatId,
2383
+ msg_id: msg.message_id,
2384
+ bot: BOT_NAME,
2385
+ code: settlementError?.code || settlementError?.name || 'unknown',
2386
+ });
2387
+ }
1917
2388
  // If the user just aborted this session, silently finalise the stream
1918
2389
  // without the scary "⚠ stream interrupted" banner. The user has already
1919
2390
  // seen their "Остановлено." ack; adding a warning to the partial bubble
@@ -1955,6 +2426,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1955
2426
  await streamer.finalize('', errorSuffix ? { errorSuffix } : {}).catch(() => {});
1956
2427
  reactor.setState(reactorState);
1957
2428
  }
2429
+ if (deliverySettlementError) throw deliverySettlementError;
1958
2430
  throw err;
1959
2431
  } finally {
1960
2432
  stopTyping();
@@ -2125,6 +2597,11 @@ function createBot(token) {
2125
2597
  handleAbortIfRequested: (...a) => handleAbortIfRequested(...a),
2126
2598
  getRewindHandler: () => rewindHandler,
2127
2599
  isRewindCommand,
2600
+ resolveRuntime: ({ chatId, threadId }) => resolvePromptBackend({
2601
+ config,
2602
+ chatId,
2603
+ threadId: threadId || null,
2604
+ }),
2128
2605
  getQuestionHandlers: () => questionHandlers,
2129
2606
  shouldHandle,
2130
2607
  getSessionKey,
@@ -2249,6 +2726,10 @@ function createBot(token) {
2249
2726
  const data = ctx.callbackQuery.data;
2250
2727
  if (data.startsWith('cfg:')) {
2251
2728
  await handleConfigCallback(ctx);
2729
+ } else if (data.startsWith('cxr:')) {
2730
+ if (handleCodexReconciliationCallback) {
2731
+ await handleCodexReconciliationCallback(ctx);
2732
+ }
2252
2733
  } else if (data.startsWith('q:')) {
2253
2734
  if (questionHandlers) await questionHandlers.handleQuestionCallback(ctx);
2254
2735
  } else {
@@ -2298,9 +2779,9 @@ function createBot(token) {
2298
2779
  });
2299
2780
  return;
2300
2781
  }
2301
- // Mid-turn (turn still in flight) → fold into the running turn via the 0.9.0
2302
- // injector. Post-turn (idle) — OR the injector no-ops because the turn just
2303
- // settled at the boundary — → re-dispatch as a NEW turn (edit re-delivery).
2782
+ // Claude can fold a same-message re-edit into its running turn. Codex
2783
+ // defers the edited content until the same generation is idle, then
2784
+ // re-dispatches it as a provider-pinned visible turn.
2304
2785
  const injected = maybeInjectEditCorrection?.(ctx.editedMessage);
2305
2786
  if (!injected) maybePostTurnEdit?.(ctx.editedMessage, oldText, botUsername, mentionRe);
2306
2787
  } catch (err) {
@@ -2376,6 +2857,11 @@ let startPollWatchdog = null;
2376
2857
 
2377
2858
  async function main() {
2378
2859
  const cgroupOomObserver = createCgroupOomObserver();
2860
+ let codexManagerOptions = Object.freeze({
2861
+ codexHostIdentity: null,
2862
+ codexBootSessionIdentity: null,
2863
+ codexRecoveryState: Object.freeze({ status: 'clear' }),
2864
+ });
2379
2865
  loadConfig();
2380
2866
 
2381
2867
  let dbOverride;
@@ -2406,6 +2892,18 @@ async function main() {
2406
2892
  loadStickers();
2407
2893
  DB_PATH = dbOverride || path.join(DB_DIR, `${BOT_NAME}.db`);
2408
2894
  console.log(`[polygram] bot: ${BOT_NAME} (${Object.keys(config.chats).length} chats) db: ${DB_PATH}`);
2895
+ let ipcRuntimeDir;
2896
+ try {
2897
+ ipcRuntimeDir = ipcServer.ensureRuntimeDirectory();
2898
+ process.env.POLYGRAM_IPC_DIR = ipcRuntimeDir;
2899
+ ipcServer.socketPathFor(BOT_NAME);
2900
+ } catch (error) {
2901
+ console.error(
2902
+ `[fatal] IPC runtime directory is unsafe or unavailable (${error.code || 'IPC_DIR_INVALID'})`,
2903
+ );
2904
+ process.exit(2);
2905
+ return;
2906
+ }
2409
2907
  const sessionLauncher = process.env.ORCHESTRA_SESSION_LAUNCHER;
2410
2908
  const requireExistingServer = process.env.ORCHESTRA_TMUX_REQUIRE_SERVER === '1';
2411
2909
  console.log(`[polygram] session containment configured: ${sessionLauncher ? 'yes' : 'no'}`);
@@ -2449,6 +2947,39 @@ async function main() {
2449
2947
  try {
2450
2948
  db = dbClient.open(DB_PATH);
2451
2949
  console.log(`[db] opened ${DB_PATH}`);
2950
+ try {
2951
+ codexRuntimeController = createCodexRuntimeController({
2952
+ config,
2953
+ db,
2954
+ defaultDaemonSecretRoots: [
2955
+ DATA_DIR,
2956
+ ipcRuntimeDir,
2957
+ path.dirname(CONFIG_PATH),
2958
+ path.join(process.env.HOME || DATA_DIR, '.claude'),
2959
+ ],
2960
+ logger: console,
2961
+ });
2962
+ } catch (error) {
2963
+ codexRuntimeController = null;
2964
+ console.warn(
2965
+ `[codex] runtime unavailable; Claude remains enabled: ${error.message}`,
2966
+ );
2967
+ }
2968
+ if (codexRuntimeController) {
2969
+ try {
2970
+ const codexBoot = codexRuntimeController.initialize();
2971
+ codexManagerOptions = codexBoot.managerOptions;
2972
+ if (codexBoot.recovery.status === 'quarantined') {
2973
+ console.warn(
2974
+ `[codex] runtime quarantined at boot (${codexBoot.recovery.reason || 'unknown'})`,
2975
+ );
2976
+ }
2977
+ } catch (error) {
2978
+ console.warn(
2979
+ `[codex] runtime unavailable; Claude remains enabled: ${error.message}`,
2980
+ );
2981
+ }
2982
+ }
2452
2983
  tg = createSender(db, console, config);
2453
2984
  richMediaFileIdCache = createMediaFileIdCache();
2454
2985
  richEditMessageText = createRichEditor({
@@ -2525,6 +3056,7 @@ async function main() {
2525
3056
  setImmediate(() => runEventsPrune('boot'));
2526
3057
  setInterval(() => runEventsPrune('interval'), 24 * 3_600_000).unref?.();
2527
3058
  }
3059
+ scheduleCodexRetention({ db, logEvent, logger: console });
2528
3060
 
2529
3061
  // #5 secret redaction — background sweep (deterministic floor). Conservative:
2530
3062
  // DISABLED unless config.defaults.secret_sweep.enabled; dryRun defaults ON, so
@@ -2806,7 +3338,7 @@ async function main() {
2806
3338
  });
2807
3339
  const channelsClaudeBin = pinnedClaudeBin;
2808
3340
 
2809
- const processFactory = createProcessFactory({
3341
+ const orchestraProcessFactory = createProcessFactory({
2810
3342
  config,
2811
3343
  spawnFn: buildSdkOptions,
2812
3344
  db,
@@ -2836,7 +3368,17 @@ async function main() {
2836
3368
  // normally supersedes this; kept so any context-less spawn still gets the backend
2837
3369
  // default rather than orchestra's neutral 100MB).
2838
3370
  maxOutboundFileBytes: resolveFileCaps({ localApi: !!config.bot?.apiRoot }).outBytes,
3371
+ codexClientFactory: codexRuntimeController?.clientFactory,
3372
+ codexCheckpointSink: codexRuntimeController?.checkpointSink,
3373
+ codexExpectedStaticProfile: codexRuntimeController?.resolveReceipt,
3374
+ codexHostIdentity: codexManagerOptions.codexHostIdentity,
3375
+ codexBootSessionIdentity: codexManagerOptions.codexBootSessionIdentity,
2839
3376
  });
3377
+ const processFactory = (sessionKey, spawnContext) => {
3378
+ const proc = orchestraProcessFactory(sessionKey, spawnContext);
3379
+ if (proc?.runtime === 'codex') codexRuntimeController.registerProcess(proc);
3380
+ return proc;
3381
+ };
2840
3382
  // Route in-process approval prompts through the SAME canUseTool plumbing
2841
3383
  // that SDK chats use:
2842
3384
  // - SdkProcess: SDK callbacks fire 'approval-required' via its own
@@ -2883,6 +3425,7 @@ async function main() {
2883
3425
  logger: console,
2884
3426
  callbacks: sdkCallbacks,
2885
3427
  budget: cap,
3428
+ ...codexManagerOptions,
2886
3429
  });
2887
3430
  // formatConfigInfoText MUST be wired BEFORE createHandleConfigCallback
2888
3431
  // — the latter destructures formatConfigInfoText from its deps at
@@ -2890,12 +3433,17 @@ async function main() {
2890
3433
  // caught this as the same class as the v3 BLOCKER.
2891
3434
  formatConfigInfoText = createFormatConfigInfoText({
2892
3435
  pm, db, getClaudeSessionId,
3436
+ resolveRuntimeView: resolveSessionRuntimeView,
2893
3437
  });
2894
3438
  handleConfigCallback = createHandleConfigCallback({
2895
- config, db, dbWrite, pm, getSessionKey,
3439
+ config, db, dbWrite, pm, intentLock, getSessionKey,
2896
3440
  formatConfigInfoText, buildConfigKeyboard, saveConfig,
3441
+ resolveRuntimeView: resolveSessionRuntimeView,
2897
3442
  botName: BOT_NAME, logger: console,
2898
3443
  });
3444
+ handleCodexReconciliationCallback = createHandleCodexReconciliationCallback({
3445
+ config, db, intentLock, getSessionKey, logger: console,
3446
+ });
2899
3447
  handleAbortIfRequested = createHandleAbort({
2900
3448
  pm, bot, tg, logEvent, isAbortRequest,
2901
3449
  markSessionAborted, clearAutosteeredReactions, getSessionKey,
@@ -2922,6 +3470,14 @@ async function main() {
2922
3470
  } = createDispatcher({
2923
3471
  config, db, dbWrite, tg, botName: BOT_NAME, logEvent,
2924
3472
  handleMessage, sendToProcess,
3473
+ recoverCodex: (input) => {
3474
+ if (typeof recoverCodexRequest !== 'function') {
3475
+ const error = new Error('Exact Codex recovery path is unavailable');
3476
+ error.code = 'CODEX_RECOVERY_UNAVAILABLE';
3477
+ throw error;
3478
+ }
3479
+ return recoverCodexRequest(input);
3480
+ },
2925
3481
  classifyError, isAutoResumable,
2926
3482
  abortGrace, autoResumeTracker,
2927
3483
  chunkMarkdownText, deliverReplies,
@@ -2932,9 +3488,9 @@ async function main() {
2932
3488
  logger: console,
2933
3489
  }));
2934
3490
  // 0.12.0 post-turn edit re-delivery: constructed AFTER dispatchHandleMessage is assigned (above).
2935
- // An edit while a turn is in flight folds via maybeInjectEditCorrection; an edit after the turn
2936
- // (or when the injector no-ops at the boundary) re-dispatches as a new turn. The on-edit 👀 is a
2937
- // pre-turn ack for the cold-spawn gap; the synthetic turn's own reactor then takes over the msg.
3491
+ // Claude keeps its same-message fold behavior. Codex defers edits to the
3492
+ // exact live generation's idle boundary and then re-dispatches them as
3493
+ // provider-pinned turns. The on-edit 👀 acknowledges the deferred action.
2938
3494
  maybePostTurnEdit = createEditRedelivery({
2939
3495
  pm, config, getSessionKey, shouldHandle, dispatchHandleMessage, bot,
2940
3496
  react: (chatId, msgId) => applyReactionToMessages({
@@ -2956,6 +3512,29 @@ async function main() {
2956
3512
  }).catch(() => {}),
2957
3513
  bot, logEvent, logger: console,
2958
3514
  });
3515
+ recoverCodexRequest = async ({
3516
+ sessionKey,
3517
+ chatId,
3518
+ msg,
3519
+ bot: recoveryBot,
3520
+ providerRecovery,
3521
+ }) => {
3522
+ if (providerRecovery?.provider !== 'codex') {
3523
+ return { ok: false, reason: 'provider recovery is not exact Codex' };
3524
+ }
3525
+ await handleMessage(
3526
+ sessionKey,
3527
+ String(chatId),
3528
+ {
3529
+ ...msg,
3530
+ _isReplay: true,
3531
+ _requiredProvider: 'codex',
3532
+ _codexAutoResume: true,
3533
+ },
3534
+ recoveryBot,
3535
+ );
3536
+ return { ok: true };
3537
+ };
2959
3538
  dropRedeliverer = createDropRedeliverer({
2960
3539
  db, redeliver: redeliverAsFreshTurn, logEvent, logger: console,
2961
3540
  });
@@ -2965,10 +3544,11 @@ async function main() {
2965
3544
  logger: console,
2966
3545
  }));
2967
3546
  dispatchSlashCommand = createSlashCommands({
2968
- config, db, dbWrite, pm, pairings, parsePairingTtl,
3547
+ config, db, dbWrite, pm, intentLock, pairings, parsePairingTtl,
2969
3548
  contextHintShown, formatContextReply, getClaudeSessionId,
2970
3549
  getOrSpawnForChat, parsePairCodeArgs,
2971
3550
  modelVersionsDesc: MODEL_VERSIONS_DESC, saveConfig,
3551
+ resolveRuntimeView: resolveSessionRuntimeView,
2972
3552
  botName: BOT_NAME, logEvent, logger: console,
2973
3553
  });
2974
3554
  console.log('[polygram] using SDK ProcessManager');
@@ -3263,7 +3843,37 @@ async function main() {
3263
3843
  return msg;
3264
3844
  };
3265
3845
 
3266
- const plan = classifyReplay({ candidates, cleanShutdown, hasCompletedTurn, announceable });
3846
+ const replaySessionKey = (row) => {
3847
+ const chatConfig = config.chats[row.chat_id];
3848
+ if (!chatConfig) return null;
3849
+ const threadId = row.thread_id == null
3850
+ ? null
3851
+ : String(row.thread_id);
3852
+ return getSessionKey(row.chat_id, threadId, chatConfig);
3853
+ };
3854
+ const getProviderRecovery = (row) => {
3855
+ const sessionKey = replaySessionKey(row);
3856
+ if (!sessionKey) {
3857
+ return {
3858
+ provider: 'unknown',
3859
+ reason: 'session-configuration-missing',
3860
+ };
3861
+ }
3862
+ return db.getReplayProviderRecovery({
3863
+ sessionKey,
3864
+ botName: BOT_NAME,
3865
+ telegramChatId: String(row.chat_id),
3866
+ telegramMessageId: String(row.msg_id),
3867
+ });
3868
+ };
3869
+
3870
+ const plan = classifyReplay({
3871
+ candidates,
3872
+ cleanShutdown,
3873
+ hasCompletedTurn,
3874
+ announceable,
3875
+ getProviderRecovery,
3876
+ });
3267
3877
 
3268
3878
  const result = await executeReplayPlan({
3269
3879
  plan,
@@ -3273,11 +3883,49 @@ async function main() {
3273
3883
  recover: async (row) => {
3274
3884
  const chatConfig = config.chats[row.chat_id];
3275
3885
  if (!chatConfig) return { ok: false };
3886
+ const selected = resolvePromptBackend({
3887
+ config,
3888
+ chatId: row.chat_id,
3889
+ threadId: row.thread_id == null ? null : String(row.thread_id),
3890
+ }) === 'codex'
3891
+ ? 'codex'
3892
+ : 'claude';
3893
+ if (selected !== 'claude') return { ok: false };
3894
+ const msg = reconstruct(row);
3895
+ msg._requiredProvider = 'claude';
3276
3896
  return redeliverAsFreshTurn({
3277
- chatId: row.chat_id, msg: reconstruct(row),
3897
+ chatId: row.chat_id, msg,
3278
3898
  source: 'boot-replay', preMark: 'replay-attempted',
3279
3899
  });
3280
3900
  },
3901
+ recoverCodex: async (row) => {
3902
+ const chatConfig = config.chats[row.chat_id];
3903
+ if (!chatConfig) return { ok: false };
3904
+ const selected = resolvePromptBackend({
3905
+ config,
3906
+ chatId: row.chat_id,
3907
+ threadId: row.thread_id == null ? null : String(row.thread_id),
3908
+ }) === 'codex'
3909
+ ? 'codex'
3910
+ : 'claude';
3911
+ if (selected !== 'codex') {
3912
+ logEvent('codex-replay-deferred', {
3913
+ chat_id: row.chat_id,
3914
+ thread_id: row.thread_id ?? null,
3915
+ msg_id: row.msg_id,
3916
+ reason: 'runtime-selection-changed',
3917
+ });
3918
+ return { ok: false };
3919
+ }
3920
+ const msg = reconstruct(row);
3921
+ msg._requiredProvider = 'codex';
3922
+ return redeliverAsFreshTurn({
3923
+ chatId: row.chat_id,
3924
+ msg,
3925
+ source: 'boot-replay-codex',
3926
+ preMark: 'replay-attempted',
3927
+ });
3928
+ },
3281
3929
  // CLEAN path — one visibility notice per (chat, thread). plainText so
3282
3930
  // no markdown/HTML parse on a boot send.
3283
3931
  sendNotice: async (g) => {
@@ -3295,11 +3943,12 @@ async function main() {
3295
3943
  });
3296
3944
 
3297
3945
  if (candidates.length > 0) {
3298
- console.log(`[replay] ${cleanShutdown ? 'clean restart' : 'crash'} — recovered ${result.recovered}, skipped ${result.skipped}, noticed ${result.noticed}${result.noticeFailed ? `, notice-failed ${result.noticeFailed}` : ''}`);
3946
+ console.log(`[replay] ${cleanShutdown ? 'clean restart' : 'crash'} — recovered ${result.recovered}, skipped ${result.skipped}, noticed ${result.noticed}${result.noticeFailed ? `, notice-failed ${result.noticeFailed}` : ''}${result.deferred ? `, deferred ${result.deferred}` : ''}`);
3299
3947
  logEvent('replay-on-boot', {
3300
3948
  bot: BOT_NAME, clean: cleanShutdown,
3301
3949
  recovered: result.recovered, skipped: result.skipped,
3302
3950
  noticed: result.noticed, notice_failed: result.noticeFailed,
3951
+ deferred: result.deferred || 0,
3303
3952
  total: candidates.length,
3304
3953
  });
3305
3954
  }