polygram 0.38.4 → 0.38.5

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.38.4",
4
+ "version": "0.38.5",
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",
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ function changedProcessError() {
4
+ const error = new Error(
5
+ 'Clean Codex replay no longer owns the process that rearmed its input',
6
+ );
7
+ error.code = 'CODEX_CLEAN_REPLAY_PROCESS_CHANGED';
8
+ return error;
9
+ }
10
+
11
+ function authorizeCleanReplayDispatch({
12
+ sessionKey,
13
+ currentProcess,
14
+ expectedProcess,
15
+ reservation,
16
+ controller,
17
+ } = {}) {
18
+ const generationId = reservation?.generationId;
19
+ const reservationId = reservation?.reservationId;
20
+ if (
21
+ typeof sessionKey !== 'string'
22
+ || sessionKey.length === 0
23
+ || currentProcess !== expectedProcess
24
+ || !currentProcess
25
+ || currentProcess.closed === true
26
+ || (currentProcess.runtime ?? currentProcess.backend) !== 'codex'
27
+ || !['Active', 'Idle', 'StartingTurn'].includes(currentProcess.state)
28
+ || typeof generationId !== 'string'
29
+ || generationId.length === 0
30
+ || currentProcess.generationId !== generationId
31
+ || typeof reservationId !== 'string'
32
+ || reservationId.length === 0
33
+ || typeof controller?.markDispatchDisposition !== 'function'
34
+ ) {
35
+ throw changedProcessError();
36
+ }
37
+
38
+ controller.markDispatchDisposition({
39
+ sessionKey,
40
+ generationId,
41
+ reservationId,
42
+ disposition: 'queue-authorized',
43
+ });
44
+ return Object.freeze({
45
+ reservationId,
46
+ generationId,
47
+ state: 'queue-authorized',
48
+ });
49
+ }
50
+
51
+ module.exports = { authorizeCleanReplayDispatch };
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ const { createHash } = require('node:crypto');
4
+
5
+ function codexDispatchReservationId({
6
+ botName,
7
+ telegramChatId,
8
+ telegramMessageId,
9
+ }) {
10
+ const digest = createHash('sha256')
11
+ .update(JSON.stringify({
12
+ botName,
13
+ telegramChatId,
14
+ telegramMessageId,
15
+ }))
16
+ .digest('hex');
17
+ return `codex-dispatch:v1:${digest}`;
18
+ }
19
+
20
+ module.exports = { codexDispatchReservationId };
@@ -18,6 +18,10 @@ const {
18
18
  resolveCodexRuntimeRequest,
19
19
  resolveRuntimeConfig,
20
20
  } = require('../runtime-config');
21
+ const { CODEX_APP_SERVER_NAMESPACE } = require('../db/sessions');
22
+ const {
23
+ codexDispatchReservationId,
24
+ } = require('./dispatch-reservation-id');
21
25
 
22
26
  const DEFAULT_CATALOG_MAX_AGE_MS = 15 * 60 * 1_000;
23
27
 
@@ -734,7 +738,9 @@ function createCodexRuntimeController({
734
738
  return processReplacementReason(sessionKey, proc) !== null;
735
739
  }
736
740
 
737
- function registerProcess(proc) {
741
+ function registerProcess(proc, {
742
+ expectedProviderGenerationId = null,
743
+ } = {}) {
738
744
  if (
739
745
  proc?.runtime !== 'codex'
740
746
  || typeof proc.generationId !== 'string'
@@ -742,6 +748,21 @@ function createCodexRuntimeController({
742
748
  ) {
743
749
  throw new TypeError('Codex runtime controller requires a Codex process');
744
750
  }
751
+ const strictResume = (
752
+ proc.spawnOptions?.resumePolicy === 'require-interrupted-turn'
753
+ );
754
+ if (
755
+ (strictResume && (
756
+ typeof expectedProviderGenerationId !== 'string'
757
+ || expectedProviderGenerationId.length === 0
758
+ || Buffer.byteLength(expectedProviderGenerationId) > 4096
759
+ ))
760
+ || (!strictResume && expectedProviderGenerationId !== null)
761
+ ) {
762
+ throw new TypeError(
763
+ 'Codex provider lineage is valid only for an exact strict resume',
764
+ );
765
+ }
745
766
  const receipt = resolveReceipt(proc.sessionKey, {
746
767
  runtime: 'codex',
747
768
  spawnProfileId: proc.spawnProfileId,
@@ -765,6 +786,8 @@ function createCodexRuntimeController({
765
786
  retirementRequested: false,
766
787
  retirementWaiters: [],
767
788
  retired: false,
789
+ expectedProviderGenerationId,
790
+ strictResumeAttemptId: null,
768
791
  credentialStateFingerprint: receiptsBySession
769
792
  .get(proc.sessionKey)
770
793
  .credentialStateFingerprint,
@@ -852,18 +875,6 @@ function createCodexRuntimeController({
852
875
  };
853
876
  }
854
877
 
855
- function dispatchReservationId({
856
- botName,
857
- telegramChatId,
858
- telegramMessageId,
859
- }) {
860
- return `codex-dispatch:v1:${digest({
861
- botName,
862
- telegramChatId,
863
- telegramMessageId,
864
- })}`;
865
- }
866
-
867
878
  function claimDispatchReservation({
868
879
  sessionKey,
869
880
  generationId,
@@ -881,7 +892,7 @@ function createCodexRuntimeController({
881
892
  telegramMessageId,
882
893
  'Telegram message ID',
883
894
  );
884
- const reservationId = dispatchReservationId({
895
+ const reservationId = codexDispatchReservationId({
885
896
  botName: exactBotName,
886
897
  telegramChatId: exactChatId,
887
898
  telegramMessageId: exactMessageId,
@@ -982,7 +993,7 @@ function createCodexRuntimeController({
982
993
  reservationId,
983
994
  'dispatch reservation ID',
984
995
  );
985
- if (exactReservationId !== dispatchReservationId({
996
+ if (exactReservationId !== codexDispatchReservationId({
986
997
  botName: exactBotName,
987
998
  telegramChatId: exactChatId,
988
999
  telegramMessageId: exactMessageId,
@@ -1172,7 +1183,10 @@ function createCodexRuntimeController({
1172
1183
  }
1173
1184
 
1174
1185
  function activateWithPreparedCheckpoint(record, payload) {
1175
- if (payload.kind !== 'request-prepared') {
1186
+ if (
1187
+ payload.kind !== 'request-prepared'
1188
+ || !['thread/start', 'thread/resume'].includes(payload.method)
1189
+ ) {
1176
1190
  throw controllerError(
1177
1191
  'Codex generation reached durability out of order.',
1178
1192
  'CODEX_GENERATION_NOT_DURABLE',
@@ -1205,6 +1219,100 @@ function createCodexRuntimeController({
1205
1219
  record.durable = true;
1206
1220
  }
1207
1221
 
1222
+ function persistPreparedTurnIdentity(record, payload, state) {
1223
+ const proc = record.proc;
1224
+ const pending = proc.current;
1225
+ const settings = pending?.admittedSettings;
1226
+ const sourceMsgId = pending?.context?.sourceMsgId ?? null;
1227
+ const starting = (
1228
+ proc.state === 'StartingTurn'
1229
+ && proc.settingsAdmissionClosed === false
1230
+ );
1231
+ const quiescing = (
1232
+ proc.state === 'Quiescing'
1233
+ && proc.settingsAdmissionClosed === true
1234
+ );
1235
+ const reject = () => {
1236
+ throw controllerError(
1237
+ 'Codex turn preparation does not match the exact admitted turn.',
1238
+ 'CODEX_TURN_PREPARATION_REJECTED',
1239
+ 'Fence the generation before retrying the user turn.',
1240
+ );
1241
+ };
1242
+ if (
1243
+ currentProcessBySession.get(proc.sessionKey) !== record
1244
+ || record.closed
1245
+ || record.retired
1246
+ || record.durabilityBlocked
1247
+ || record.retirementVerificationFailed
1248
+ || !record.durable
1249
+ || proc.closed === true
1250
+ || proc.runtime !== 'codex'
1251
+ || proc.backend !== 'codex'
1252
+ || proc.generationId !== payload.generationId
1253
+ || proc.hostIdentity !== state.identity.stableHostId
1254
+ || proc.bootSessionIdentity !== state.identity.bootSessionId
1255
+ || (!starting && !quiescing)
1256
+ || !pending
1257
+ || proc.pendingQueue?.[0] !== pending
1258
+ || pending.settled !== false
1259
+ || pending.startDeliveryState !== 'preparing'
1260
+ || pending.attemptId !== payload.attemptId
1261
+ || typeof pending.clientUserMessageId !== 'string'
1262
+ || pending.clientUserMessageId.length === 0
1263
+ || (pending.clientUserMessageId ?? null)
1264
+ !== (payload.clientUserMessageId ?? null)
1265
+ || sourceMsgId !== (payload.source ?? null)
1266
+ || pending.admittedSettings !== proc.admittingTurnSettings
1267
+ || !Object.isFrozen(settings)
1268
+ || typeof settings?.model !== 'string'
1269
+ || settings.model.length === 0
1270
+ || typeof settings?.effort !== 'string'
1271
+ || settings.effort.length === 0
1272
+ || typeof proc.providerSessionId !== 'string'
1273
+ || proc.providerSessionId.length === 0
1274
+ || proc.providerSessionId !== payload.threadId
1275
+ || proc.spawnProfileId !== record.receipt.spawnProfileId
1276
+ || proc.cwd !== record.receipt.expectedStaticProfile.cwd
1277
+ ) {
1278
+ reject();
1279
+ }
1280
+ const expectedProviderGenerationId = (
1281
+ record.expectedProviderGenerationId !== null
1282
+ && (
1283
+ record.strictResumeAttemptId === null
1284
+ || record.strictResumeAttemptId === payload.attemptId
1285
+ )
1286
+ )
1287
+ ? record.expectedProviderGenerationId
1288
+ : null;
1289
+ const result = db.recordCodexTurnStartPrepared({
1290
+ checkpoint: payload,
1291
+ provider: {
1292
+ session_key: proc.sessionKey,
1293
+ namespace: CODEX_APP_SERVER_NAMESPACE,
1294
+ provider: 'codex',
1295
+ provider_session_id: proc.providerSessionId,
1296
+ app_server_session_id: null,
1297
+ agent: null,
1298
+ cwd: proc.cwd,
1299
+ model: settings.model,
1300
+ effort: settings.effort,
1301
+ pm_backend: 'codex',
1302
+ spawn_profile_id: proc.spawnProfileId,
1303
+ ts: payload.ts ?? Date.now(),
1304
+ },
1305
+ expectedProviderGenerationId,
1306
+ });
1307
+ if (
1308
+ expectedProviderGenerationId !== null
1309
+ && record.strictResumeAttemptId === null
1310
+ ) {
1311
+ record.strictResumeAttemptId = payload.attemptId;
1312
+ }
1313
+ return result;
1314
+ }
1315
+
1208
1316
  function maybeRetire(record) {
1209
1317
  if (
1210
1318
  record.retired
@@ -1488,7 +1596,7 @@ function createCodexRuntimeController({
1488
1596
  }
1489
1597
  }
1490
1598
 
1491
- async function checkpointSink(payload) {
1599
+ function checkpointSinkImpl(payload) {
1492
1600
  const record = processesByGeneration.get(payload?.generationId);
1493
1601
  const state = requireAvailableInitialization();
1494
1602
  if (
@@ -1553,7 +1661,19 @@ function createCodexRuntimeController({
1553
1661
  return Object.freeze({ ...result });
1554
1662
  }
1555
1663
 
1556
- if (!record.durable) {
1664
+ if (
1665
+ payload.kind === 'request-prepared'
1666
+ && payload.method === 'turn/start'
1667
+ ) {
1668
+ if (!record.durable) {
1669
+ throw controllerError(
1670
+ 'Codex turn reached persistence before generation initialization.',
1671
+ 'CODEX_GENERATION_NOT_DURABLE',
1672
+ 'Quarantine the generation and inspect its initialization checkpoints.',
1673
+ );
1674
+ }
1675
+ persistPreparedTurnIdentity(record, payload, state);
1676
+ } else if (!record.durable) {
1557
1677
  activateWithPreparedCheckpoint(record, payload);
1558
1678
  } else {
1559
1679
  db.recordCodexCheckpoint(payload);
@@ -1567,6 +1687,20 @@ function createCodexRuntimeController({
1567
1687
  maybeRetire(record);
1568
1688
  }
1569
1689
 
1690
+ async function checkpointSink(payload) {
1691
+ try {
1692
+ return checkpointSinkImpl(payload);
1693
+ } catch (error) {
1694
+ if (
1695
+ payload?.kind === 'request-prepared'
1696
+ && payload?.method === 'turn/start'
1697
+ ) {
1698
+ error.checkpointDisposition = 'not-committed';
1699
+ }
1700
+ throw error;
1701
+ }
1702
+ }
1703
+
1570
1704
  return Object.freeze({
1571
1705
  initialize,
1572
1706
  prepareSession,
@@ -64,6 +64,7 @@ async function buildCodexSpawnContext({
64
64
  existingSessionId,
65
65
  resumePolicy: 'require-interrupted-turn',
66
66
  expectedInterruptedTurnId: strictResume.expectedInterruptedTurnId,
67
+ expectedProviderGenerationId: strictResume.expectedGenerationId,
67
68
  noWaitForCapacity: true,
68
69
  };
69
70
  }
@@ -80,15 +80,172 @@ function createAutoResumeTracker({ cooldownMs = DEFAULT_COOLDOWN_MS, now = Date.
80
80
  * Classify the durable provider ledger for one Codex Telegram input.
81
81
  *
82
82
  * A request is safe to submit automatically only when the ledger proves that
83
- * no provider write occurred. Explicit cancellation is also definitely
84
- * not-sent, but remains cancelled rather than becoming new work. Every other
85
- * state fails closed because re-submission could duplicate text or tool
86
- * effects.
83
+ * no provider write occurred. A deploy-owned pre-write cancellation is safe
84
+ * only with exact clean-restart provenance; other cancellations remain
85
+ * cancelled. Every ambiguous state fails closed because re-submission could
86
+ * duplicate text or tool effects.
87
87
  *
88
88
  * Linked steering inputs are never independent work. Their state follows the
89
89
  * target turn, which is returned only for diagnostics and reconciliation.
90
90
  */
91
91
  function classifyCodexRecoveryEvidence(evidence = {}) {
92
+ const structured = (
93
+ evidence?.kind != null
94
+ || evidence?.selection != null
95
+ || evidence?.reservation != null
96
+ || evidence?.attempt != null
97
+ || evidence?.linkedInput != null
98
+ || evidence?.targetAttempt != null
99
+ || evidence?.cancellationProof != null
100
+ );
101
+ if (structured) {
102
+ const selection = evidence?.selection;
103
+ const reservation = evidence?.reservation;
104
+ const attempt = evidence?.attempt;
105
+ const linkedInput = evidence?.linkedInput;
106
+ const targetAttempt = evidence?.targetAttempt;
107
+ const cancellationProof = evidence?.cancellationProof;
108
+ const exactSelection = (
109
+ evidence?.provider === 'codex'
110
+ && selection?.provider === 'codex'
111
+ && typeof selection.sessionKey === 'string'
112
+ && selection.sessionKey.length > 0
113
+ && Number.isSafeInteger(selection.selectedTs)
114
+ && selection.selectedTs >= 0
115
+ );
116
+ const exactReservation = (
117
+ typeof reservation?.reservationId === 'string'
118
+ && reservation.reservationId.length > 0
119
+ && typeof reservation?.generationId === 'string'
120
+ && reservation.generationId.length > 0
121
+ && ['reserved', 'queue-authorized'].includes(reservation.state)
122
+ && reservation.steerAttemptId == null
123
+ && reservation.targetAttemptId == null
124
+ && reservation.settledTs == null
125
+ );
126
+ const exactAttempt = (
127
+ typeof attempt?.attemptId === 'string'
128
+ && attempt.attemptId.length > 0
129
+ && typeof attempt?.generationId === 'string'
130
+ && attempt.generationId.length > 0
131
+ && attempt.deliveryState === 'prepared'
132
+ && attempt.turnId == null
133
+ && attempt.terminalStatus == null
134
+ );
135
+ const noLinkedEvidence = linkedInput == null && targetAttempt == null;
136
+ const matchingGeneration = (
137
+ reservation == null
138
+ || attempt == null
139
+ || reservation.generationId === attempt.generationId
140
+ );
141
+ const exactOptionalQueueReservation = (
142
+ reservation == null
143
+ || (exactReservation && reservation.state === 'queue-authorized')
144
+ );
145
+
146
+ if (!exactSelection) {
147
+ return {
148
+ action: 'defer',
149
+ reason: 'provider-acceptance-not-disproven',
150
+ };
151
+ }
152
+ if (!noLinkedEvidence) {
153
+ return {
154
+ action: 'defer',
155
+ reason: 'linked-input',
156
+ };
157
+ }
158
+ if (
159
+ evidence.kind === 'selection-only'
160
+ && reservation == null
161
+ && attempt == null
162
+ && cancellationProof == null
163
+ ) {
164
+ return {
165
+ action: 'recover',
166
+ reason: 'selection-proven-not-dispatched',
167
+ cleanRestartSafe: true,
168
+ };
169
+ }
170
+ if (
171
+ evidence.kind === 'dispatch-reservation'
172
+ && exactReservation
173
+ && attempt == null
174
+ && cancellationProof == null
175
+ ) {
176
+ return {
177
+ action: 'recover',
178
+ reason: 'reservation-proven-not-dispatched',
179
+ cleanRestartSafe: true,
180
+ };
181
+ }
182
+
183
+ const exactPrimary = (
184
+ exactAttempt
185
+ && attempt.method === 'turn/start'
186
+ && matchingGeneration
187
+ && exactOptionalQueueReservation
188
+ && (
189
+ (reservation == null && evidence.kind === 'primary-turn')
190
+ || (
191
+ reservation != null
192
+ && evidence.kind === 'dispatch-reservation'
193
+ )
194
+ )
195
+ );
196
+ if (
197
+ exactPrimary
198
+ && attempt.recoveryState === 'prepared'
199
+ && cancellationProof == null
200
+ ) {
201
+ return {
202
+ action: 'recover',
203
+ reason: 'request-proven-not-accepted',
204
+ cleanRestartSafe: true,
205
+ };
206
+ }
207
+ if (exactPrimary && attempt.recoveryState === 'cancelled') {
208
+ if (
209
+ cancellationProof?.kind === 'active-start-cancelled'
210
+ && cancellationProof?.reason === 'clean-restart'
211
+ && Object.keys(cancellationProof).length === 2
212
+ ) {
213
+ return {
214
+ action: 'recover',
215
+ reason: 'clean-restart-request-cancelled-before-acceptance',
216
+ cleanRestartSafe: true,
217
+ };
218
+ }
219
+ return {
220
+ action: 'skip',
221
+ reason: 'request-cancelled-before-acceptance',
222
+ };
223
+ }
224
+
225
+ const exactQueuedCancellation = (
226
+ evidence.kind === 'queued-send'
227
+ && exactAttempt
228
+ && attempt.method === 'queued/send'
229
+ && attempt.recoveryState === 'cancelled'
230
+ && matchingGeneration
231
+ && exactOptionalQueueReservation
232
+ && cancellationProof?.kind === 'queued-send-cancelled'
233
+ && cancellationProof?.reason === 'clean-restart'
234
+ && Object.keys(cancellationProof).length === 2
235
+ );
236
+ if (exactQueuedCancellation) {
237
+ return {
238
+ action: 'recover',
239
+ reason: 'clean-restart-queued-send-cancelled',
240
+ cleanRestartSafe: true,
241
+ };
242
+ }
243
+ return {
244
+ action: 'defer',
245
+ reason: 'provider-acceptance-not-disproven',
246
+ };
247
+ }
248
+
92
249
  const deliveryState = evidence?.delivery_state ?? evidence?.deliveryState;
93
250
  const recoveryState = evidence?.recovery_state ?? evidence?.recoveryState;
94
251
  const linkedState = (
@@ -278,6 +278,14 @@ const FIELDS = {
278
278
  replies: 'int',
279
279
  reply_count: 'int',
280
280
  receipt_count: 'int',
281
+ recovered_count: 'int',
282
+ skipped_count: 'int',
283
+ codex_scheduled_count: 'int',
284
+ admitted_count: 'int',
285
+ terminal_count: 'int',
286
+ noticed_count: 'int',
287
+ notice_failed_count: 'int',
288
+ deferred_count: 'int',
281
289
  interim_count: 'int',
282
290
  block_count: 'int',
283
291
  media_count: 'int',