polygram 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +5 -3
  3. package/config.example.json +23 -1
  4. package/lib/codex/binary.js +287 -0
  5. package/lib/codex/delivery-finalizer.js +97 -0
  6. package/lib/codex/diagnostics.js +483 -0
  7. package/lib/codex/event-normalizer.js +50 -0
  8. package/lib/codex/host-identity.js +103 -0
  9. package/lib/codex/model-catalog.js +359 -0
  10. package/lib/codex/retention-scheduler.js +59 -0
  11. package/lib/codex/runtime-controller.js +1292 -0
  12. package/lib/codex/runtime-profile.js +888 -0
  13. package/lib/codex/spawn-context.js +87 -0
  14. package/lib/db/auto-resume.js +77 -9
  15. package/lib/db/codex-reconciliation.js +490 -0
  16. package/lib/db/codex-retention.js +139 -0
  17. package/lib/db/sessions.js +102 -1
  18. package/lib/db.js +3418 -19
  19. package/lib/error/classify.js +140 -0
  20. package/lib/handlers/abort.js +143 -5
  21. package/lib/handlers/autosteer.js +256 -1
  22. package/lib/handlers/codex-reconciliation.js +230 -0
  23. package/lib/handlers/config-callback.js +324 -89
  24. package/lib/handlers/config-ui.js +401 -39
  25. package/lib/handlers/dispatcher.js +71 -8
  26. package/lib/handlers/edit-correction.js +11 -1
  27. package/lib/handlers/edit-redelivery.js +230 -4
  28. package/lib/handlers/gate-inbound.js +7 -0
  29. package/lib/handlers/replay-disposition.js +133 -11
  30. package/lib/handlers/slash-commands.js +443 -37
  31. package/lib/ipc/client.js +22 -12
  32. package/lib/ipc/runtime-directory.js +208 -0
  33. package/lib/ipc/server.js +18 -12
  34. package/lib/model-costs.js +4 -3
  35. package/lib/pm-interface.js +33 -0
  36. package/lib/prompt.js +15 -9
  37. package/lib/rewind/rewind.js +21 -1
  38. package/lib/runtime-config.js +605 -0
  39. package/lib/sdk/build-options.js +4 -1
  40. package/lib/sdk/callbacks.js +46 -3
  41. package/migrations/015-agent-runtime-sessions.sql +50 -0
  42. package/migrations/016-agent-turn-attempts.sql +211 -0
  43. package/migrations/017-config-runtime-changes.sql +46 -0
  44. package/ops/README.md +57 -0
  45. package/package.json +2 -2
  46. package/polygram.js +697 -48
  47. package/scripts/doctor.js +37 -8
@@ -1,10 +1,14 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Post-turn edit re-delivery (0.12.0). When the user edits a Telegram message AFTER claude's turn
5
- * has finished, re-dispatch the edited message as a NEW turn so claude acts on the change — the
6
- * "an edit is just a message" model. The mid-turn case (turn still in flight) stays with the
7
- * existing injector (lib/handlers/edit-correction.js); this is the post-turn path.
4
+ * Post-turn edit re-delivery. When the user edits a Telegram message after a
5
+ * turn has finished, re-dispatch the edited message as a new turn so the model
6
+ * acts on the change — the "an edit is just a message" model.
7
+ *
8
+ * Claude retains its per-message mid-turn injection behavior. Codex edits are
9
+ * deferred and dispatched as normal visible turns only when the exact process
10
+ * generation that observed them becomes idle. Teardown or replacement cancels
11
+ * the deferred content.
8
12
  *
9
13
  * Spec: docs/0.12.0-edit-redelivery-spec.md (twice-reviewed). Key correctness points the review
10
14
  * surfaced:
@@ -47,8 +51,195 @@ function createEditRedelivery({
47
51
  // redelivery (dispatchHandleMessage autosteers it naturally if a turn is in
48
52
  // flight — through the formatted-prompt path, not a hand-built string).
49
53
  const redeliveredAt = new Map(); // `${chatId}:${msgId}` → ts
54
+ const deferredCodexBySession = new Map();
50
55
  const INTERLOCK_TTL_MS = 10 * 60 * 1000;
51
56
 
57
+ function codexGenerationId(proc) {
58
+ return typeof proc?.generationId === 'string' && proc.generationId.length > 0
59
+ ? proc.generationId
60
+ : null;
61
+ }
62
+
63
+ function isCodexProcess(sessionKey, proc) {
64
+ const backend = typeof pm?.getBackend === 'function'
65
+ ? pm.getBackend(sessionKey)
66
+ : proc?.backend ?? proc?.runtime;
67
+ return backend === 'codex' || proc?.backend === 'codex' || proc?.runtime === 'codex';
68
+ }
69
+
70
+ function removeDeferredListeners(state) {
71
+ state.proc.removeListener?.('idle', state.onIdle);
72
+ state.proc.removeListener?.('close', state.onClose);
73
+ state.proc.removeListener?.('session-reset', state.onSessionReset);
74
+ state.proc.removeListener?.('codex-settled', state.onCodexSettled);
75
+ state.proc.removeListener?.('codex-lifecycle', state.onCodexLifecycle);
76
+ }
77
+
78
+ function safeLogDeferredEvent(kind, detail) {
79
+ try {
80
+ logEvent(kind, detail);
81
+ } catch (error) {
82
+ try {
83
+ logger.error?.(`[edit-redelivery] deferred event log failed: ${error.message}`);
84
+ } catch {
85
+ // Process lifecycle listeners must never throw back into supervision.
86
+ }
87
+ }
88
+ }
89
+
90
+ function cancelDeferred(state, reason) {
91
+ if (deferredCodexBySession.get(state.sessionKey) !== state) return;
92
+ deferredCodexBySession.delete(state.sessionKey);
93
+ removeDeferredListeners(state);
94
+ safeLogDeferredEvent('edit-redelivery-deferred-cancelled', {
95
+ session_key: state.sessionKey,
96
+ generation_id: state.generationId,
97
+ reason,
98
+ message_count: state.entries.size,
99
+ });
100
+ }
101
+
102
+ function dispatchDeferredSynthetic({
103
+ sessionKey,
104
+ chatId,
105
+ msgId,
106
+ synthetic,
107
+ oldLength,
108
+ newLength,
109
+ }) {
110
+ dispatchHandleMessage(sessionKey, chatId, synthetic, bot);
111
+ safeLogDeferredEvent('edit-redelivered', {
112
+ chat_id: chatId,
113
+ session_key: sessionKey,
114
+ msg_id: msgId,
115
+ old_len: oldLength,
116
+ new_len: newLength,
117
+ });
118
+ }
119
+
120
+ function flushDeferred(state) {
121
+ if (deferredCodexBySession.get(state.sessionKey) !== state) return;
122
+ const current = pm?.get?.(state.sessionKey);
123
+ if (
124
+ current !== state.proc
125
+ || codexGenerationId(current) !== state.generationId
126
+ ) {
127
+ cancelDeferred(state, 'generation-replaced');
128
+ return;
129
+ }
130
+ if (current.closed) {
131
+ cancelDeferred(state, 'close');
132
+ return;
133
+ }
134
+ // An idle-shaped event is not enough: process state is the final fence
135
+ // against dispatching while the generation still owns a live turn.
136
+ if (current.inFlight) return;
137
+
138
+ deferredCodexBySession.delete(state.sessionKey);
139
+ removeDeferredListeners(state);
140
+ for (const entry of state.entries.values()) {
141
+ try {
142
+ dispatchDeferredSynthetic(entry);
143
+ } catch (error) {
144
+ try {
145
+ logger.error?.(`[edit-redelivery] deferred dispatch failed: ${error.message}`);
146
+ } catch {
147
+ // Process lifecycle listeners must never throw back into supervision.
148
+ }
149
+ safeLogDeferredEvent('edit-redelivery-deferred-cancelled', {
150
+ session_key: state.sessionKey,
151
+ generation_id: state.generationId,
152
+ reason: 'dispatch-failed',
153
+ message_count: 1,
154
+ msg_id: entry.msgId,
155
+ });
156
+ }
157
+ }
158
+ }
159
+
160
+ function deferCodexEdit(entry, proc) {
161
+ const generationId = codexGenerationId(proc);
162
+ if (!generationId) {
163
+ safeLogDeferredEvent('edit-redelivery-deferred-cancelled', {
164
+ session_key: entry.sessionKey,
165
+ generation_id: null,
166
+ reason: 'missing-generation-id',
167
+ message_count: 1,
168
+ msg_id: entry.msgId,
169
+ });
170
+ return false;
171
+ }
172
+ if (
173
+ typeof proc.on !== 'function'
174
+ || typeof proc.removeListener !== 'function'
175
+ ) {
176
+ safeLogDeferredEvent('edit-redelivery-deferred-cancelled', {
177
+ session_key: entry.sessionKey,
178
+ generation_id: generationId,
179
+ reason: 'event-interface-unavailable',
180
+ message_count: 1,
181
+ msg_id: entry.msgId,
182
+ });
183
+ return false;
184
+ }
185
+
186
+ let state = deferredCodexBySession.get(entry.sessionKey);
187
+ if (
188
+ state
189
+ && (
190
+ state.proc !== proc
191
+ || state.generationId !== generationId
192
+ )
193
+ ) {
194
+ cancelDeferred(state, 'generation-replaced');
195
+ state = null;
196
+ }
197
+ if (!state) {
198
+ state = {
199
+ sessionKey: entry.sessionKey,
200
+ generationId,
201
+ proc,
202
+ entries: new Map(),
203
+ };
204
+ state.onIdle = () => flushDeferred(state);
205
+ state.onClose = () => cancelDeferred(state, 'close');
206
+ state.onSessionReset = () => cancelDeferred(state, 'session-reset');
207
+ state.onCodexSettled = (event) => {
208
+ if (event?.kind === 'stopped') cancelDeferred(state, 'stop');
209
+ };
210
+ state.onCodexLifecycle = () => cancelDeferred(state, 'lifecycle-failed');
211
+ deferredCodexBySession.set(entry.sessionKey, state);
212
+ proc.on('idle', state.onIdle);
213
+ proc.on('close', state.onClose);
214
+ proc.on('session-reset', state.onSessionReset);
215
+ proc.on('codex-settled', state.onCodexSettled);
216
+ proc.on('codex-lifecycle', state.onCodexLifecycle);
217
+ }
218
+
219
+ const interlockKey = `${entry.chatId}:${entry.msgId}`;
220
+ const coalesced = state.entries.has(interlockKey);
221
+ // Map#set replaces the value without changing insertion order.
222
+ state.entries.set(interlockKey, entry);
223
+ safeLogDeferredEvent('edit-redelivery-deferred', {
224
+ chat_id: entry.chatId,
225
+ session_key: entry.sessionKey,
226
+ generation_id: generationId,
227
+ msg_id: entry.msgId,
228
+ coalesced,
229
+ });
230
+ // Close the check→listener registration race. If the turn settled (or the
231
+ // process was replaced) while the edit was being gated, release or cancel
232
+ // through the same generation-fenced path immediately.
233
+ if (
234
+ pm?.get?.(entry.sessionKey) !== proc
235
+ || proc.closed
236
+ || !proc.inFlight
237
+ ) {
238
+ flushDeferred(state);
239
+ }
240
+ return true;
241
+ }
242
+
52
243
  return function maybePostTurnEdit(editedMsg, oldText, botUsername, mentionRe = null) {
53
244
  try {
54
245
  if (!editedMsg?.chat) return false;
@@ -77,6 +268,41 @@ function createEditRedelivery({
77
268
  // BEFORE the gate; the gate now runs upstream in the edited_message
78
269
  // handler, so every path through here is already gated).
79
270
  const proc = pm?.get?.(sessionKey);
271
+ if (proc?.inFlight && isCodexProcess(sessionKey, proc)) {
272
+ // Re-run the real-message gate before retaining content in memory. The
273
+ // outer edited-message path already gates today, but this handler keeps
274
+ // its own contract when reused independently.
275
+ if (!shouldHandle(editedMsg, chatConfig, botUsername)) return false;
276
+ const cleanNew = mentionRe ? newText.replace(mentionRe, '').trim() : newText.trim();
277
+ const synthetic = {
278
+ chat: editedMsg.chat,
279
+ message_id: editedMsg.message_id,
280
+ from: editedMsg.from,
281
+ text: cleanNew,
282
+ date: editedMsg.date,
283
+ ...(threadId && { message_thread_id: Number(threadId) }),
284
+ reply_to_message: {
285
+ message_id: editedMsg.message_id,
286
+ from: editedMsg.from,
287
+ text: oldText || '',
288
+ date: editedMsg.date,
289
+ },
290
+ _isReplay: true,
291
+ _requiredProvider: 'codex',
292
+ };
293
+ const deferred = deferCodexEdit({
294
+ sessionKey,
295
+ chatId,
296
+ msgId: editedMsg.message_id,
297
+ synthetic,
298
+ oldLength: (oldText || '').length,
299
+ newLength: newText.length,
300
+ }, proc);
301
+ if (!deferred) return false;
302
+ try { react?.(chatId, editedMsg.message_id); } catch { /* best-effort */ }
303
+ return false;
304
+ }
305
+
80
306
  const interlockKey = `${chatId}:${editedMsg.message_id}`;
81
307
  const lastRedeliveredAt = redeliveredAt.get(interlockKey) || 0;
82
308
  if (proc?.inFlight && (Date.now() - lastRedeliveredAt) < INTERLOCK_TTL_MS && lastRedeliveredAt > 0) {
@@ -50,6 +50,7 @@ function createGateInbound({
50
50
  handleAbortIfRequested,
51
51
  getRewindHandler = () => null,
52
52
  isRewindCommand = () => false,
53
+ resolveRuntime = () => 'claude',
53
54
  getQuestionHandlers = () => null,
54
55
  shouldHandle,
55
56
  getSessionKey,
@@ -158,6 +159,12 @@ function createGateInbound({
158
159
  const rewindSafe = msg.chat?.type === 'private' || chatConfig?.isolateTopics === true;
159
160
  const r = await rewindHandler.tryConsume({
160
161
  sessionKey, chatId, threadId, msg, cleanText,
162
+ runtime: resolveRuntime({
163
+ sessionKey,
164
+ chatId,
165
+ threadId,
166
+ chatConfig,
167
+ }),
161
168
  botUsername: getBotUsername?.() || '',
162
169
  rewindSafe, isOperatorIdentity, paired, accessMode,
163
170
  });
@@ -1,5 +1,47 @@
1
1
  'use strict';
2
2
 
3
+ const {
4
+ classifyCodexRecoveryEvidence,
5
+ } = require('../db/auto-resume');
6
+
7
+ function providerReplayDisposition(candidate, getProviderRecovery) {
8
+ if (typeof getProviderRecovery === 'function') {
9
+ let evidence;
10
+ try {
11
+ evidence = getProviderRecovery(candidate);
12
+ } catch {
13
+ return {
14
+ provider: 'unknown',
15
+ action: 'defer',
16
+ reason: 'provider-recovery-unavailable',
17
+ };
18
+ }
19
+ if (evidence?.provider === 'claude') {
20
+ return { provider: 'claude', action: 'legacy' };
21
+ }
22
+ if (evidence?.provider === 'codex') {
23
+ return {
24
+ provider: 'codex',
25
+ ...classifyCodexRecoveryEvidence(evidence),
26
+ };
27
+ }
28
+ return {
29
+ provider: 'unknown',
30
+ action: 'defer',
31
+ reason: 'provider-recovery-unavailable',
32
+ };
33
+ }
34
+
35
+ const provider = candidate?.provider ?? candidate?.runtime;
36
+ if (provider === 'codex') {
37
+ return {
38
+ provider: 'codex',
39
+ ...classifyCodexRecoveryEvidence(candidate),
40
+ };
41
+ }
42
+ return { provider: provider || 'claude', action: 'legacy' };
43
+ }
44
+
3
45
  /**
4
46
  * Boot-replay disposition (0.14) — decide what to do with the replay candidate
5
47
  * set, given whether the last shutdown was a DELIBERATE restart or a CRASH.
@@ -30,15 +72,53 @@
30
72
  * @param {(candidate)=>boolean} [opts.hasCompletedTurn] already-answered predicate (default: none)
31
73
  * @param {(candidate)=>boolean} [opts.announceable] notice-eligible predicate (default: all). Excludes
32
74
  * admin/slash + abort-shaped rows so we don't announce "I didn't resume your /new".
33
- * @returns {{recover: Array, skip: Array, notices: Array<{chat_id, thread_id, items: Array}>}}
75
+ * @param {(candidate)=>object} [opts.getProviderRecovery] authoritative provider
76
+ * and durable-recovery evidence. When supplied, unknown/error results defer
77
+ * rather than enter the legacy recovery path.
78
+ * @returns {{recover: Array, recoverCodex?: Array, skip: Array,
79
+ * defer?: Array, notices: Array<{chat_id, thread_id, items: Array}>}}
34
80
  */
35
- function classifyReplay({ candidates = [], cleanShutdown = false, hasCompletedTurn, announceable } = {}) {
81
+ function classifyReplay({
82
+ candidates = [],
83
+ cleanShutdown = false,
84
+ hasCompletedTurn,
85
+ announceable,
86
+ getProviderRecovery,
87
+ } = {}) {
36
88
  const answered = typeof hasCompletedTurn === 'function' ? hasCompletedTurn : () => false;
37
89
  const pending = (candidates || []).filter((c) => !answered(c));
90
+ const legacy = [];
91
+ const recoverCodex = [];
92
+ const cancelled = [];
93
+ const defer = [];
94
+
95
+ for (const candidate of pending) {
96
+ const disposition = providerReplayDisposition(
97
+ candidate,
98
+ getProviderRecovery,
99
+ );
100
+ if (disposition.action === 'legacy') {
101
+ legacy.push(candidate);
102
+ } else if (disposition.action === 'recover') {
103
+ recoverCodex.push(candidate);
104
+ } else if (disposition.action === 'skip') {
105
+ cancelled.push(candidate);
106
+ } else {
107
+ defer.push(candidate);
108
+ }
109
+ }
38
110
 
39
111
  if (!cleanShutdown) {
40
- // Crash: recover everything unanswered (unchanged rc.57 behavior).
41
- return { recover: pending, skip: [], notices: [] };
112
+ // Legacy providers retain crash recovery. Codex requests use a separate
113
+ // recoverer so they cannot accidentally fail over to Claude.
114
+ const plan = {
115
+ recover: legacy,
116
+ skip: cancelled,
117
+ notices: [],
118
+ };
119
+ if (recoverCodex.length > 0) plan.recoverCodex = recoverCodex;
120
+ if (defer.length > 0) plan.defer = defer;
121
+ return plan;
42
122
  }
43
123
 
44
124
  // Deliberate restart: skip ALL pending (none re-answered), and group the
@@ -49,7 +129,8 @@ function classifyReplay({ candidates = [], cleanShutdown = false, hasCompletedTu
49
129
  const isAnnounceable = typeof announceable === 'function' ? announceable : () => true;
50
130
  const SEP = String.fromCharCode(0); // NUL separator (H8, collision-proof)
51
131
  const groups = new Map();
52
- for (const c of pending) {
132
+ const restartSkipped = [...legacy, ...recoverCodex];
133
+ for (const c of restartSkipped) {
53
134
  if (!isAnnounceable(c)) continue;
54
135
  const thread = c.thread_id == null ? null : c.thread_id;
55
136
  const key = `${c.chat_id}${SEP}${thread == null ? '' : thread}`;
@@ -57,7 +138,13 @@ function classifyReplay({ candidates = [], cleanShutdown = false, hasCompletedTu
57
138
  if (!g) { g = { chat_id: c.chat_id, thread_id: thread, items: [] }; groups.set(key, g); }
58
139
  g.items.push(c);
59
140
  }
60
- return { recover: [], skip: pending, notices: Array.from(groups.values()) };
141
+ const plan = {
142
+ recover: [],
143
+ skip: [...restartSkipped, ...cancelled],
144
+ notices: Array.from(groups.values()),
145
+ };
146
+ if (defer.length > 0) plan.defer = defer;
147
+ return plan;
61
148
  }
62
149
 
63
150
  /**
@@ -73,18 +160,29 @@ function classifyReplay({ candidates = [], cleanShutdown = false, hasCompletedTu
73
160
  * skip items (not in any notice group) are marked terminal silently.
74
161
  *
75
162
  * @param {object} opts
76
- * @param {{recover:Array, skip:Array, notices:Array}} opts.plan
163
+ * @param {{recover:Array, recoverCodex?:Array, skip:Array, defer?:Array,
164
+ * notices:Array}} opts.plan
77
165
  * @param {object} opts.deps
78
166
  * @param {(candidate)=>Promise<{ok:boolean}>} opts.deps.recover crash-path re-dispatch
167
+ * @param {(candidate)=>Promise<{ok:boolean}>} [opts.deps.recoverCodex]
168
+ * exact-runtime Codex crash-path re-dispatch
79
169
  * @param {(group)=>Promise<{ok:boolean, messageId?:any, error?:string}>} opts.deps.sendNotice
80
170
  * @param {(candidate)=>void} opts.deps.markSkipped mark row terminal 'replay-skipped'
81
171
  * @param {(kind, detail)=>void} [opts.deps.logEvent]
82
- * @returns {Promise<{recovered:number, skipped:number, noticed:number, noticeFailed:number}>}
172
+ * @returns {Promise<{recovered:number, skipped:number, noticed:number,
173
+ * noticeFailed:number, deferred?:number}>}
83
174
  */
84
175
  async function executeReplayPlan({ plan, deps }) {
85
- const { recover = [], skip = [], notices = [] } = plan || {};
176
+ const {
177
+ recover = [],
178
+ recoverCodex = [],
179
+ skip = [],
180
+ notices = [],
181
+ defer = [],
182
+ } = plan || {};
86
183
  const log = (deps && deps.logEvent) || (() => {});
87
184
  let recovered = 0; let skipped = 0; let noticed = 0; let noticeFailed = 0;
185
+ let deferred = defer.length;
88
186
 
89
187
  // CRASH branch — recover each (unchanged rc.57 behavior).
90
188
  for (const c of recover) {
@@ -93,6 +191,24 @@ async function executeReplayPlan({ plan, deps }) {
93
191
  if (r && r.ok) recovered += 1; else skipped += 1;
94
192
  }
95
193
 
194
+ // Codex recovery is deliberately separate from the legacy recoverer. If the
195
+ // caller has not wired an exact-runtime path, leave the row pending.
196
+ for (const c of recoverCodex) {
197
+ if (typeof deps.recoverCodex !== 'function') {
198
+ deferred += 1;
199
+ log('codex-replay-deferred', {
200
+ chat_id: c.chat_id,
201
+ thread_id: c.thread_id == null ? null : c.thread_id,
202
+ msg_id: c.msg_id,
203
+ reason: 'codex-recoverer-unavailable',
204
+ });
205
+ continue;
206
+ }
207
+ // eslint-disable-next-line no-await-in-loop
208
+ const r = await deps.recoverCodex(c);
209
+ if (r && r.ok) recovered += 1; else deferred += 1;
210
+ }
211
+
96
212
  // CLEAN branch — notice-then-mark, per group.
97
213
  const inNotices = new Set();
98
214
  for (const g of notices) for (const c of g.items) inNotices.add(c);
@@ -114,7 +230,13 @@ async function executeReplayPlan({ plan, deps }) {
114
230
  for (const c of skip) {
115
231
  if (!inNotices.has(c)) { deps.markSkipped(c); skipped += 1; }
116
232
  }
117
- return { recovered, skipped, noticed, noticeFailed };
233
+ const result = { recovered, skipped, noticed, noticeFailed };
234
+ if (deferred > 0) result.deferred = deferred;
235
+ return result;
118
236
  }
119
237
 
120
- module.exports = { classifyReplay, executeReplayPlan };
238
+ module.exports = {
239
+ classifyReplay,
240
+ executeReplayPlan,
241
+ classifyCodexRecoveryEvidence,
242
+ };