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.
- package/.claude-plugin/plugin.json +1 -1
- package/config.example.json +2 -1
- package/lib/error/classify.js +11 -0
- package/lib/handlers/config-callback.js +114 -13
- package/lib/handlers/config-ui.js +44 -183
- package/lib/handlers/gate-inbound.js +4 -0
- package/lib/handlers/replay-disposition.js +31 -5
- package/lib/prompt.js +10 -3
- package/lib/runtime-config.js +41 -0
- package/lib/session-key.js +5 -7
- package/package.json +4 -4
- package/polygram.js +73 -24
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.
|
|
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",
|
package/config.example.json
CHANGED
|
@@ -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
|
|
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",
|
package/lib/error/classify.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
711
|
-
//
|
|
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)
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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: '
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
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.
|
|
305
|
-
* getClaudeSessionId fetcher.
|
|
294
|
+
* Factory for the card-body formatter.
|
|
306
295
|
*
|
|
307
|
-
* @param {object} deps
|
|
308
|
-
* @param {object} deps.
|
|
309
|
-
*
|
|
310
|
-
*
|
|
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
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
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,
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
//
|
|
187
|
+
// Legacy provider crash recovery.
|
|
188
188
|
for (const c of recover) {
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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 {
|
|
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
|
-
|
|
18
|
+
const descriptor = resolveRuntimeDescriptor({
|
|
16
19
|
config,
|
|
17
20
|
chatId: normalizedChatId,
|
|
18
21
|
threadId: normalizedThreadId,
|
|
19
22
|
defaultPm: 'sdk',
|
|
20
|
-
})
|
|
23
|
+
});
|
|
24
|
+
if (descriptor.backend === 'codex') {
|
|
25
|
+
requireCodexEnabled(config, normalizedChatId, normalizedThreadId);
|
|
26
|
+
}
|
|
27
|
+
return descriptor.backend;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
/**
|
package/lib/runtime-config.js
CHANGED
|
@@ -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,
|
package/lib/session-key.js
CHANGED
|
@@ -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
|
-
*
|
|
112
|
-
*
|
|
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
|
|
116
|
+
function getConfigWriteScope(chatConfig, threadId) {
|
|
117
117
|
const tid = (threadId == null || threadId === '') ? null : String(threadId);
|
|
118
|
-
|
|
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.
|
|
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-
|
|
28
|
-
"coverage": "node --test --test-
|
|
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.
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
|
1117
|
-
// runtime
|
|
1118
|
-
//
|
|
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
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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 (
|
|
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(
|
|
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.
|