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