polygram 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 (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
@@ -0,0 +1,230 @@
1
+ 'use strict';
2
+
3
+ const { createHash, randomUUID } = require('node:crypto');
4
+
5
+ const CALLBACK_PREFIX = 'cxr';
6
+ const CALLBACK_TOKEN_BYTES = 16;
7
+
8
+ const ACTIONS = Object.freeze({
9
+ incorporated: Object.freeze({
10
+ callback: 'i',
11
+ disposition: 'incorporated',
12
+ reason: 'owner marked the ambiguous input incorporated via Telegram',
13
+ acknowledgement: 'Marked incorporated. No retry was authorized.',
14
+ }),
15
+ dismissed: Object.freeze({
16
+ callback: 'd',
17
+ disposition: 'dismissed',
18
+ reason: 'owner dismissed the ambiguous input without retry via Telegram',
19
+ acknowledgement: 'Dismissed without retry.',
20
+ }),
21
+ retry: Object.freeze({
22
+ callback: 'r',
23
+ disposition: 'retry-authorized',
24
+ reason: 'owner authorized one retry after the duplicate-risk warning via Telegram',
25
+ acknowledgement: 'One retry was authorized. It remains fenced by containment.',
26
+ }),
27
+ });
28
+
29
+ const ACTION_BY_CALLBACK = new Map(
30
+ Object.values(ACTIONS).map((action) => [action.callback, action]),
31
+ );
32
+
33
+ function attemptToken(attemptId) {
34
+ if (typeof attemptId !== 'string' || attemptId.length === 0) {
35
+ throw new TypeError('Codex reconciliation attempt ID is required');
36
+ }
37
+ return createHash('sha256')
38
+ .update(attemptId)
39
+ .digest('hex')
40
+ .slice(0, CALLBACK_TOKEN_BYTES * 2);
41
+ }
42
+
43
+ function callbackData(action, attemptId) {
44
+ const value = `${CALLBACK_PREFIX}:${action.callback}:${attemptToken(attemptId)}`;
45
+ if (Buffer.byteLength(value, 'utf8') > 64) {
46
+ throw new TypeError('Codex reconciliation callback exceeds Telegram limits');
47
+ }
48
+ return value;
49
+ }
50
+
51
+ function buildCodexReconciliationView(attempt) {
52
+ if (
53
+ !attempt
54
+ || typeof attempt.attempt_id !== 'string'
55
+ || typeof attempt.telegram_message_id !== 'string'
56
+ ) {
57
+ throw new TypeError('Codex reconciliation view requires a located attempt');
58
+ }
59
+ const quarantined = attempt.containment_status === 'quarantined';
60
+ const containment = quarantined
61
+ ? 'This decision does not release containment quarantine; the prescribed host reboot is still required.'
62
+ : 'This decision does not release containment quarantine.';
63
+ return {
64
+ text: [
65
+ '⚠️ Codex input needs reconciliation',
66
+ `Original Telegram message #${attempt.telegram_message_id} may already have been incorporated.`,
67
+ 'Choose one owner action. Retrying can duplicate output or side effects.',
68
+ containment,
69
+ ].join('\n'),
70
+ reply_markup: {
71
+ inline_keyboard: [
72
+ [
73
+ {
74
+ text: '✓ Mark incorporated',
75
+ callback_data: callbackData(ACTIONS.incorporated, attempt.attempt_id),
76
+ },
77
+ {
78
+ text: 'Dismiss without retry',
79
+ callback_data: callbackData(ACTIONS.dismissed, attempt.attempt_id),
80
+ },
81
+ ],
82
+ [{
83
+ text: '⚠ Authorize one retry (duplicate risk)',
84
+ callback_data: callbackData(ACTIONS.retry, attempt.attempt_id),
85
+ }],
86
+ ],
87
+ },
88
+ };
89
+ }
90
+
91
+ function configuredOperatorUserId(config) {
92
+ const configured = config?.bot?.operatorUserId
93
+ ?? config?.bot?.adminChatId;
94
+ const value = Number(configured);
95
+ return Number.isSafeInteger(value) && value > 0 ? value : null;
96
+ }
97
+
98
+ async function acknowledge(ctx, text, showAlert = false) {
99
+ if (typeof ctx?.answerCallbackQuery !== 'function') return;
100
+ await ctx.answerCallbackQuery({
101
+ text,
102
+ ...(showAlert ? { show_alert: true } : {}),
103
+ }).catch(() => {});
104
+ }
105
+
106
+ function createHandleCodexReconciliationCallback({
107
+ config,
108
+ db,
109
+ intentLock,
110
+ getSessionKey,
111
+ retryAttemptId = randomUUID,
112
+ now = Date.now,
113
+ isAuthorizedActor = null,
114
+ logger = console,
115
+ } = {}) {
116
+ if (
117
+ !config
118
+ || typeof db?.listUnresolvedCodexAttempts !== 'function'
119
+ || typeof db?.reconcileCodexAttempt !== 'function'
120
+ || typeof intentLock?.acquire !== 'function'
121
+ || typeof getSessionKey !== 'function'
122
+ || typeof retryAttemptId !== 'function'
123
+ || typeof now !== 'function'
124
+ ) {
125
+ throw new TypeError('Codex reconciliation handler dependencies are incomplete');
126
+ }
127
+
128
+ return async function handleCodexReconciliationCallback(ctx) {
129
+ const data = String(ctx?.callbackQuery?.data ?? '');
130
+ const match = /^cxr:([idr]):([a-f0-9]{32})$/.exec(data);
131
+ if (!match) return false;
132
+
133
+ const action = ACTION_BY_CALLBACK.get(match[1]);
134
+ const token = match[2];
135
+ const message = ctx.callbackQuery?.message;
136
+ const chatId = message?.chat?.id == null
137
+ ? null
138
+ : String(message.chat.id);
139
+ const chatConfig = chatId == null ? null : config.chats?.[chatId];
140
+ if (!chatConfig) {
141
+ await acknowledge(ctx, 'This reconciliation is not available.', true);
142
+ return true;
143
+ }
144
+
145
+ const actorId = ctx.from?.id ?? ctx.callbackQuery?.from?.id;
146
+ const operatorUserId = configuredOperatorUserId(config);
147
+ const allowed = typeof isAuthorizedActor === 'function'
148
+ ? await isAuthorizedActor({ actorId, chatId, ctx })
149
+ : (
150
+ operatorUserId != null
151
+ && Number(actorId) === operatorUserId
152
+ );
153
+ if (!allowed) {
154
+ await acknowledge(ctx, 'Not authorised to reconcile Codex input.', true);
155
+ return true;
156
+ }
157
+
158
+ const threadId = message.message_thread_id?.toString() ?? null;
159
+ const sessionKey = getSessionKey(chatId, threadId, chatConfig);
160
+ const release = await intentLock.acquire(sessionKey);
161
+ try {
162
+ const matching = db.listUnresolvedCodexAttempts({
163
+ session_key: sessionKey,
164
+ }).filter((attempt) => attemptToken(attempt.attempt_id) === token);
165
+ if (matching.length !== 1) {
166
+ await acknowledge(
167
+ ctx,
168
+ matching.length === 0
169
+ ? 'This input was already resolved or is no longer available.'
170
+ : 'Reconciliation identity conflict; no action was recorded.',
171
+ true,
172
+ );
173
+ return true;
174
+ }
175
+
176
+ const attempt = matching[0];
177
+ const input = {
178
+ attempt_id: attempt.attempt_id,
179
+ disposition: action.disposition,
180
+ actor: `telegram-user:${actorId}`,
181
+ reason: action.reason,
182
+ ts: now(),
183
+ };
184
+ if (action.disposition === 'retry-authorized') {
185
+ input.retry_attempt_id = retryAttemptId();
186
+ input.duplicate_risk_acknowledged = true;
187
+ }
188
+
189
+ try {
190
+ db.reconcileCodexAttempt(input);
191
+ } catch (error) {
192
+ if (
193
+ error?.code === 'CODEX_ATTEMPT_ALREADY_RECONCILED'
194
+ || error?.code === 'CODEX_ATTEMPT_NOT_AMBIGUOUS'
195
+ ) {
196
+ await acknowledge(ctx, 'This input was already resolved.', true);
197
+ return true;
198
+ }
199
+ if (error?.code === 'CODEX_RETRY_GENERATION_NOT_TERMINAL') {
200
+ await acknowledge(
201
+ ctx,
202
+ 'Wait for the prior Codex generation to finish before authorizing retry.',
203
+ true,
204
+ );
205
+ return true;
206
+ }
207
+ throw error;
208
+ }
209
+
210
+ if (typeof ctx.editMessageReplyMarkup === 'function') {
211
+ await ctx.editMessageReplyMarkup({ inline_keyboard: [] }).catch((error) => {
212
+ logger.error?.(
213
+ `Codex reconciliation UI update failed: ${error?.message || error}`,
214
+ );
215
+ });
216
+ }
217
+ await acknowledge(ctx, action.acknowledgement);
218
+ return true;
219
+ } finally {
220
+ release();
221
+ }
222
+ };
223
+ }
224
+
225
+ module.exports = {
226
+ ACTIONS,
227
+ attemptToken,
228
+ buildCodexReconciliationView,
229
+ createHandleCodexReconciliationCallback,
230
+ };
@@ -4,8 +4,8 @@
4
4
  * When a user taps a button on the config card, this routes:
5
5
  * 1. validate the new value;
6
6
  * 2. mutate chatConfig in-place + persist via db.logConfigChange;
7
- * 3. apply the change live to the running SDK Query via
8
- * pm.applyFlagSettings (effort) or pm.setModel (model);
7
+ * 3. apply the change live to Claude via its legacy methods, or select one
8
+ * complete Codex pair for the next turn via pm.selectModelSettings;
9
9
  * 4. re-render the card with the new ✓ marker;
10
10
  * 5. acknowledge the button press with a context-aware toast.
11
11
  *
@@ -19,6 +19,15 @@
19
19
  const { toTelegramHtml } = require('../telegram/format');
20
20
  const { resolveRichTextEnabled } = require('../telegram/rich');
21
21
  const { getTopicConfig, getConfigWriteScope } = require('../session-key');
22
+ const {
23
+ configuredRuntimeValue,
24
+ formatCodexSettingsOutcome,
25
+ getCodexCatalogEfforts,
26
+ getCodexCatalogModels,
27
+ isCodexRuntimeView,
28
+ resolveCodexEffortForModel,
29
+ withCodexSettingsOutcome,
30
+ } = require('./config-ui');
22
31
 
23
32
  const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
24
33
  const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
@@ -31,6 +40,8 @@ function createHandleConfigCallback({
31
40
  getSessionKey,
32
41
  formatConfigInfoText,
33
42
  buildConfigKeyboard,
43
+ resolveRuntimeView = null,
44
+ intentLock,
34
45
  saveConfig = () => {},
35
46
  botName,
36
47
  logger = console,
@@ -58,101 +69,325 @@ function createHandleConfigCallback({
58
69
  return;
59
70
  }
60
71
 
61
- if (isRichText) {
62
- if (value !== 'on' && value !== 'off') {
63
- await ctx.answerCallbackQuery({ text: 'Invalid richtext value' }).catch(() => {});
64
- return;
72
+ const callbackThreadIdEarly = ctx.callbackQuery.message?.message_thread_id?.toString() || null;
73
+ const callbackSessionKey = getSessionKey(
74
+ chatId,
75
+ callbackThreadIdEarly,
76
+ chatConfig,
77
+ );
78
+ const releaseIntent = await intentLock.acquire(callbackSessionKey);
79
+ let intentHeld = true;
80
+ const releaseConfigIntent = () => {
81
+ if (!intentHeld) return;
82
+ intentHeld = false;
83
+ releaseIntent();
84
+ };
85
+ let runtimeView = null;
86
+ try {
87
+ if (typeof resolveRuntimeView === 'function') {
88
+ try {
89
+ runtimeView = await resolveRuntimeView({
90
+ sessionKey: callbackSessionKey,
91
+ chatId,
92
+ threadId: callbackThreadIdEarly,
93
+ });
94
+ } catch (error) {
95
+ logger.error?.(
96
+ `[${botName}] config-callback runtime view failed: `
97
+ + `${error?.code || error?.name || 'unknown'}`,
98
+ );
99
+ releaseConfigIntent();
100
+ await ctx.answerCallbackQuery({
101
+ text: 'Configuration is temporarily unavailable',
102
+ show_alert: true,
103
+ }).catch(() => {});
104
+ return;
105
+ }
65
106
  }
66
- } else {
67
- const validValues = setting === 'model' ? MODEL_OPTIONS : EFFORT_OPTIONS;
68
- if (!validValues.includes(value)) {
69
- await ctx.answerCallbackQuery({ text: `Invalid ${setting}` }).catch(() => {});
107
+ const codex = isCodexRuntimeView(runtimeView);
108
+ let currentModel = null;
109
+
110
+ if (isRichText) {
111
+ if (value !== 'on' && value !== 'off') {
112
+ releaseConfigIntent();
113
+ await ctx.answerCallbackQuery({ text: 'Invalid richtext value' })
114
+ .catch(() => {});
115
+ return;
116
+ }
117
+ } else {
118
+ const topicConfig = getTopicConfig(chatConfig, callbackThreadIdEarly);
119
+ currentModel = configuredRuntimeValue(
120
+ chatConfig,
121
+ topicConfig,
122
+ runtimeView,
123
+ 'model',
124
+ );
125
+ const validValues = codex
126
+ ? setting === 'model'
127
+ ? getCodexCatalogModels(runtimeView).map((entry) => entry.model)
128
+ : getCodexCatalogEfforts(runtimeView, currentModel)
129
+ : setting === 'model' ? MODEL_OPTIONS : EFFORT_OPTIONS;
130
+ if (!validValues.includes(value)) {
131
+ releaseConfigIntent();
132
+ await ctx.answerCallbackQuery({ text: `Invalid ${setting}` })
133
+ .catch(() => {});
134
+ return;
135
+ }
136
+ }
137
+
138
+ // Write to the scope the card belongs to: a topic card targets THAT topic
139
+ // (so Music ≠ General), a chat-level card the chat root. Resolving the
140
+ // thread BEFORE the already-set check so "Already X" compares against the
141
+ // topic's effective value, not the chat root (2026-06-12 bug).
142
+ const { scope: writeScope, threadId: writeThreadId } =
143
+ getConfigWriteScope(chatConfig, callbackThreadIdEarly, {
144
+ allowNonIsolatedTopic: setting === 'richtext',
145
+ });
146
+ // The persisted camel-case key differs from the lowercase callback
147
+ // label used alongside model and effort.
148
+ const configField = isRichText
149
+ ? 'richText'
150
+ : codex
151
+ ? setting === 'model' ? 'codexModel' : 'codexEffort'
152
+ : setting;
153
+ const oldValue = isRichText
154
+ ? resolveRichTextEnabled(config, chatId, callbackThreadIdEarly)
155
+ : (
156
+ writeScope[configField] != null
157
+ ? writeScope[configField]
158
+ : chatConfig[configField] != null
159
+ ? chatConfig[configField]
160
+ : codex ? runtimeView[setting] : chatConfig[configField]
161
+ );
162
+ const snapshots = new Map([
163
+ [configField, {
164
+ present: Object.hasOwn(writeScope, configField),
165
+ value: writeScope[configField],
166
+ }],
167
+ ]);
168
+ if (codex && setting === 'model') {
169
+ snapshots.set('codexEffort', {
170
+ present: Object.hasOwn(writeScope, 'codexEffort'),
171
+ value: writeScope.codexEffort,
172
+ });
173
+ }
174
+ let effortAdjustment = null;
175
+ let selectedEffort = setting === 'effort' ? value : null;
176
+ if (codex && setting === 'model') {
177
+ const oldEffort = writeScope.codexEffort != null
178
+ ? writeScope.codexEffort
179
+ : chatConfig.codexEffort != null
180
+ ? chatConfig.codexEffort
181
+ : runtimeView.effort;
182
+ const nextEffort = resolveCodexEffortForModel(
183
+ runtimeView,
184
+ value,
185
+ oldEffort,
186
+ );
187
+ if (nextEffort == null) {
188
+ releaseConfigIntent();
189
+ await ctx.answerCallbackQuery({
190
+ text: 'Selected model has no authenticated default effort',
191
+ show_alert: true,
192
+ }).catch(() => {});
193
+ return;
194
+ }
195
+ selectedEffort = nextEffort;
196
+ if (nextEffort !== oldEffort) {
197
+ effortAdjustment = { oldValue: oldEffort, newValue: nextEffort };
198
+ }
199
+ }
200
+ if (oldValue === parsedValue) {
201
+ releaseConfigIntent();
202
+ await ctx.answerCallbackQuery({ text: `Already ${value}` })
203
+ .catch(() => {});
70
204
  return;
71
205
  }
72
- }
73
206
 
74
- // Write to the scope the card belongs to: a topic card targets THAT topic
75
- // (so Music ≠ General), a chat-level card the chat root. Resolving the
76
- // thread BEFORE the already-set check so "Already X" compares against the
77
- // topic's effective value, not the chat root (2026-06-12 bug).
78
- const callbackThreadIdEarly = ctx.callbackQuery.message?.message_thread_id?.toString() || null;
79
- const { scope: writeScope, threadId: writeThreadId } =
80
- getConfigWriteScope(chatConfig, callbackThreadIdEarly, {
81
- allowNonIsolatedTopic: setting === 'richtext',
82
- });
83
- // The persisted camel-case key differs from the lowercase callback
84
- // label used alongside model and effort.
85
- const configField = isRichText ? 'richText' : setting;
86
- const oldValue = isRichText
87
- ? resolveRichTextEnabled(config, chatId, callbackThreadIdEarly)
88
- : (writeScope[configField] != null ? writeScope[configField] : chatConfig[configField]);
89
- if (oldValue === parsedValue) {
90
- await ctx.answerCallbackQuery({ text: `Already ${value}` }).catch(() => {});
91
- return;
92
- }
207
+ writeScope[configField] = parsedValue;
208
+ if (effortAdjustment) {
209
+ writeScope.codexEffort = effortAdjustment.newValue;
210
+ }
211
+ try {
212
+ await saveConfig();
213
+ } catch (error) {
214
+ for (const [field, snapshot] of snapshots) {
215
+ if (snapshot.present) writeScope[field] = snapshot.value;
216
+ else delete writeScope[field];
217
+ }
218
+ logger.error?.(
219
+ `[${botName}] config-callback saveConfig failed: ${error.message}`,
220
+ );
221
+ releaseConfigIntent();
222
+ await ctx.answerCallbackQuery({
223
+ text: `Couldn't save ${setting}; nothing changed`,
224
+ show_alert: true,
225
+ }).catch(() => {});
226
+ return;
227
+ }
228
+ const cmdUserId = ctx.callbackQuery.from?.id || null;
229
+ const cmdUser = ctx.callbackQuery.from?.first_name
230
+ || ctx.callbackQuery.from?.username
231
+ || null;
232
+ const auditRows = [{
233
+ chat_id: chatId, thread_id: writeThreadId,
234
+ // The audit schema intentionally keeps provider-neutral field names.
235
+ field: isRichText ? configField : setting,
236
+ old_value: oldValue, new_value: parsedValue,
237
+ user: cmdUser, user_id: cmdUserId, source: 'inline-button',
238
+ }];
239
+ if (effortAdjustment) {
240
+ auditRows.push({
241
+ chat_id: chatId,
242
+ thread_id: writeThreadId,
243
+ field: 'effort',
244
+ old_value: effortAdjustment.oldValue,
245
+ new_value: effortAdjustment.newValue,
246
+ user: cmdUser,
247
+ user_id: cmdUserId,
248
+ source: 'inline-button',
249
+ });
250
+ }
251
+ if (codex && !isRichText) {
252
+ try {
253
+ db.logConfigChanges(auditRows);
254
+ } catch (error) {
255
+ for (const [field, snapshot] of snapshots) {
256
+ if (snapshot.present) writeScope[field] = snapshot.value;
257
+ else delete writeScope[field];
258
+ }
259
+ let rollbackFailed = false;
260
+ try {
261
+ await saveConfig();
262
+ } catch (rollbackError) {
263
+ rollbackFailed = true;
264
+ logger.error?.(
265
+ `[${botName}] config-callback rollback failed: `
266
+ + rollbackError.message,
267
+ );
268
+ }
269
+ logger.error?.(
270
+ `[${botName}] config-callback audit failed: ${error.message}`,
271
+ );
272
+ releaseConfigIntent();
273
+ await ctx.answerCallbackQuery({
274
+ text: rollbackFailed
275
+ ? `Couldn't audit ${setting}; live process unchanged, `
276
+ + 'persisted config needs attention'
277
+ : `Couldn't audit ${setting}; nothing changed`,
278
+ show_alert: true,
279
+ }).catch(() => {});
280
+ return;
281
+ }
282
+ } else {
283
+ dbWrite(() => {
284
+ for (const row of auditRows) db.logConfigChange(row);
285
+ }, `log ${configField} change`);
286
+ }
93
287
 
94
- writeScope[configField] = parsedValue;
95
- // Persist to config.json so the change survives a restart — without this,
96
- // every /model tap was lost on the next deploy (2026-06-12). Best-effort:
97
- // never let a disk hiccup swallow the in-memory change + live application.
98
- try { saveConfig(); }
99
- catch (err) { logger.error?.(`[${botName}] config-callback saveConfig failed: ${err.message}`); }
100
- const cmdUserId = ctx.callbackQuery.from?.id || null;
101
- const cmdUser = ctx.callbackQuery.from?.first_name || ctx.callbackQuery.from?.username || null;
102
- dbWrite(() => db.logConfigChange({
103
- chat_id: chatId, thread_id: writeThreadId, field: configField,
104
- old_value: oldValue, new_value: parsedValue,
105
- user: cmdUser, user_id: cmdUserId, source: 'inline-button',
106
- }), `log ${configField} change`);
288
+ // Graceful application to the topic's session. Claude keeps its
289
+ // existing live SDK calls. Codex receives one complete pair; expected
290
+ // lifecycle states are reported by a discriminated outcome.
291
+ const callbackThreadId = callbackThreadIdEarly;
292
+ let applied = false;
293
+ let settingsResult = null;
294
+ if (codex && !isRichText) {
295
+ const selectedModel = setting === 'model' ? value : currentModel;
296
+ try {
297
+ settingsResult = await pm.selectModelSettings(callbackSessionKey, {
298
+ model: selectedModel,
299
+ effort: selectedEffort,
300
+ });
301
+ } catch (error) {
302
+ logger.error?.(
303
+ `[${botName}] config-callback Codex selection failed: `
304
+ + `${error?.code || error?.name || 'unknown'}`,
305
+ );
306
+ settingsResult = {
307
+ outcome: 'live-status-unknown',
308
+ nextTurn: {
309
+ model: selectedModel,
310
+ effort: selectedEffort,
311
+ },
312
+ };
313
+ }
314
+ runtimeView = withCodexSettingsOutcome(runtimeView, settingsResult);
315
+ } else if (!codex && setting === 'effort') {
316
+ applied = await pm.applyFlagSettings(
317
+ callbackSessionKey,
318
+ { effortLevel: value },
319
+ );
320
+ } else if (!codex && setting === 'model') {
321
+ applied = await pm.setModel(callbackSessionKey, value);
322
+ }
323
+ const anyActive = !applied;
324
+ releaseConfigIntent();
107
325
 
108
- // Graceful application to the topic's session. SDK pm applies live
109
- // via setModel / applyFlagSettings; chatConfig is already updated
110
- // on disk above so a missing live session still picks up the new
111
- // value on its next cold spawn. richText is NOT a spawn-time flag —
112
- // delivery reads it live at send time (lib/telegram/rich.js), so
113
- // there is nothing to apply to a running session; only the system-
114
- // prompt authoring hint lags to the next spawn, which the rich-text
115
- // acknowledgement explains separately from model/effort changes.
116
- const callbackThreadId = callbackThreadIdEarly;
117
- const callbackSessionKey = getSessionKey(chatId, callbackThreadId, chatConfig);
118
- let applied = false;
119
- if (setting === 'effort') {
120
- applied = await pm.applyFlagSettings(callbackSessionKey, { effortLevel: value });
121
- } else if (setting === 'model') {
122
- applied = await pm.setModel(callbackSessionKey, value);
123
- }
124
- const anyActive = !applied;
326
+ // Re-render the card with the new ✓ marker. Detect original card
327
+ // type (model-only / effort-only / both) by counting rows in the
328
+ // existing reply_markup so the user sees the same layout they
329
+ // tapped into.
330
+ const existingRows =
331
+ ctx.callbackQuery.message?.reply_markup?.inline_keyboard?.length || 0;
332
+ const showRow = existingRows >= 2 ? 'all' : setting;
333
+ const _cbTopicCfg = getTopicConfig(chatConfig, callbackThreadId);
334
+ const effectiveRichText = resolveRichTextEnabled(
335
+ config,
336
+ chatId,
337
+ callbackThreadId,
338
+ );
339
+ const newInfo = await formatConfigInfoText(
340
+ chatConfig,
341
+ showRow,
342
+ codex ? callbackSessionKey : chatId,
343
+ _cbTopicCfg,
344
+ effectiveRichText,
345
+ runtimeView,
346
+ );
347
+ const newKeyboard = buildConfigKeyboard(
348
+ chatConfig,
349
+ showRow,
350
+ _cbTopicCfg,
351
+ effectiveRichText,
352
+ runtimeView,
353
+ );
354
+ try {
355
+ const { text: html, parseMode } = toTelegramHtml(newInfo);
356
+ await ctx.editMessageText(html, {
357
+ reply_markup: newKeyboard,
358
+ ...(parseMode && { parse_mode: parseMode }),
359
+ });
360
+ } catch (err) {
361
+ logger.error?.(`[${botName}] config-card edit failed: ${err.message}`);
362
+ }
125
363
 
126
- // Re-render the card with the new ✓ marker. Detect original card
127
- // type (model-only / effort-only / both) by counting rows in the
128
- // existing reply_markup so the user sees the same layout they
129
- // tapped into.
130
- const existingRows = ctx.callbackQuery.message?.reply_markup?.inline_keyboard?.length || 0;
131
- const showRow = existingRows >= 2 ? 'all' : setting;
132
- // Re-render with per-topic overrides resolved (topic > chat), so the agent
133
- // line doesn't flip back to the chat-level default after a button tap —
134
- // mirrors the /model command card (polygram.js). getTopicConfig returns {}
135
- // for the chat-level card.
136
- const _cbTopicCfg = getTopicConfig(chatConfig, callbackThreadId);
137
- const effectiveRichText = resolveRichTextEnabled(config, chatId, callbackThreadId);
138
- const newInfo = formatConfigInfoText(chatConfig, showRow, chatId, _cbTopicCfg, effectiveRichText);
139
- const newKeyboard = buildConfigKeyboard(chatConfig, showRow, _cbTopicCfg, effectiveRichText);
140
- try {
141
- const { text: html, parseMode } = toTelegramHtml(newInfo);
142
- await ctx.editMessageText(html, {
143
- reply_markup: newKeyboard,
144
- ...(parseMode && { parse_mode: parseMode }),
145
- });
146
- } catch (err) {
147
- logger.error?.(`[${botName}] config-card edit failed: ${err.message}`);
364
+ // Rich delivery changes immediately, but its authoring hint changes
365
+ // on the next session spawn; surface both timings in the acknowledgement.
366
+ const ackText = isRichText
367
+ ? (
368
+ parsedValue
369
+ ? 'Rich text → on (delivery live; agent authors for it next session)'
370
+ : 'Rich text → off'
371
+ )
372
+ : codex
373
+ ? (
374
+ `${setting} → ${value}`
375
+ + (
376
+ effortAdjustment
377
+ ? `; effort → ${effortAdjustment.newValue}`
378
+ : ''
379
+ )
380
+ + formatCodexSettingsOutcome(settingsResult)
381
+ )
382
+ : (
383
+ anyActive
384
+ ? `${setting} → ${value} — switching when finished`
385
+ : `${setting} → ${value}`
386
+ );
387
+ await ctx.answerCallbackQuery({ text: ackText }).catch(() => {});
388
+ } finally {
389
+ releaseConfigIntent();
148
390
  }
149
-
150
- // Rich delivery changes immediately, but its authoring hint changes
151
- // on the next session spawn; surface both timings in the acknowledgement.
152
- const ackText = isRichText
153
- ? (parsedValue ? 'Rich text → on (delivery live; agent authors for it next session)' : 'Rich text → off')
154
- : (anyActive ? `${setting} → ${value} — switching when finished` : `${setting} → ${value}`);
155
- await ctx.answerCallbackQuery({ text: ackText }).catch(() => {});
156
391
  };
157
392
  }
158
393