polygram 0.22.0 → 0.23.1

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
@@ -0,0 +1,605 @@
1
+ /**
2
+ * Pure runtime selection for one chat/topic.
3
+ *
4
+ * Claude backend selection delegates to Orchestra's established resolver so
5
+ * aliases, warning behavior, truthy fallthrough, and unknown-value fallback do
6
+ * not drift. Codex is the sole fail-closed extension: it can be selected only
7
+ * with an availability object constructed from Orchestra's branded, asserted
8
+ * spawn-profile receipt.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { createHash } = require('crypto');
14
+ const { pickBackend } = require('@shumkov/orchestra');
15
+
16
+ const PM_DESCRIPTORS = Object.freeze({
17
+ sdk: Object.freeze({
18
+ provider: 'claude',
19
+ runtime: 'claude',
20
+ backend: 'sdk',
21
+ sessionNamespace: 'claude:inline',
22
+ promptMode: 'inline',
23
+ }),
24
+ cli: Object.freeze({
25
+ provider: 'claude',
26
+ runtime: 'claude',
27
+ backend: 'cli',
28
+ sessionNamespace: 'claude:channels',
29
+ promptMode: 'channels',
30
+ }),
31
+ codex: Object.freeze({
32
+ provider: 'codex',
33
+ runtime: 'codex',
34
+ backend: 'codex',
35
+ sessionNamespace: 'codex:app-server',
36
+ promptMode: 'codex',
37
+ }),
38
+ });
39
+
40
+ const CODEX_ERROR_BY_STATE = Object.freeze({
41
+ loading: Object.freeze({
42
+ code: 'CODEX_PREFLIGHT_LOADING',
43
+ action: 'Wait for Codex startup preflight to finish, then try again.',
44
+ }),
45
+ unavailable: Object.freeze({
46
+ code: 'CODEX_RUNTIME_UNAVAILABLE',
47
+ action: 'Fix Codex app-server credentials or startup preflight, then try again.',
48
+ }),
49
+ ineligible: Object.freeze({
50
+ code: 'CODEX_RUNTIME_INELIGIBLE',
51
+ action: 'Choose an eligible Codex account/model or keep this session on Claude.',
52
+ }),
53
+ });
54
+
55
+ const DEFAULT_CODEX_AVAILABILITY = Object.freeze({
56
+ state: 'unavailable',
57
+ reason: 'Codex runtime availability is not wired',
58
+ });
59
+ const MAX_CONFIG_VALUE_LENGTH = 4096;
60
+ const assertedReceiptByAvailability = new WeakMap();
61
+
62
+ class RuntimeConfigError extends Error {
63
+ constructor(message, {
64
+ code,
65
+ source = null,
66
+ value = null,
67
+ runtime = null,
68
+ availabilityState = null,
69
+ reason = null,
70
+ action = null,
71
+ }) {
72
+ super(message);
73
+ this.name = 'RuntimeConfigError';
74
+ this.code = code;
75
+ this.source = source;
76
+ this.value = value;
77
+ this.runtime = runtime;
78
+ this.availabilityState = availabilityState;
79
+ this.reason = reason;
80
+ this.action = action;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Convert an Orchestra-branded preflight receipt into the only availability
86
+ * object that can enable Codex. The assertion is required lazily so a
87
+ * Claude-only installation remains loadable when Codex exports are absent.
88
+ */
89
+ function createCodexRuntimeAvailability(receipt) {
90
+ const orchestra = require('@shumkov/orchestra');
91
+ if (typeof orchestra.assertCodexSpawnProfile !== 'function') {
92
+ throw new RuntimeConfigError(
93
+ 'Codex preflight receipt assertion is not installed.',
94
+ {
95
+ code: 'CODEX_PREFLIGHT_UNWIRED',
96
+ runtime: 'codex',
97
+ availabilityState: 'unavailable',
98
+ reason: 'Installed Orchestra does not expose assertCodexSpawnProfile',
99
+ action: 'Install the reviewed Orchestra Codex runtime before enabling Codex.',
100
+ },
101
+ );
102
+ }
103
+
104
+ // Do not emulate or partially duplicate Orchestra's private branding check.
105
+ // Invalid receipts retain Orchestra's CODEX_PREFLIGHT_RECEIPT_INVALID error.
106
+ const asserted = orchestra.assertCodexSpawnProfile(receipt);
107
+ if (asserted !== receipt) {
108
+ throw new RuntimeConfigError(
109
+ 'Codex preflight assertion returned a different receipt.',
110
+ {
111
+ code: 'CODEX_PREFLIGHT_INVALID',
112
+ runtime: 'codex',
113
+ availabilityState: 'available',
114
+ },
115
+ );
116
+ }
117
+ const profile = asserted?.expectedStaticProfile;
118
+ const spawnProfileId = requireConfigString(
119
+ asserted?.spawnProfileId,
120
+ 'spawn profile ID',
121
+ 'codexAvailability',
122
+ );
123
+ for (const [field, label] of [
124
+ ['model', 'Codex model'],
125
+ ['effort', 'Codex effort'],
126
+ ['cwd', 'Codex cwd'],
127
+ ]) {
128
+ requireConfigString(profile?.[field], label, 'codexAvailability');
129
+ }
130
+
131
+ const availability = Object.freeze({
132
+ state: 'available',
133
+ spawnProfileId,
134
+ });
135
+ assertedReceiptByAvailability.set(availability, asserted);
136
+ return availability;
137
+ }
138
+
139
+ /**
140
+ * @param {object} opts
141
+ * @param {object} [opts.config]
142
+ * @param {string|number|null} [opts.chatId]
143
+ * @param {string|number|null} [opts.threadId]
144
+ * @param {string} [opts.defaultPm='sdk']
145
+ * @param {object} [opts.codexAvailability]
146
+ * @param {object|null} [opts.composedClaudeOptions]
147
+ * The final result of Polygram's buildSdkOptions composition. Without it,
148
+ * model/effort remain unknown rather than inventing precedence.
149
+ * @param {object} [opts.logger]
150
+ * @returns {object}
151
+ */
152
+ function resolveRuntimeConfig({
153
+ config = {},
154
+ chatId = null,
155
+ threadId = null,
156
+ defaultPm = 'sdk',
157
+ codexAvailability,
158
+ composedClaudeOptions = null,
159
+ logger = console,
160
+ } = {}) {
161
+ const scopes = configScopes(config, chatId, threadId);
162
+ const selection = resolveRuntimeDescriptor({
163
+ config,
164
+ chatId,
165
+ threadId,
166
+ defaultPm,
167
+ logger,
168
+ });
169
+ const pmChoice = {
170
+ value: selection.configuredPm,
171
+ source: selection.source,
172
+ };
173
+ const isCodex = selection.runtime === 'codex';
174
+ const descriptor = selection.descriptor;
175
+
176
+ let modelChoice;
177
+ let effortChoice;
178
+ let cwdChoice;
179
+ let agentChoice;
180
+ let spawnProfileId = null;
181
+
182
+ if (isCodex) {
183
+ const receipt = validateCodexAvailability(codexAvailability);
184
+ modelChoice = resolveScopedValue(
185
+ scopes.providerOptions,
186
+ 'codexModel',
187
+ null,
188
+ );
189
+ effortChoice = resolveScopedValue(
190
+ scopes.providerOptions,
191
+ 'codexEffort',
192
+ null,
193
+ );
194
+ cwdChoice = resolveScopedValue(scopes.codexSpawn, 'cwd', null);
195
+ agentChoice = { value: null, source: null };
196
+
197
+ modelChoice.value = requireConfigString(
198
+ modelChoice.value,
199
+ 'Codex model',
200
+ modelChoice.source,
201
+ 'CODEX_RUNTIME_SELECTION_INCOMPLETE',
202
+ );
203
+ effortChoice.value = requireConfigString(
204
+ effortChoice.value,
205
+ 'Codex effort',
206
+ effortChoice.source,
207
+ 'CODEX_RUNTIME_SELECTION_INCOMPLETE',
208
+ );
209
+ cwdChoice.value = requireConfigString(
210
+ cwdChoice.value,
211
+ 'Codex cwd',
212
+ cwdChoice.source,
213
+ 'CODEX_RUNTIME_SELECTION_INCOMPLETE',
214
+ );
215
+
216
+ const expected = receipt.expectedStaticProfile;
217
+ if (expected.cwd !== cwdChoice.value) {
218
+ throw new RuntimeConfigError(
219
+ 'Codex preflight profile does not match selected cwd.',
220
+ {
221
+ code: 'CODEX_PREFLIGHT_PROFILE_MISMATCH',
222
+ runtime: 'codex',
223
+ availabilityState: 'available',
224
+ reason: 'mismatched fields: cwd',
225
+ action: 'Run preflight with the exact selected cwd.',
226
+ },
227
+ );
228
+ }
229
+ const selectedModels = Array.isArray(receipt.modelCatalog)
230
+ ? receipt.modelCatalog.filter(
231
+ (entry) => entry?.model === modelChoice.value,
232
+ )
233
+ : [];
234
+ if (
235
+ selectedModels.length !== 1
236
+ || !Array.isArray(selectedModels[0].supportedReasoningEfforts)
237
+ || !selectedModels[0].supportedReasoningEfforts
238
+ .includes(effortChoice.value)
239
+ ) {
240
+ throw new RuntimeConfigError(
241
+ 'Codex model and effort are not in the authenticated catalog.',
242
+ {
243
+ code: 'CODEX_PREFLIGHT_PROFILE_MISMATCH',
244
+ runtime: 'codex',
245
+ availabilityState: 'available',
246
+ reason: 'selected model/effort pair is unavailable',
247
+ action: 'Choose a model and effort from the authenticated Codex catalog.',
248
+ },
249
+ );
250
+ }
251
+ spawnProfileId = receipt.spawnProfileId;
252
+ } else {
253
+ const chat = scopes.chat;
254
+ const topic = scopes.topic;
255
+ cwdChoice = resolveTruthyValue([
256
+ ['topic', topic],
257
+ ['chat', chat],
258
+ ], 'cwd');
259
+ agentChoice = resolveTruthyValue([
260
+ ['topic', topic],
261
+ ['chat', chat],
262
+ ], 'agent');
263
+ cwdChoice.value = optionalConfigString(
264
+ cwdChoice.value,
265
+ 'Claude cwd',
266
+ cwdChoice.source,
267
+ );
268
+ agentChoice.value = optionalConfigString(
269
+ agentChoice.value,
270
+ 'Claude agent',
271
+ agentChoice.source,
272
+ );
273
+
274
+ if (composedClaudeOptions == null) {
275
+ modelChoice = { value: null, source: null };
276
+ effortChoice = { value: null, source: null };
277
+ } else {
278
+ if (
279
+ typeof composedClaudeOptions !== 'object'
280
+ || Array.isArray(composedClaudeOptions)
281
+ ) {
282
+ throw invalidConfigValue(
283
+ 'composed Claude options',
284
+ composedClaudeOptions,
285
+ 'composed-options',
286
+ );
287
+ }
288
+ modelChoice = {
289
+ value: optionalConfigString(
290
+ composedClaudeOptions.model,
291
+ 'composed Claude model',
292
+ 'composed-options',
293
+ ),
294
+ source: 'composed-options',
295
+ };
296
+ effortChoice = {
297
+ value: optionalConfigString(
298
+ composedClaudeOptions.effort,
299
+ 'composed Claude effort',
300
+ 'composed-options',
301
+ ),
302
+ source: 'composed-options',
303
+ };
304
+ }
305
+ }
306
+
307
+ const modelFamily = descriptor.provider;
308
+ const identity = stableRuntimeConfigIdentity({
309
+ runtime: descriptor.runtime,
310
+ backend: descriptor.backend,
311
+ sessionNamespace: descriptor.sessionNamespace,
312
+ promptMode: descriptor.promptMode,
313
+ modelFamily,
314
+ model: modelChoice.value,
315
+ effort: effortChoice.value,
316
+ cwd: cwdChoice.value,
317
+ agent: agentChoice.value,
318
+ spawnProfileId,
319
+ });
320
+
321
+ return Object.freeze({
322
+ configuredPm: pmChoice.value,
323
+ ...descriptor,
324
+ modelFamily,
325
+ modelField: modelFamily === 'codex' ? 'codexModel' : 'model',
326
+ effortField: modelFamily === 'codex' ? 'codexEffort' : 'effort',
327
+ model: modelChoice.value,
328
+ effort: effortChoice.value,
329
+ cwd: cwdChoice.value,
330
+ agent: agentChoice.value,
331
+ sources: Object.freeze({
332
+ pm: pmChoice.source,
333
+ model: modelChoice.source,
334
+ effort: effortChoice.source,
335
+ cwd: cwdChoice.source,
336
+ agent: agentChoice.source,
337
+ }),
338
+ spawnProfileId,
339
+ // This digest belongs to Polygram's config/application layer. Orchestra's
340
+ // strict replaceRuntime uses its own runtime/backend/profile identity.
341
+ runtimeConfigIdentity: identity,
342
+ });
343
+ }
344
+
345
+ /**
346
+ * Resolve only the operator-selected Codex inputs needed to run the matching
347
+ * preflight. This does not make Codex available and carries no receipt.
348
+ */
349
+ function resolveCodexRuntimeRequest({
350
+ config = {},
351
+ chatId = null,
352
+ threadId = null,
353
+ defaultPm = 'sdk',
354
+ } = {}) {
355
+ const pmChoice = resolvePmChoice(config, chatId, threadId, defaultPm);
356
+ if (!chatId || pmChoice.value !== 'codex') return null;
357
+
358
+ const scopes = configScopes(config, chatId, threadId);
359
+ const modelChoice = resolveScopedValue(
360
+ scopes.providerOptions,
361
+ 'codexModel',
362
+ null,
363
+ );
364
+ const effortChoice = resolveScopedValue(
365
+ scopes.providerOptions,
366
+ 'codexEffort',
367
+ null,
368
+ );
369
+ const cwdChoice = resolveScopedValue(scopes.codexSpawn, 'cwd', null);
370
+ const sources = Object.freeze({
371
+ pm: pmChoice.source,
372
+ model: modelChoice.source,
373
+ effort: effortChoice.source,
374
+ cwd: cwdChoice.source,
375
+ });
376
+
377
+ return Object.freeze({
378
+ runtime: 'codex',
379
+ model: requireConfigString(
380
+ modelChoice.value,
381
+ 'Codex model',
382
+ modelChoice.source,
383
+ 'CODEX_RUNTIME_SELECTION_INCOMPLETE',
384
+ ),
385
+ effort: requireConfigString(
386
+ effortChoice.value,
387
+ 'Codex effort',
388
+ effortChoice.source,
389
+ 'CODEX_RUNTIME_SELECTION_INCOMPLETE',
390
+ ),
391
+ cwd: requireConfigString(
392
+ cwdChoice.value,
393
+ 'Codex cwd',
394
+ cwdChoice.source,
395
+ 'CODEX_RUNTIME_SELECTION_INCOMPLETE',
396
+ ),
397
+ sources,
398
+ });
399
+ }
400
+
401
+ function configScopes(config, chatId, threadId) {
402
+ const hasChat = Boolean(chatId);
403
+ const chat = hasChat ? config?.chats?.[chatId] : null;
404
+ const topic = hasChat && threadId ? chat?.topics?.[threadId] : null;
405
+ return {
406
+ chat,
407
+ topic,
408
+ providerOptions: [
409
+ ['topic', topic],
410
+ ['chat', chat],
411
+ ['bot', config?.bot],
412
+ ['default', config?.defaults],
413
+ ],
414
+ codexSpawn: [
415
+ ['topic', topic],
416
+ ['chat', chat],
417
+ ['default', config?.defaults],
418
+ ],
419
+ };
420
+ }
421
+
422
+ function resolvePmChoice(config, chatId, threadId, defaultPm) {
423
+ if (!chatId) return { value: defaultPm, source: 'default' };
424
+ const chat = config?.chats?.[chatId];
425
+ const topic = threadId && chat?.topics?.[threadId];
426
+ if (topic?.pm) return { value: topic.pm, source: 'topic' };
427
+ if (chat?.pm) return { value: chat.pm, source: 'chat' };
428
+ if (config?.bot?.pm) return { value: config.bot.pm, source: 'bot' };
429
+ return { value: defaultPm, source: 'default' };
430
+ }
431
+
432
+ function resolveRuntimeDescriptor({
433
+ config = {},
434
+ chatId = null,
435
+ threadId = null,
436
+ defaultPm = 'sdk',
437
+ logger = console,
438
+ } = {}) {
439
+ const pmChoice = resolvePmChoice(config, chatId, threadId, defaultPm);
440
+ const isCodex = Boolean(chatId) && pmChoice.value === 'codex';
441
+ const pickedBackend = isCodex
442
+ ? 'codex'
443
+ : pickBackend({
444
+ config,
445
+ chatId,
446
+ threadId,
447
+ logger,
448
+ pmDefault: defaultPm,
449
+ });
450
+ // Orchestra's factory treats every non-CLI result as SDK. In particular,
451
+ // pickBackend returns pmDefault untouched when chatId is falsy.
452
+ const canonicalBackend = isCodex
453
+ ? 'codex'
454
+ : pickedBackend === 'cli'
455
+ ? 'cli'
456
+ : 'sdk';
457
+ return Object.freeze({
458
+ configuredPm: pmChoice.value,
459
+ source: pmChoice.source,
460
+ runtime: PM_DESCRIPTORS[canonicalBackend].runtime,
461
+ backend: canonicalBackend,
462
+ promptMode: PM_DESCRIPTORS[canonicalBackend].promptMode,
463
+ descriptor: PM_DESCRIPTORS[canonicalBackend],
464
+ });
465
+ }
466
+
467
+ function resolveScopedValue(scopes, field, fallback) {
468
+ for (const [source, scope] of scopes) {
469
+ if (
470
+ scope
471
+ && Object.hasOwn(scope, field)
472
+ && scope[field] !== null
473
+ && scope[field] !== undefined
474
+ ) {
475
+ return { value: scope[field], source };
476
+ }
477
+ }
478
+ return { value: fallback, source: 'default' };
479
+ }
480
+
481
+ function resolveTruthyValue(scopes, field) {
482
+ for (const [source, scope] of scopes) {
483
+ if (scope?.[field]) return { value: scope[field], source };
484
+ }
485
+ return { value: null, source: null };
486
+ }
487
+
488
+ function validateCodexAvailability(input) {
489
+ const availability = input ?? DEFAULT_CODEX_AVAILABILITY;
490
+ if (availability?.state === 'available') {
491
+ const receipt = (
492
+ availability
493
+ && typeof availability === 'object'
494
+ && assertedReceiptByAvailability.get(availability)
495
+ );
496
+ if (!receipt) {
497
+ throw new RuntimeConfigError(
498
+ 'Codex availability was not constructed from an asserted spawn receipt.',
499
+ {
500
+ code: 'CODEX_PREFLIGHT_INVALID',
501
+ runtime: 'codex',
502
+ availabilityState: 'available',
503
+ reason: 'unbranded or copied availability object',
504
+ action: 'Use createCodexRuntimeAvailability with the exact preflight receipt.',
505
+ },
506
+ );
507
+ }
508
+ return receipt;
509
+ }
510
+
511
+ const errorInfo = CODEX_ERROR_BY_STATE[availability?.state];
512
+ if (!errorInfo) {
513
+ throw new RuntimeConfigError(
514
+ `Unknown Codex availability state '${String(availability?.state)}'`,
515
+ {
516
+ code: 'RUNTIME_CONFIG_INVALID',
517
+ source: 'codexAvailability',
518
+ value: availability?.state,
519
+ runtime: 'codex',
520
+ action: 'Use available, loading, unavailable, or ineligible.',
521
+ },
522
+ );
523
+ }
524
+
525
+ const reason = typeof availability.reason === 'string' && availability.reason
526
+ ? availability.reason
527
+ : 'No additional reason was provided';
528
+ throw new RuntimeConfigError(
529
+ `Codex runtime is ${availability.state}: ${reason}. ${errorInfo.action}`,
530
+ {
531
+ code: errorInfo.code,
532
+ runtime: 'codex',
533
+ availabilityState: availability.state,
534
+ reason,
535
+ action: errorInfo.action,
536
+ },
537
+ );
538
+ }
539
+
540
+ function invalidConfigValue(label, value, source, code = 'RUNTIME_CONFIG_VALUE_INVALID') {
541
+ return new RuntimeConfigError(
542
+ `${label} at ${source ?? 'unknown'} scope must be a bounded string.`,
543
+ {
544
+ code,
545
+ source,
546
+ value: (
547
+ typeof value === 'string'
548
+ || typeof value === 'number'
549
+ || typeof value === 'boolean'
550
+ ) ? value : null,
551
+ runtime: label.startsWith('Codex') ? 'codex' : null,
552
+ },
553
+ );
554
+ }
555
+
556
+ function requireConfigString(
557
+ value,
558
+ label,
559
+ source,
560
+ missingCode = 'RUNTIME_CONFIG_VALUE_INVALID',
561
+ ) {
562
+ if (
563
+ typeof value !== 'string'
564
+ || value.trim().length === 0
565
+ || value.length > MAX_CONFIG_VALUE_LENGTH
566
+ ) {
567
+ const code = value == null || value === ''
568
+ ? missingCode
569
+ : 'RUNTIME_CONFIG_VALUE_INVALID';
570
+ throw invalidConfigValue(label, value, source, code);
571
+ }
572
+ return value;
573
+ }
574
+
575
+ function optionalConfigString(value, label, source) {
576
+ if (value == null) return null;
577
+ return requireConfigString(value, label, source);
578
+ }
579
+
580
+ function stableRuntimeConfigIdentity(fields) {
581
+ const model = fields.runtime === 'codex' ? null : fields.model;
582
+ const effort = fields.runtime === 'codex' ? null : fields.effort;
583
+ const canonical = JSON.stringify([
584
+ fields.runtime,
585
+ fields.backend,
586
+ fields.sessionNamespace,
587
+ fields.promptMode,
588
+ fields.modelFamily,
589
+ model,
590
+ effort,
591
+ fields.cwd,
592
+ fields.agent,
593
+ fields.spawnProfileId,
594
+ ]);
595
+ const digest = createHash('sha256').update(canonical).digest('hex');
596
+ return `runtime-config:v1:${digest}`;
597
+ }
598
+
599
+ module.exports = {
600
+ createCodexRuntimeAvailability,
601
+ RuntimeConfigError,
602
+ resolveCodexRuntimeRequest,
603
+ resolveRuntimeConfig,
604
+ resolveRuntimeDescriptor,
605
+ };
@@ -110,9 +110,12 @@ function createBuildSdkOptions({
110
110
  // was unconditionally exported so the deleted bin/approval-hook.js
111
111
  // could authenticate; with the hook gone, the only IPC consumers
112
112
  // are external scripts (cron-driven sends) running in their own
113
- // processes with their own access to /tmp/polygram-<bot>.secret.
113
+ // processes with access to the owner-only IPC runtime directory.
114
114
  if (botConfig.exposeIpcSecretToChildren && processEnv.POLYGRAM_IPC_SECRET) {
115
115
  childEnv.POLYGRAM_IPC_SECRET = processEnv.POLYGRAM_IPC_SECRET;
116
+ if (processEnv.POLYGRAM_IPC_DIR) {
117
+ childEnv.POLYGRAM_IPC_DIR = processEnv.POLYGRAM_IPC_DIR;
118
+ }
116
119
  }
117
120
  if (botConfig.needsToken) {
118
121
  childEnv.TELEGRAM_BOT_TOKEN = botConfig.token || '';
@@ -22,6 +22,10 @@
22
22
 
23
23
  const { getTopicConfig } = require('../session-key');
24
24
  const { pickBackend } = require('@shumkov/orchestra');
25
+ const {
26
+ isCodexProcess,
27
+ normalizeStreamText,
28
+ } = require('../codex/event-normalizer');
25
29
 
26
30
  function createSdkCallbacks({
27
31
  db,
@@ -91,6 +95,22 @@ function createSdkCallbacks({
91
95
  // making historical telemetry meaningless.
92
96
  const chatConfig = config.chats[entry.chatId] || {};
93
97
  const topicConfig = getTopicConfig(chatConfig, entry.threadId || null);
98
+ if (isCodexProcess(entry)) {
99
+ dbWrite(() => db.upsertProviderSession({
100
+ session_key: sessionKey,
101
+ namespace: 'codex:app-server',
102
+ provider: 'codex',
103
+ provider_session_id: event.providerSessionId ?? event.session_id,
104
+ app_server_session_id: event.appServerSessionId ?? null,
105
+ agent: null,
106
+ cwd: entry.cwd,
107
+ model: entry.model,
108
+ effort: entry.effort,
109
+ pm_backend: 'codex',
110
+ }), `upsert Codex session ${sessionKey}`);
111
+ return;
112
+ }
113
+
94
114
  dbWrite(() => db.upsertSession({
95
115
  session_key: sessionKey,
96
116
  chat_id: entry.chatId,
@@ -107,9 +127,22 @@ function createSdkCallbacks({
107
127
  }), `upsert session ${sessionKey}`);
108
128
  },
109
129
 
110
- onClose: (sessionKey, code, entry) => {
130
+ onClose: (sessionKey, code, closeDetailOrEntry, appendedEntry) => {
131
+ // ProcessManager appends the live Process after the event arguments.
132
+ // Claude emits only `code`, while Codex emits `code, closeDetail`; keep
133
+ // the detail from shifting the process entry out of this callback.
134
+ const entry = appendedEntry ?? closeDetailOrEntry;
135
+ const closeDetail = appendedEntry ? closeDetailOrEntry : null;
111
136
  logger.log?.(`[${entry.label}] Process exited (code ${code})`);
112
- logEvent('process-close', { chat_id: entry.chatId, session_key: sessionKey, code });
137
+ logEvent('process-close', {
138
+ chat_id: entry.chatId,
139
+ session_key: sessionKey,
140
+ code,
141
+ ...(closeDetail?.generationId
142
+ ? { generation_id: closeDetail.generationId }
143
+ : {}),
144
+ ...(closeDetail?.reason ? { reason: closeDetail.reason } : {}),
145
+ });
113
146
  // 0.13 D3: a session closing mid-autonomous-cycle must tear down the
114
147
  // controller's visuals (typing loop + anchor reaction) — the safety net
115
148
  // against a forever-typing leak on a dead session.
@@ -128,12 +161,22 @@ function createSdkCallbacks({
128
161
  },
129
162
 
130
163
  onStreamChunk: (sessionKey, partial, entry) => {
164
+ const normalized = normalizeStreamText(partial, entry);
165
+ if (isCodexProcess(entry) && normalized == null) {
166
+ logEvent('codex-stream-event-invalid', {
167
+ chat_id: entry?.chatId ?? getChatIdFromKey(sessionKey),
168
+ session_key: sessionKey,
169
+ generation_id: entry?.generationId ?? null,
170
+ value_type: typeof partial,
171
+ });
172
+ return;
173
+ }
131
174
  // Route to the head pending's per-turn streamer. In the
132
175
  // concurrent-pending model, only the HEAD is the turn Claude
133
176
  // is actively emitting events for.
134
177
  const head = entry.pendingQueue?.[0];
135
178
  const s = head?.context?.streamer;
136
- if (s) s.onChunk(partial).catch(() => {});
179
+ if (s) s.onChunk(normalized).catch(() => {});
137
180
  // Heartbeat the reactor so long text generation doesn't trip
138
181
  // the 10s STALL → 🥱 / 30s TIMEOUT → 😨 promotion.
139
182
  const r = head?.context?.reactor;