polygram 0.34.1 → 0.36.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.34.1",
4
+ "version": "0.36.0",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -66,7 +66,7 @@
66
66
  },
67
67
 
68
68
  "codex": {
69
- "_comment": "Optional native macOS arm64 or Linux x64 Codex app-server beta. Codex stays disabled unless a chat/topic explicitly sets pm:'codex' and exact target-specific startup preflight succeeds; other host targets, including Windows, are rejected. `home` must be a dedicated non-temporary 0700 CODEX_HOME already authenticated with ChatGPT; polygram never copies or reads credential contents. `binary` must be the canonical immutable path to the reviewed target's pinned Codex CLI, not a PATH symlink. On Linux set POLYGRAM_CODEX_TMPDIR to an owner-only 0700 runtime directory; otherwise Codex inherits the daemon TMPDIR after the same safety checks. Every directory containing daemon credentials or control data must be listed in daemonSecretRoots and must not overlap a Codex workspace or CODEX_HOME. Command network and model-native web search are disabled; product MCP tools and interactive approvals are unavailable. Keep long-running commands in the foreground. Detached/background servers are unsupported and may survive hard runtime loss.",
69
+ "_comment": "Optional native macOS arm64 or Linux x64 Codex app-server beta. Codex is default-off: a chat/topic must explicitly set codexEnabled:true before pm:'codex' can be selected or dispatched, and exact target-specific startup preflight must succeed; other host targets, including Windows, are rejected. `home` must be a dedicated non-temporary 0700 CODEX_HOME already authenticated with ChatGPT; polygram never copies or reads credential contents. `binary` must be the canonical immutable path to the reviewed target's pinned Codex CLI, not a PATH symlink. On Linux set POLYGRAM_CODEX_TMPDIR to an owner-only 0700 runtime directory; otherwise Codex inherits the daemon TMPDIR after the same safety checks. Every directory containing daemon credentials or control data must be listed in daemonSecretRoots and must not overlap a Codex workspace or CODEX_HOME. Command network and model-native web search are disabled; product MCP tools and interactive approvals are unavailable. Keep long-running commands in the foreground. Detached/background servers are unsupported and may survive hard runtime loss.",
70
70
  "home": "/Users/you/.codex-polygram",
71
71
  "binary": "/Users/you/.local/share/codex/versions/0.145.0/codex",
72
72
  "daemonSecretRoots": [
@@ -94,6 +94,7 @@
94
94
  "name": "Codex beta DM",
95
95
  "bot": "admin-bot",
96
96
  "_comment_pm": "Codex uses the pinned app-server SDK surface directly (no tmux). Native macOS arm64 and Linux x64 beta: command network and model-native web search are disabled; product MCP tools and interactive approvals are unavailable; long-running commands must stay in the foreground; detached/background servers are unsupported and may survive hard runtime loss. The model and effort are accepted only when returned by this deployment's authenticated ChatGPT-backed model catalog.",
97
+ "codexEnabled": true,
97
98
  "pm": "codex",
98
99
  "codexModel": "gpt-5.6-sol",
99
100
  "codexEffort": "xhigh",
@@ -145,6 +145,14 @@ function mapCodes(codes, shape) {
145
145
  }
146
146
 
147
147
  const CODEX_ERROR_SHAPES = Object.freeze({
148
+ scopeDisabled: stableCodeShape(
149
+ 'codexScopeDisabled',
150
+ 'Codex is not enabled for this chat',
151
+ ),
152
+ runtimeSelectionIncomplete: stableCodeShape(
153
+ 'codexRuntimeSelectionIncomplete',
154
+ 'Codex is enabled, but its model, effort, or workspace is not configured for this chat',
155
+ ),
148
156
  auth: stableCodeShape(
149
157
  'codexAuthUnavailable',
150
158
  '🔑 Codex authentication is unavailable. The operator needs to re-authenticate the pinned deployment.',
@@ -192,6 +200,9 @@ const CODEX_ERROR_SHAPES = Object.freeze({
192
200
  });
193
201
 
194
202
  const CODEX_ERROR_CODES = {
203
+ CODEX_SCOPE_DISABLED: CODEX_ERROR_SHAPES.scopeDisabled,
204
+ CODEX_RUNTIME_SELECTION_INCOMPLETE:
205
+ CODEX_ERROR_SHAPES.runtimeSelectionIncomplete,
195
206
  ...mapCodes([
196
207
  'CODEX_AUTH_UNAVAILABLE',
197
208
  ], CODEX_ERROR_SHAPES.auth),
@@ -18,7 +18,11 @@
18
18
  const { toTelegramHtml } = require('../telegram/format');
19
19
  const { resolveRichTextEnabled } = require('../telegram/rich');
20
20
  const { getTopicConfig, getConfigWriteScope } = require('../session-key');
21
- const { resolveRuntimeDescriptor } = require('../runtime-config');
21
+ const {
22
+ resolveCodexEnabled,
23
+ resolveRuntimeDescriptor,
24
+ } = require('../runtime-config');
25
+ const { classify } = require('../error/classify');
22
26
  const {
23
27
  configuredRuntimeValue,
24
28
  formatCodexSettingsOutcome,
@@ -49,7 +53,19 @@ const BLOCKED_RUNTIME_STATES = new Set([
49
53
  ]);
50
54
 
51
55
  function runtimeLabel(runtime) {
52
- return RUNTIME_OPTION_BY_BACKEND.get(runtime)?.label ?? 'Claude SDK';
56
+ // Only ever called for a runtime that has a button — the callback rejects
57
+ // anything else as an invalid runtime before this point.
58
+ return RUNTIME_OPTION_BY_BACKEND.get(runtime).label;
59
+ }
60
+
61
+ function codexSelectionErrorMessage(error) {
62
+ if (
63
+ error?.code === 'CODEX_SCOPE_DISABLED'
64
+ || error?.code === 'CODEX_RUNTIME_SELECTION_INCOMPLETE'
65
+ ) {
66
+ return classify(error).userMessage;
67
+ }
68
+ return 'Codex is unavailable; check login and runtime diagnostics';
53
69
  }
54
70
 
55
71
  function runtimeSwitchBlocked(proc) {
@@ -133,6 +149,17 @@ function createHandleConfigCallback({
133
149
  .catch(() => {});
134
150
  return;
135
151
  }
152
+ if (
153
+ value === 'codex'
154
+ && !resolveCodexEnabled(config, chatId, callbackThreadIdEarly)
155
+ ) {
156
+ releaseConfigIntent();
157
+ await ctx.answerCallbackQuery({
158
+ text: codexSelectionErrorMessage({ code: 'CODEX_SCOPE_DISABLED' }),
159
+ show_alert: true,
160
+ }).catch(() => {});
161
+ return;
162
+ }
136
163
  const currentSelection = resolveRuntimeDescriptor({
137
164
  config,
138
165
  chatId,
@@ -188,7 +215,7 @@ function createHandleConfigCallback({
188
215
  );
189
216
  releaseConfigIntent();
190
217
  await ctx.answerCallbackQuery({
191
- text: 'Codex is unavailable; check login and runtime diagnostics',
218
+ text: codexSelectionErrorMessage(error),
192
219
  show_alert: true,
193
220
  }).catch(() => {});
194
221
  return;
@@ -387,6 +414,11 @@ function createHandleConfigCallback({
387
414
  backend: value,
388
415
  configuredPm: value,
389
416
  selectionSource: writeThreadId ? 'topic' : 'chat',
417
+ codexEnabled: resolveCodexEnabled(
418
+ config,
419
+ chatId,
420
+ callbackThreadIdEarly,
421
+ ),
390
422
  };
391
423
  }
392
424
  const topicConfig = getTopicConfig(
@@ -485,10 +517,15 @@ function createHandleConfigCallback({
485
517
  // (so Music ≠ General), a chat-level card the chat root. Resolving the
486
518
  // thread BEFORE the already-set check so "Already X" compares against the
487
519
  // topic's effective value, not the chat root (2026-06-12 bug).
520
+ //
521
+ // Rich text follows session isolation like every other setting. Delivery
522
+ // resolves it per message with the real thread, but the agent's authoring
523
+ // hint is spawn-time state resolved with the SESSION's thread — which a
524
+ // chat without isolateTopics collapses to null. A topic-level write there
525
+ // would flip delivery while the hint never drifts: no respawn, and an ack
526
+ // promising a change that never arrives.
488
527
  const { scope: writeScope, threadId: writeThreadId } =
489
- getConfigWriteScope(chatConfig, callbackThreadIdEarly, {
490
- allowNonIsolatedTopic: setting === 'richtext',
491
- });
528
+ getConfigWriteScope(chatConfig, callbackThreadIdEarly);
492
529
  // The persisted camel-case key differs from the lowercase callback
493
530
  // label used alongside model and effort.
494
531
  const configField = isRichText
@@ -543,13 +580,78 @@ function createHandleConfigCallback({
543
580
  effortAdjustment = { oldValue: oldEffort, newValue: nextEffort };
544
581
  }
545
582
  }
583
+ // Rich text has no per-topic scope in a chat whose topics share one
584
+ // session: there is a single hint, so there can be a single value. Any
585
+ // topics[tid].richText there was written by the pre-fix behavior and can
586
+ // never be honored by the agent — but delivery resolves topic-first, so
587
+ // it still shadows the chat value in its own topic. Clearing every one of
588
+ // them is part of writing at chat level, not a separate cleanup.
589
+ const clearStaleTopicRichText = () => {
590
+ if (!isRichText || chatConfig?.isolateTopics === true) return [];
591
+ const cleared = [];
592
+ for (const [tid, topic] of Object.entries(chatConfig.topics || {})) {
593
+ if (
594
+ topic
595
+ && typeof topic === 'object'
596
+ && Object.hasOwn(topic, configField)
597
+ ) {
598
+ cleared.push({ threadId: String(tid), scope: topic, value: topic[configField] });
599
+ delete topic[configField];
600
+ }
601
+ }
602
+ return cleared;
603
+ };
604
+ const restoreTopicRichText = (cleared) => {
605
+ for (const { scope, value } of cleared) scope[configField] = value;
606
+ };
607
+ // The user tapped one card; topics they never touched can change value
608
+ // with it. Record which ones and what they held, so a config that moved
609
+ // on its own is still explicable afterwards. Logged only once the sweep
610
+ // is persisted — an event for a rolled-back sweep is worse than none.
611
+ const logTopicRichTextSweep = (cleared) => {
612
+ if (!cleared.length) return;
613
+ dbWrite(() => {
614
+ db.logEvent('config-topic-override-swept', {
615
+ chat_id: chatId,
616
+ field: configField,
617
+ topics: cleared.map(({ threadId, value }) => ({
618
+ thread_id: threadId,
619
+ old_value: value,
620
+ })),
621
+ });
622
+ }, 'log topic-override sweep');
623
+ };
624
+
546
625
  if (oldValue === parsedValue) {
626
+ // Nothing to write — but a stale override is exactly why this tap can
627
+ // be a no-op, and leaving it keeps delivery and the session's hint
628
+ // disagreeing. Normalize the scope and keep the value the ack states:
629
+ // the chat level is the only place the shared session reads.
630
+ const cleared = clearStaleTopicRichText();
631
+ if (cleared.length) {
632
+ const hadChatValue = Object.hasOwn(chatConfig, configField);
633
+ const previousChatValue = chatConfig[configField];
634
+ chatConfig[configField] = parsedValue;
635
+ try {
636
+ await saveConfig();
637
+ logTopicRichTextSweep(cleared);
638
+ } catch (error) {
639
+ restoreTopicRichText(cleared);
640
+ if (hadChatValue) chatConfig[configField] = previousChatValue;
641
+ else delete chatConfig[configField];
642
+ logger.error?.(
643
+ `[${botName}] config-callback scope normalization failed: `
644
+ + error.message,
645
+ );
646
+ }
647
+ }
547
648
  releaseConfigIntent();
548
649
  await ctx.answerCallbackQuery({ text: `Already ${value}` })
549
650
  .catch(() => {});
550
651
  return;
551
652
  }
552
653
 
654
+ const clearedTopicRichText = clearStaleTopicRichText();
553
655
  writeScope[configField] = parsedValue;
554
656
  if (effortAdjustment) {
555
657
  writeScope.codexEffort = effortAdjustment.newValue;
@@ -561,6 +663,7 @@ function createHandleConfigCallback({
561
663
  if (snapshot.present) writeScope[field] = snapshot.value;
562
664
  else delete writeScope[field];
563
665
  }
666
+ restoreTopicRichText(clearedTopicRichText);
564
667
  logger.error?.(
565
668
  `[${botName}] config-callback saveConfig failed: ${error.message}`,
566
669
  );
@@ -571,6 +674,7 @@ function createHandleConfigCallback({
571
674
  }).catch(() => {});
572
675
  return;
573
676
  }
677
+ logTopicRichTextSweep(clearedTopicRichText);
574
678
  const cmdUserId = ctx.callbackQuery.from?.id || null;
575
679
  const cmdUser = ctx.callbackQuery.from?.first_name
576
680
  || ctx.callbackQuery.from?.username
@@ -707,14 +811,11 @@ function createHandleConfigCallback({
707
811
  logger.error?.(`[${botName}] config-card edit failed: ${err.message}`);
708
812
  }
709
813
 
710
- // Rich delivery changes immediately, but its authoring hint changes
711
- // on the next session spawn; surface both timings in the acknowledgement.
814
+ // Delivery changes immediately and the authoring hint follows on the
815
+ // user's next message: a hint drift reloads the session (conversation
816
+ // preserved), so there is no lag left worth a caveat.
712
817
  const ackText = isRichText
713
- ? (
714
- parsedValue
715
- ? 'Rich text → on (delivery live; agent authors for it next session)'
716
- : 'Rich text → off'
717
- )
818
+ ? `Rich text → ${parsedValue ? 'on' : 'off'}`
718
819
  : codex
719
820
  ? (
720
821
  `${setting} → ${value}`
@@ -4,23 +4,31 @@
4
4
  * the runtime/model/effort callback re-render paths
5
5
  * (lib/handlers/config-callback.js).
6
6
  *
7
- * Pure functions (no DB / fs) but `formatConfigInfoText` needs
8
- * runtime context (pm to check warm/cold, db + getClaudeSessionId
9
- * to fetch session id) → factory wraps it. Keyboard builder is a
10
- * top-level export.
7
+ * Pure functions (no DB / fs). `formatConfigInfoText` is factory-wrapped
8
+ * only so a caller can supply the async runtime-view resolver; the
9
+ * keyboard builder is a top-level export.
11
10
  *
12
11
  * MODEL_VERSIONS_DESC bumps with each Claude release — see polygram's
13
12
  * release notes for the verification step (`claude --model <alias>`
14
- * + check the system:init event's `model` field).
13
+ * + check the system:init event's `model` field). Surfaced by the
14
+ * /model reply, not by the card.
15
15
  */
16
16
 
17
17
  'use strict';
18
18
 
19
19
  const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
20
20
  const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
21
+ const CODEX_MODEL_OPTIONS = new Set([
22
+ 'gpt-5.6-sol',
23
+ 'gpt-5.6-terra',
24
+ 'gpt-5.6-luna',
25
+ ]);
26
+ // Which runtimes the card offers. The sdk backend stays fully supported in
27
+ // code and config files — it is per-token and nobody picks it from a chat, so
28
+ // it has no button. Codex is filtered below unless enabled for the current
29
+ // scope. A chat configured on sdk simply shows no ✓ on this row.
21
30
  const RUNTIME_OPTIONS = Object.freeze([
22
- Object.freeze({ backend: 'sdk', label: 'Claude SDK' }),
23
- Object.freeze({ backend: 'cli', label: 'Claude CLI' }),
31
+ Object.freeze({ backend: 'cli', label: 'Claude' }),
24
32
  Object.freeze({ backend: 'codex', label: 'Codex' }),
25
33
  ]);
26
34
 
@@ -44,22 +52,6 @@ function canonicalRuntimeBackend(runtimeView) {
44
52
  return 'sdk';
45
53
  }
46
54
 
47
- function runtimeSelectionSource(runtimeView) {
48
- const source = runtimeView?.selectionSource
49
- ?? runtimeView?.source
50
- ?? runtimeView?.sources?.pm;
51
- return typeof source === 'string' && source.length > 0 ? source : null;
52
- }
53
-
54
- function formatRuntimeLine(runtimeView) {
55
- const backend = canonicalRuntimeBackend(runtimeView);
56
- const label = backend === 'codex'
57
- ? 'Codex app-server'
58
- : backend === 'cli' ? 'Claude CLI' : 'Claude SDK';
59
- const source = runtimeSelectionSource(runtimeView);
60
- return `Runtime: ${label}${source ? ` (source: ${source})` : ''}`;
61
- }
62
-
63
55
  function uniqueStrings(values) {
64
56
  if (!Array.isArray(values)) return [];
65
57
  return [...new Set(values.filter(
@@ -77,6 +69,7 @@ function getCodexCatalogModels(runtimeView) {
77
69
  !entry
78
70
  || typeof entry.model !== 'string'
79
71
  || entry.model.length === 0
72
+ || !CODEX_MODEL_OPTIONS.has(entry.model)
80
73
  || seen.has(entry.model)
81
74
  ) {
82
75
  return false;
@@ -118,13 +111,6 @@ function formatSettingsPair(settings) {
118
111
  return `${settings.model}/${settings.effort}`;
119
112
  }
120
113
 
121
- function sameSettingsPair(left, right) {
122
- return (
123
- left?.model === right?.model
124
- && left?.effort === right?.effort
125
- );
126
- }
127
-
128
114
  function formatCodexSettingsOutcome(result) {
129
115
  const next = formatSettingsPair(result?.nextTurn);
130
116
  switch (result?.outcome) {
@@ -265,10 +251,14 @@ function buildConfigKeyboard(
265
251
  const rows = [];
266
252
  if (show === 'all') {
267
253
  const currentBackend = canonicalRuntimeBackend(runtimeView);
268
- rows.push(RUNTIME_OPTIONS.map(({ backend, label }) => ({
269
- text: backend === currentBackend ? `✓ ${label}` : label,
270
- callback_data: `cfg:runtime:${backend}`,
271
- })));
254
+ rows.push(RUNTIME_OPTIONS
255
+ .filter(({ backend }) => (
256
+ backend !== 'codex' || runtimeView?.codexEnabled === true
257
+ ))
258
+ .map(({ backend, label }) => ({
259
+ text: backend === currentBackend ? `✓ ${label}` : label,
260
+ callback_data: `cfg:runtime:${backend}`,
261
+ })));
272
262
  }
273
263
  if ((show === 'model' || show === 'all') && modelOptions.length > 0) {
274
264
  rows.push(modelOptions.map((entry) => {
@@ -301,18 +291,14 @@ function buildConfigKeyboard(
301
291
  }
302
292
 
303
293
  /**
304
- * Factory for the card-body formatter. Needs runtime pm + db + a
305
- * getClaudeSessionId fetcher.
294
+ * Factory for the card-body formatter.
306
295
  *
307
- * @param {object} deps
308
- * @param {object} deps.pm
309
- * @param {object} deps.db
310
- * @param {(db, sessionKey) => string|null} deps.getClaudeSessionId
296
+ * @param {object} [deps]
297
+ * @param {(ctx: {sessionKey: string, chatId: string, threadId: ?string}) => object} [deps.resolveRuntimeView]
298
+ * — resolves the runtime view for callers that don't pass one; the view
299
+ * decides which runtime's help sections the card carries.
311
300
  */
312
301
  function createFormatConfigInfoText({
313
- pm,
314
- db,
315
- getClaudeSessionId,
316
302
  resolveRuntimeView = null,
317
303
  } = {}) {
318
304
  function formatWithRuntimeView(
@@ -323,148 +309,18 @@ function createFormatConfigInfoText({
323
309
  effectiveRichText,
324
310
  runtimeView,
325
311
  ) {
326
- const alive = pm.has(sessionKey) && !pm.get(sessionKey).closed;
327
312
  const codex = isCodexRuntimeView(runtimeView);
328
- // Per-topic overrides win over chat-level for the displayed values,
329
- // mirroring the spawn path (polygram.js: topicConfig.agent ||
330
- // chatConfig.agent). Pre-fix the card always read chat-level, so a topic's
331
- // /model showed the WRONG agent — shumorobot Music topic (thread 3) showed
332
- // "Agent: shumabit" instead of its music-curation:music-curator override
333
- // (2026-06-03). topicConfig defaults to null (chat-level) for callers with
334
- // no active topic.
335
- const model = configuredRuntimeValue(
336
- chatConfig,
337
- topicConfig,
338
- runtimeView,
339
- 'model',
340
- );
341
- const effort = configuredRuntimeValue(
342
- chatConfig,
343
- topicConfig,
344
- runtimeView,
345
- 'effort',
346
- );
313
+ // The selected Codex model decides which reasoning efforts its help section
314
+ // may list — per-topic overrides win over chat-level, mirroring the spawn
315
+ // path. Nothing else about the configuration is read here: the buttons under
316
+ // the card carry it, each with its own ✓.
347
317
  const codexModels = codex ? getCodexCatalogModels(runtimeView) : [];
318
+ const codexModel = codex
319
+ ? configuredRuntimeValue(chatConfig, topicConfig, runtimeView, 'model')
320
+ : null;
348
321
  const codexEfforts = codex
349
- ? getCodexCatalogEfforts(runtimeView, model)
322
+ ? getCodexCatalogEfforts(runtimeView, codexModel)
350
323
  : [];
351
- const agent = (topicConfig && topicConfig.agent) || chatConfig.agent;
352
- const codexModelEntry = codex
353
- ? codexModels
354
- .find((entry) => entry.model === model)
355
- : null;
356
- const codexDisplayName = codexModelEntry?.displayName;
357
- const ver = codex
358
- ? (
359
- typeof codexDisplayName === 'string'
360
- && codexDisplayName.length > 0
361
- && codexDisplayName !== model
362
- ? codexDisplayName
363
- : model
364
- )
365
- : MODEL_VERSIONS_DESC[model] || model;
366
- const sess = codex
367
- ? 'managed by Codex'
368
- : getClaudeSessionId(db, sessionKey)?.slice(0, 8) || 'new';
369
- // Running vs configured: cli can't hot-swap model/effort, so a /model or
370
- // /effort change is PENDING until the session reloads (on the next message).
371
- // Show the truth — the live proc's spawn-time value (proc.model/proc.effort)
372
- // vs the configured one — so the card never claims a model the session
373
- // isn't actually running (the "says opus, runs sonnet" confusion). SDK
374
- // applies live (its proc value tracks config) so no drift line ever shows.
375
- const proc = alive ? pm.get(sessionKey) : null;
376
- const runModel = proc && proc.model;
377
- const runEffort = proc && proc.effort;
378
- const modelLine = (runModel && runModel !== model)
379
- ? `Model: ${runModel} (running) → ${model} (pending — applies on your next message)`
380
- : `Model: ${model} (${ver})`;
381
- const effortLine = (runEffort && runEffort !== effort)
382
- ? `Effort: ${runEffort} (running) → ${effort} (pending — applies on your next message)`
383
- : `Effort: ${effort}`;
384
- // Delivery reads richText live, while the authoring hint is fixed at
385
- // session spawn. Explain that lag separately from model/effort,
386
- // which have concrete running and configured values to compare.
387
- const richText = resolveDisplayRichText(chatConfig, topicConfig, effectiveRichText);
388
- const richTextLine = richText
389
- ? 'Rich text: on (headings/tables/checklists on qualifying replies; agent authors for it once this session next (re)spawns)'
390
- : 'Rich text: off';
391
- const runtimeLine = formatRuntimeLine(runtimeView);
392
- let head;
393
- if (codex) {
394
- const desired = runtimeView.desiredSettings ?? { model, effort };
395
- const desiredPair = formatSettingsPair(desired)
396
- ?? 'incomplete Codex selection';
397
- const activePair = formatSettingsPair(runtimeView.activeTurnSettings);
398
- const nextTurn = runtimeView.nextTurnSettings;
399
- const nextTurnPair = formatSettingsPair(nextTurn);
400
- const observedPair = formatSettingsPair(
401
- runtimeView.observedThreadSettings,
402
- );
403
- const processStatus = runtimeView.processStatus
404
- ?? (alive ? 'loaded' : 'not-loaded');
405
- const settingsLines = [];
406
- if (processStatus === 'loaded') {
407
- if (activePair) {
408
- settingsLines.push(`Current turn: ${activePair}`);
409
- settingsLines.push(`Next turn: ${nextTurnPair ?? desiredPair}`);
410
- } else {
411
- settingsLines.push(
412
- `Selected for next turn: ${nextTurnPair ?? desiredPair}`,
413
- );
414
- }
415
- if (
416
- nextTurnPair
417
- && !sameSettingsPair(nextTurn, desired)
418
- ) {
419
- settingsLines.push(
420
- `Saved selection awaiting live reconciliation: ${desiredPair}`,
421
- );
422
- }
423
- } else if (processStatus === 'not-loaded') {
424
- settingsLines.push(
425
- `Selected for this chat's next session: ${desiredPair}`,
426
- );
427
- } else {
428
- settingsLines.push(`Selected: ${desiredPair}`);
429
- }
430
- if (observedPair) {
431
- settingsLines.push(`Observed thread: ${observedPair}`);
432
- }
433
- let processLine;
434
- if (processStatus === 'daemon-busy') {
435
- processLine = 'Process: not loaded; its next message may be busy';
436
- } else if (processStatus === 'unavailable') {
437
- processLine = 'Process: unavailable'
438
- + (
439
- runtimeView.unavailableReason
440
- ? ` (${runtimeView.unavailableReason})`
441
- : ''
442
- );
443
- } else if (processStatus === 'unknown') {
444
- processLine = 'Process: live status unknown';
445
- } else {
446
- processLine = `Process: ${
447
- processStatus === 'loaded' ? 'warm' : 'not loaded'
448
- }`;
449
- }
450
- head = [
451
- ...settingsLines,
452
- richTextLine,
453
- runtimeLine,
454
- processLine,
455
- `Session: ${sess}`,
456
- ].join('\n');
457
- } else {
458
- head =
459
- `${modelLine}\n` +
460
- `${effortLine}\n` +
461
- `${richTextLine}\n` +
462
- `${runtimeLine}\n` +
463
- `Agent: ${agent}\n` +
464
- `Process: ${alive ? 'warm' : 'cold'}\n` +
465
- `Session: ${sess}`;
466
- }
467
-
468
324
  const modelHelp = codex
469
325
  ? codexModels.length > 0 ? [
470
326
  '',
@@ -493,7 +349,7 @@ function createFormatConfigInfoText({
493
349
  const effortHelp = codex
494
350
  ? codexEfforts.length > 0 ? [
495
351
  '',
496
- `**Effort** — authenticated options for ${model}:`,
352
+ `**Effort** — authenticated options for ${codexModel}:`,
497
353
  ...codexEfforts.map((value) => `• **${value}**`),
498
354
  'Model and effort changes apply to the next turn; an active turn is unchanged.',
499
355
  ].join('\n') : [
@@ -510,7 +366,12 @@ function createFormatConfigInfoText({
510
366
  '• **xhigh** / **max** — heaviest. Hard reasoning, edge cases.',
511
367
  ].join('\n');
512
368
 
513
- let body = head;
369
+ // The card is its buttons. Everything the body used to state — model,
370
+ // effort, rich text, runtime, agent, process, session — is already on a
371
+ // button below it, with a ✓ on the current value. What is left is the
372
+ // header (Telegram rejects an empty message) and the help that has no
373
+ // button to live on.
374
+ let body = '⚙️ Settings';
514
375
  if (codex) {
515
376
  body += '\n\nNative Codex beta: command network and web search are disabled. '
516
377
  + 'Product MCP tools and interactive approvals are unavailable. '
@@ -170,6 +170,10 @@ function createGateInbound({
170
170
  });
171
171
  if (r.consumed) return { action: 'handled', stage: 'rewind' };
172
172
  } catch (err) {
173
+ if (err?.code === 'CODEX_SCOPE_DISABLED') {
174
+ dispatchHandleMessage(sessionKey, chatId, msg, bot);
175
+ return { action: 'dispatched', stage: 'rewind' };
176
+ }
173
177
  // The text IS a recognized /rewind — on an internal error, consume it
174
178
  // anyway; falling through would send "/rewind" to claude as a prompt.
175
179
  logger.error?.(`[${botName}] rewind tryConsume failed: ${err?.message || err}`);
@@ -184,10 +184,23 @@ async function executeReplayPlan({ plan, deps }) {
184
184
  let recovered = 0; let skipped = 0; let noticed = 0; let noticeFailed = 0;
185
185
  let deferred = defer.length;
186
186
 
187
- // CRASH branch — recover each (unchanged rc.57 behavior).
187
+ // Legacy provider crash recovery.
188
188
  for (const c of recover) {
189
- // eslint-disable-next-line no-await-in-loop
190
- const r = await deps.recover(c);
189
+ let r;
190
+ try {
191
+ // eslint-disable-next-line no-await-in-loop
192
+ r = await deps.recover(c);
193
+ } catch (error) {
194
+ if (error?.code !== 'CODEX_SCOPE_DISABLED') throw error;
195
+ deferred += 1;
196
+ log('codex-replay-deferred', {
197
+ chat_id: c.chat_id,
198
+ thread_id: c.thread_id == null ? null : c.thread_id,
199
+ msg_id: c.msg_id,
200
+ reason: 'scope-disabled',
201
+ });
202
+ continue;
203
+ }
191
204
  if (r && r.ok) recovered += 1; else skipped += 1;
192
205
  }
193
206
 
@@ -204,8 +217,21 @@ async function executeReplayPlan({ plan, deps }) {
204
217
  });
205
218
  continue;
206
219
  }
207
- // eslint-disable-next-line no-await-in-loop
208
- const r = await deps.recoverCodex(c);
220
+ let r;
221
+ try {
222
+ // eslint-disable-next-line no-await-in-loop
223
+ r = await deps.recoverCodex(c);
224
+ } catch (error) {
225
+ if (error?.code !== 'CODEX_SCOPE_DISABLED') throw error;
226
+ deferred += 1;
227
+ log('codex-replay-deferred', {
228
+ chat_id: c.chat_id,
229
+ thread_id: c.thread_id == null ? null : c.thread_id,
230
+ msg_id: c.msg_id,
231
+ reason: 'scope-disabled',
232
+ });
233
+ continue;
234
+ }
209
235
  if (r && r.ok) recovered += 1; else deferred += 1;
210
236
  }
211
237
 
package/lib/prompt.js CHANGED
@@ -5,19 +5,26 @@
5
5
  * Telegram payload → polygram DB → unresolvable marker.
6
6
  */
7
7
 
8
- const { resolveRuntimeDescriptor } = require('./runtime-config');
8
+ const {
9
+ requireCodexEnabled,
10
+ resolveRuntimeDescriptor,
11
+ } = require('./runtime-config');
9
12
 
10
13
  function resolvePromptBackend({ config, chatId, threadId = null }) {
11
14
  const normalizedChatId = String(chatId);
12
15
  const normalizedThreadId = threadId == null || threadId === ''
13
16
  ? null
14
17
  : String(threadId);
15
- return resolveRuntimeDescriptor({
18
+ const descriptor = resolveRuntimeDescriptor({
16
19
  config,
17
20
  chatId: normalizedChatId,
18
21
  threadId: normalizedThreadId,
19
22
  defaultPm: 'sdk',
20
- }).backend;
23
+ });
24
+ if (descriptor.backend === 'codex') {
25
+ requireCodexEnabled(config, normalizedChatId, normalizedThreadId);
26
+ }
27
+ return descriptor.backend;
21
28
  }
22
29
 
23
30
  /**
@@ -12,6 +12,7 @@
12
12
 
13
13
  const { createHash } = require('crypto');
14
14
  const { pickBackend } = require('@shumkov/orchestra');
15
+ const { resolveBoolOverride } = require('./config-override');
15
16
 
16
17
  const PM_DESCRIPTORS = Object.freeze({
17
18
  sdk: Object.freeze({
@@ -93,6 +94,39 @@ class RuntimeConfigError extends Error {
93
94
  }
94
95
  }
95
96
 
97
+ function resolveCodexEnabled(config, chatId, threadId = null) {
98
+ return resolveBoolOverride(
99
+ config,
100
+ 'codexEnabled',
101
+ chatId,
102
+ threadId,
103
+ );
104
+ }
105
+
106
+ function requireCodexEnabled(config, chatId, threadId = null) {
107
+ if (resolveCodexEnabled(config, chatId, threadId)) return true;
108
+ throw new RuntimeConfigError(
109
+ 'Codex is not enabled for this chat.',
110
+ {
111
+ code: 'CODEX_SCOPE_DISABLED',
112
+ runtime: 'codex',
113
+ reason: 'effective codexEnabled policy is false',
114
+ action: 'Enable Codex for this chat before selecting or dispatching it.',
115
+ },
116
+ );
117
+ }
118
+
119
+ function requireCodexDispatchEnabled({
120
+ config,
121
+ chatId,
122
+ threadId = null,
123
+ selectedProvider,
124
+ liveCodexGeneration = false,
125
+ }) {
126
+ if (selectedProvider !== 'codex' && !liveCodexGeneration) return false;
127
+ return requireCodexEnabled(config, chatId, threadId);
128
+ }
129
+
96
130
  /**
97
131
  * Convert an Orchestra-branded preflight receipt into the only availability
98
132
  * object that can enable Codex. The assertion is required lazily so a
@@ -192,6 +226,7 @@ function resolveRuntimeConfig({
192
226
  let spawnProfileId = null;
193
227
 
194
228
  if (isCodex) {
229
+ requireCodexEnabled(config, chatId, threadId);
195
230
  const receipt = validateCodexAvailability(codexAvailability);
196
231
  modelChoice = resolveScopedValue(
197
232
  scopes.providerOptions,
@@ -365,6 +400,7 @@ function resolveCodexRuntimeCandidate({
365
400
  threadId = null,
366
401
  } = {}) {
367
402
  if (!chatId) return null;
403
+ requireCodexEnabled(config, chatId, threadId);
368
404
 
369
405
  const scopes = configScopes(config, chatId, threadId);
370
406
  const modelChoice = resolveScopedValue(
@@ -491,9 +527,11 @@ function resolveRuntimeDescriptor({
491
527
  : pickedBackend === 'cli'
492
528
  ? 'cli'
493
529
  : 'sdk';
530
+ const codexEnabled = resolveCodexEnabled(config, chatId, threadId);
494
531
  return Object.freeze({
495
532
  configuredPm: pmChoice.value,
496
533
  source: pmChoice.source,
534
+ codexEnabled,
497
535
  runtime: PM_DESCRIPTORS[canonicalBackend].runtime,
498
536
  backend: canonicalBackend,
499
537
  promptMode: PM_DESCRIPTORS[canonicalBackend].promptMode,
@@ -636,7 +674,10 @@ function stableRuntimeConfigIdentity(fields) {
636
674
  module.exports = {
637
675
  createCodexRuntimeAvailability,
638
676
  processMatchesRuntime,
677
+ requireCodexDispatchEnabled,
678
+ requireCodexEnabled,
639
679
  RuntimeConfigError,
680
+ resolveCodexEnabled,
640
681
  resolveCodexRuntimeCandidate,
641
682
  resolveCodexRuntimeRequest,
642
683
  resolveRuntimeConfig,
@@ -107,17 +107,15 @@ function getTopicConfig(chatConfig, threadId) {
107
107
  }
108
108
 
109
109
  /**
110
- * Resolve the config object a per-chat/topic setting should be written to.
111
- * Process-level settings use topic scope only for isolated sessions. A live
112
- * delivery setting can opt into topic scope even when sessions are shared.
110
+ * Resolve the config object a per-chat/topic setting should be written to:
111
+ * topic scope only where the session itself is topic-scoped, so a setting can
112
+ * never be stored somewhere the session that must honor it cannot see.
113
113
  *
114
114
  * @returns {{ scope: object, threadId: (string|null) }}
115
115
  */
116
- function getConfigWriteScope(chatConfig, threadId, { allowNonIsolatedTopic = false } = {}) {
116
+ function getConfigWriteScope(chatConfig, threadId) {
117
117
  const tid = (threadId == null || threadId === '') ? null : String(threadId);
118
- // Process-scoped settings follow session isolation. Live delivery settings
119
- // may explicitly keep topic scope because they are resolved per message.
120
- if (tid && (chatConfig?.isolateTopics === true || allowNonIsolatedTopic)) {
118
+ if (tid && chatConfig?.isolateTopics === true) {
121
119
  chatConfig.topics = chatConfig.topics || {};
122
120
  // A topic can be stored in the legacy / hand-edited form where the entry
123
121
  // is the bare name string (topics["329"] = "Advertising") rather than the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.34.1",
3
+ "version": "0.36.0",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc/client.js",
6
6
  "bin": {
@@ -24,8 +24,8 @@
24
24
  "config.example.json"
25
25
  ],
26
26
  "scripts": {
27
- "test": "node --test --test-force-exit tests/*.test.js",
28
- "coverage": "node --test --test-force-exit --experimental-test-coverage tests/*.test.js",
27
+ "test": "node --test --test-concurrency=4 tests/*.test.js",
28
+ "coverage": "node --test --test-concurrency=4 --experimental-test-coverage tests/*.test.js",
29
29
  "coverage:lcov": "mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info tests/*.test.js",
30
30
  "start": "node polygram.js",
31
31
  "split-db": "node scripts/split-db.js",
@@ -63,7 +63,7 @@
63
63
  "dependencies": {
64
64
  "@anthropic-ai/claude-agent-sdk": "0.2.123",
65
65
  "@modelcontextprotocol/sdk": "^1.29.0",
66
- "@shumkov/orchestra": "0.10.10",
66
+ "@shumkov/orchestra": "0.10.11",
67
67
  "better-sqlite3": "^12.9.0",
68
68
  "grammy": "^1.42.0",
69
69
  "markdown-it": "^14.1.1",
package/polygram.js CHANGED
@@ -79,6 +79,7 @@ const {
79
79
  } = require('./lib/codex/spawn-context');
80
80
  const {
81
81
  processMatchesRuntime,
82
+ requireCodexDispatchEnabled,
82
83
  resolveCodexRuntimeCandidate,
83
84
  resolveRuntimeDescriptor,
84
85
  } = require('./lib/runtime-config');
@@ -631,15 +632,17 @@ function buildUnavailableCodexRuntimeView({
631
632
  runtimeView = null,
632
633
  }) {
633
634
  let candidate = null;
634
- try {
635
- candidate = resolveCodexRuntimeCandidate({
636
- config,
637
- chatId,
638
- threadId,
639
- });
640
- } catch {
641
- // The runtime button must remain available even when the saved Codex
642
- // model, effort, or cwd is incomplete.
635
+ if (runtimeSelection.codexEnabled) {
636
+ try {
637
+ candidate = resolveCodexRuntimeCandidate({
638
+ config,
639
+ chatId,
640
+ threadId,
641
+ });
642
+ } catch {
643
+ // The runtime button must remain available even when the saved Codex
644
+ // model, effort, or cwd is incomplete.
645
+ }
643
646
  }
644
647
  const model = runtimeView?.model ?? candidate?.model ?? null;
645
648
  const effort = runtimeView?.effort ?? candidate?.effort ?? null;
@@ -653,6 +656,7 @@ function buildUnavailableCodexRuntimeView({
653
656
  backend: runtimeSelection.backend,
654
657
  configuredPm: runtimeSelection.configuredPm,
655
658
  selectionSource: runtimeSelection.source,
659
+ codexEnabled: runtimeSelection.codexEnabled,
656
660
  model,
657
661
  effort,
658
662
  models: runtimeView?.models ?? Object.freeze([]),
@@ -684,10 +688,19 @@ async function resolveSessionRuntimeView({
684
688
  backend: runtimeSelection.backend,
685
689
  configuredPm: runtimeSelection.configuredPm,
686
690
  selectionSource: runtimeSelection.source,
691
+ codexEnabled: runtimeSelection.codexEnabled,
687
692
  };
688
693
  if (runtimeSelection.runtime !== 'codex') {
689
694
  return Object.freeze(runtimeMetadata);
690
695
  }
696
+ if (!runtimeSelection.codexEnabled) {
697
+ return buildUnavailableCodexRuntimeView({
698
+ runtimeSelection,
699
+ chatId,
700
+ threadId,
701
+ error: { code: 'CODEX_SCOPE_DISABLED' },
702
+ });
703
+ }
691
704
  if (!codexRuntimeController) {
692
705
  return buildUnavailableCodexRuntimeView({
693
706
  runtimeSelection,
@@ -896,6 +909,16 @@ async function buildSpawnContext(
896
909
  // the inbound filter and the send() choke point use, so CliProcess's
897
910
  // pre-check + system-prompt line can't drift from actual enforcement.
898
911
  localApi: !!config.bot?.apiRoot,
912
+ // The display hint this chat should be running under, resolved fresh on
913
+ // every context build. It is spawn-time state (woven into the appended
914
+ // system prompt), so a warm session only picks up a rich-text toggle if the
915
+ // context carries the new hint for getOrSpawn to notice the drift against.
916
+ // MUST be built exactly like the factory-side resolver below — a difference
917
+ // in either argument is permanent drift, i.e. a respawn per message.
918
+ displayHint: buildPolygramDisplayHint(
919
+ resolveRichTextEnabled(config, chatId, threadId || null),
920
+ { inlineMedia: true },
921
+ ),
899
922
  // Pre-resolve through the SAME backend-aware resolver origin's CliProcess used, so
900
923
  // orchestra (which now uses opts.outboundCapOverride verbatim, without its own
901
924
  // resolveFileCaps clamp) gets the backend default (2GB local / 50MB cloud) AND the
@@ -1113,9 +1136,9 @@ let handleSendOverIpc = null;
1113
1136
  // formatToolInputForCard).
1114
1137
 
1115
1138
  // Config card UI moved to lib/handlers/config-ui.js. polygram.js
1116
- // keeps a thin formatConfigInfoText wrapper since it needs the
1117
- // runtime pm + db + getClaudeSessionId; buildConfigKeyboard is
1118
- // pure and re-exported.
1139
+ // keeps a thin formatConfigInfoText wrapper since it supplies the
1140
+ // async runtime-view resolver; buildConfigKeyboard is pure and
1141
+ // re-exported.
1119
1142
  const {
1120
1143
  buildConfigKeyboard,
1121
1144
  createFormatConfigInfoText,
@@ -1181,11 +1204,32 @@ function parsePairCodeArgs(text) {
1181
1204
  async function handleMessage(sessionKey, chatId, msg, bot) {
1182
1205
  const chatConfig = config.chats[chatId];
1183
1206
  if (!chatConfig) return;
1184
- const selectedInboundProvider = resolvePromptBackend({
1185
- config,
1186
- chatId,
1187
- threadId: msg.message_thread_id?.toString() || null,
1188
- }) === 'codex'
1207
+ const text = msg.text || msg.caption || '';
1208
+ const threadId = msg.message_thread_id;
1209
+ const threadIdStr = threadId?.toString() || null;
1210
+ const botAllowsCommands = !!config.bot?.allowConfigCommands;
1211
+ const runtimeInspectionCommand = (
1212
+ botAllowsCommands
1213
+ && (
1214
+ text === '/config'
1215
+ || text === '/model'
1216
+ || text === '/effort'
1217
+ )
1218
+ );
1219
+ const selectedBackend = runtimeInspectionCommand
1220
+ ? resolveRuntimeDescriptor({
1221
+ config,
1222
+ chatId,
1223
+ threadId: threadIdStr,
1224
+ defaultPm: 'sdk',
1225
+ logger: console,
1226
+ }).backend
1227
+ : resolvePromptBackend({
1228
+ config,
1229
+ chatId,
1230
+ threadId: threadIdStr,
1231
+ });
1232
+ const selectedInboundProvider = selectedBackend === 'codex'
1189
1233
  ? 'codex'
1190
1234
  : 'claude';
1191
1235
  if (
@@ -1221,9 +1265,6 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1221
1265
  });
1222
1266
  }
1223
1267
 
1224
- const text = msg.text || msg.caption || '';
1225
- const threadId = msg.message_thread_id;
1226
- const threadIdStr = threadId?.toString() || null;
1227
1268
  const label = getSessionLabel(chatConfig, threadIdStr);
1228
1269
 
1229
1270
  const replyOpts = (tid) => ({
@@ -1235,7 +1276,6 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1235
1276
  // comment there for the bump procedure.
1236
1277
  const MODEL_VERSIONS = MODEL_VERSIONS_DESC;
1237
1278
 
1238
- const botAllowsCommands = !!config.bot?.allowConfigCommands;
1239
1279
  const cmdUser = msg.from?.first_name || msg.from?.username || null;
1240
1280
  const cmdUserId = msg.from?.id || null;
1241
1281
 
@@ -1281,7 +1321,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1281
1321
  }, { source: 'command-reply', botName: BOT_NAME, model: chatConfig.model, effort: chatConfig.effort, ...metaTags });
1282
1322
  };
1283
1323
 
1284
- if (botAllowsCommands && (text === '/model' || text === '/config' || text === '/effort')) {
1324
+ if (runtimeInspectionCommand) {
1285
1325
  const show = text === '/effort' ? 'effort' : text === '/model' ? 'model' : 'all';
1286
1326
  // Resolve per-topic overrides so a topic's card shows its REAL
1287
1327
  // agent/model/effort, not the chat-level default — Music topic (thread 3)
@@ -2000,6 +2040,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2000
2040
  || current.backend === 'codex'
2001
2041
  ),
2002
2042
  );
2043
+ requireCodexDispatchEnabled({
2044
+ config,
2045
+ chatId,
2046
+ threadId: threadIdStr || null,
2047
+ selectedProvider: selectedInboundProvider,
2048
+ liveCodexGeneration,
2049
+ });
2003
2050
  if (liveCodexGeneration) {
2004
2051
  if (!codexRuntimeController) {
2005
2052
  codexDispatchDecision = 'unavailable';
@@ -4188,7 +4235,6 @@ async function main() {
4188
4235
  // call time and captures the value (closure-by-value). v4 reviewer
4189
4236
  // caught this as the same class as the v3 BLOCKER.
4190
4237
  formatConfigInfoText = createFormatConfigInfoText({
4191
- pm, db, getClaudeSessionId,
4192
4238
  resolveRuntimeView: resolveSessionRuntimeView,
4193
4239
  });
4194
4240
  handleConfigCallback = createHandleConfigCallback({
@@ -5037,7 +5083,10 @@ async function main() {
5037
5083
  // (rc.65+) AND a session_id to resume into.
5038
5084
  if (o.text && savedSessionId) {
5039
5085
  try {
5040
- const entry = await pm.getOrSpawn(o.session_key, buildSpawnContext(o.session_key));
5086
+ const entry = await pm.getOrSpawn(
5087
+ o.session_key,
5088
+ await buildSpawnContext(o.session_key),
5089
+ );
5041
5090
  // 0.10.0 P0.4: route through Process.fireUserMessage so both
5042
5091
  // SDK and tmux backends work. Pre-0.10.0-P0.4 reached into
5043
5092
  // entry.inputController.push directly — broken on tmux.