polygram 0.36.1 → 0.37.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.36.1",
4
+ "version": "0.37.0",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -18,6 +18,8 @@
18
18
  "replayWindowMs": 180000,
19
19
  "_comment_resumeInterruptedCliTurns": "Temporary staged-rollout flag. Default false. When true, a deliberate clean restart may strictly resume an interrupted CLI session and send one tracked literal `continue`; crashes still replay the original message. Enable only after the exact pinned-CLI recovery gate passes for the installed Orchestra version.",
20
20
  "resumeInterruptedCliTurns": false,
21
+ "_comment_resumeInterruptedCodexTurns": "Independent staged-rollout flag for exact native Codex clean-restart resume plus one literal `continue`. Default false; crashes and reboot never authorize continuation.",
22
+ "resumeInterruptedCodexTurns": false,
21
23
  "_comment_chrome": "Opt-in to Claude Code's Chrome-extension browser-automation integration. Default false. Requires the 'Claude in Chrome' extension installed in the daemon-user's Chrome browser AND a live GUI session (Chrome must be running). See https://code.claude.com/docs/en/chrome. Per-chat override via `config.chats.<id>.chrome`.",
22
24
  "chrome": false,
23
25
  "_comment_pairedChatDefaults": "When a user sends /pair <CODE> from a private chat that isn't in config.chats yet, polygram auto-creates a chat entry using these defaults (merged over top-level `defaults`). Leave out `cwd` at your peril — without it, auto-onboarded DMs have no working directory and pairing will fail.",
@@ -186,6 +186,7 @@ function createCodexRuntimeController({
186
186
  config,
187
187
  db,
188
188
  processEnv = process.env,
189
+ sessionLauncher = null,
189
190
  defaultDaemonSecretRoots = [],
190
191
  resolveHostIdentity = resolveCodexHostIdentity,
191
192
  resolveBinary = resolvePinnedCodexBinary,
@@ -235,6 +236,14 @@ function createCodexRuntimeController({
235
236
  || !Number.isSafeInteger(catalogMaxAgeMs)
236
237
  || catalogMaxAgeMs <= 0
237
238
  || typeof now !== 'function'
239
+ || (
240
+ sessionLauncher !== null
241
+ && (
242
+ typeof sessionLauncher !== 'string'
243
+ || sessionLauncher.length === 0
244
+ || /[\u0000-\u001f\u007f]/u.test(sessionLauncher)
245
+ )
246
+ )
238
247
  ) {
239
248
  throw new TypeError('Codex runtime controller dependencies are incomplete');
240
249
  }
@@ -358,6 +367,7 @@ function createCodexRuntimeController({
358
367
  return {
359
368
  codexHome,
360
369
  binaryPath: configured.binary ?? processEnv.POLYGRAM_CODEX_BIN,
370
+ sessionLauncher,
361
371
  daemonSecretRoots: [
362
372
  ...new Set([...defaultDaemonSecretRoots, ...configuredSecretRoots]),
363
373
  ],
@@ -466,6 +476,7 @@ function createCodexRuntimeController({
466
476
  request,
467
477
  codexHome: configured.codexHome,
468
478
  binaryPath: configured.binaryPath,
479
+ sessionLauncher: configured.sessionLauncher,
469
480
  daemonSecretRoots: configured.daemonSecretRoots,
470
481
  });
471
482
  if (preparingByKey.has(key)) return preparingByKey.get(key);
@@ -483,6 +494,7 @@ function createCodexRuntimeController({
483
494
  model: request.model,
484
495
  effort: request.effort,
485
496
  processEnv,
497
+ sessionLauncher: configured.sessionLauncher,
486
498
  });
487
499
  const homeAttestation = await attestCodexHome(
488
500
  expectedStaticProfile.codexHome,
@@ -666,6 +678,11 @@ function createCodexRuntimeController({
666
678
  cwd: expectedStaticProfile.cwd,
667
679
  env: expectedStaticProfile.env,
668
680
  expectedConfigSha256: expectedStaticProfile.ownedConfigSha256,
681
+ ...(expectedStaticProfile.sessionLauncher == null ? {} : {
682
+ sessionLauncher: expectedStaticProfile.sessionLauncher,
683
+ expectedSessionLauncherSha256:
684
+ expectedStaticProfile.sessionLauncherSha256,
685
+ }),
669
686
  onNotification,
670
687
  onFault,
671
688
  });
@@ -1206,7 +1223,10 @@ function createCodexRuntimeController({
1206
1223
  for (const waiter of record.retirementWaiters.splice(0)) {
1207
1224
  waiter.resolve(Object.freeze({
1208
1225
  committed: true,
1209
- disposition: 'retired',
1226
+ disposition: record.stopCancellation
1227
+ ? 'stop-cancelled'
1228
+ : 'retired',
1229
+ ...(record.stopCancellation ?? {}),
1210
1230
  }));
1211
1231
  }
1212
1232
  return true;
@@ -1309,6 +1329,7 @@ function createCodexRuntimeController({
1309
1329
  attemptId: attempt.attempt_id,
1310
1330
  providerSessionId: attempt.thread_id,
1311
1331
  providerTurnId: attempt.turn_id,
1332
+ sourceMsgId: attempt.telegram_source_message_id,
1312
1333
  });
1313
1334
  }
1314
1335
  if (maybeRetire(record)) {
@@ -1317,6 +1338,7 @@ function createCodexRuntimeController({
1317
1338
  disposition: settlement.disposition === 'stop-cancelled'
1318
1339
  ? 'stop-cancelled'
1319
1340
  : 'retired',
1341
+ ...(record.stopCancellation ?? {}),
1320
1342
  });
1321
1343
  }
1322
1344
  if (settlement.disposition !== 'pending-delivery') {
@@ -18,6 +18,7 @@ const {
18
18
  buildCodexAppServerEnv,
19
19
  CodexAppServerClient,
20
20
  codexProtocolSchema,
21
+ characterizePinnedSessionLauncher,
21
22
  resolveCodexTargetPin,
22
23
  } = require('@shumkov/orchestra');
23
24
 
@@ -832,11 +833,14 @@ async function collectPermissionProfiles(client, workspace) {
832
833
 
833
834
  async function characterizeRuntime(materialized, {
834
835
  binaryReceipt,
836
+ sessionLauncherReceipt,
835
837
  clientFactory,
836
838
  }) {
837
839
  let latchedFault = null;
838
840
  const client = clientFactory({
839
841
  binary: binaryReceipt.path,
842
+ sessionLauncher: sessionLauncherReceipt?.path ?? null,
843
+ expectedSessionLauncherSha256: sessionLauncherReceipt?.sha256 ?? null,
840
844
  cwd: materialized.workspace,
841
845
  codexHome: materialized.codexHome,
842
846
  env: materialized.env,
@@ -937,11 +941,13 @@ function createCodexRuntimeProfileBuilder({
937
941
  clientFactory = (options) => new CodexAppServerClient(options),
938
942
  protocolSchema = codexProtocolSchema,
939
943
  resolveTargetPin = resolveCodexTargetPin,
944
+ attestSessionLauncher = characterizePinnedSessionLauncher,
940
945
  } = {}) {
941
946
  if (
942
947
  !Array.isArray(temporaryRoots)
943
948
  || typeof clientFactory !== 'function'
944
949
  || typeof resolveTargetPin !== 'function'
950
+ || !['undefined', 'function'].includes(typeof attestSessionLauncher)
945
951
  ) {
946
952
  throw new TypeError('Codex runtime profile builder options are invalid');
947
953
  }
@@ -950,9 +956,38 @@ function createCodexRuntimeProfileBuilder({
950
956
  async prepare(options = {}) {
951
957
  const targetPin = resolveTargetPin();
952
958
  validateBinaryReceipt(options.binaryReceipt, protocolSchema, targetPin);
959
+ if (
960
+ options.sessionLauncher != null
961
+ && typeof attestSessionLauncher !== 'function'
962
+ ) {
963
+ throw fail(
964
+ 'Codex session launcher attestation is unavailable.',
965
+ 'CODEX_SESSION_LAUNCHER_MISMATCH',
966
+ 'Install the Orchestra release that attests the session launcher.',
967
+ );
968
+ }
969
+ const sessionLauncherReceipt = options.sessionLauncher == null
970
+ ? null
971
+ : await attestSessionLauncher(
972
+ requiredString(options.sessionLauncher, 'session launcher'),
973
+ );
974
+ if (
975
+ sessionLauncherReceipt !== null
976
+ && (
977
+ sessionLauncherReceipt.path !== options.sessionLauncher
978
+ || !SHA256_RE.test(sessionLauncherReceipt.sha256)
979
+ )
980
+ ) {
981
+ throw fail(
982
+ 'Codex session launcher attestation was inconsistent.',
983
+ 'CODEX_SESSION_LAUNCHER_MISMATCH',
984
+ 'Restore the exact root-owned session launcher before enabling Codex.',
985
+ );
986
+ }
953
987
  const materialized = provisionRuntimeFiles(options, temporaryRoots);
954
988
  const characterized = await characterizeRuntime(materialized, {
955
989
  binaryReceipt: options.binaryReceipt,
990
+ sessionLauncherReceipt,
956
991
  clientFactory,
957
992
  });
958
993
  const model = requiredString(options.model, 'model');
@@ -979,6 +1014,8 @@ function createCodexRuntimeProfileBuilder({
979
1014
  permissionProfileId: CODEX_PERMISSION_PROFILE_ID,
980
1015
  model,
981
1016
  effort,
1017
+ sessionLauncher: sessionLauncherReceipt?.path ?? null,
1018
+ sessionLauncherSha256: sessionLauncherReceipt?.sha256 ?? null,
982
1019
  });
983
1020
  },
984
1021
  });
@@ -6,6 +6,9 @@ const {
6
6
  const {
7
7
  processMatchesRuntime,
8
8
  } = require('../runtime-config');
9
+ const {
10
+ validateStrictResumeSpawn,
11
+ } = require('../ops/clean-resume');
9
12
 
10
13
  async function buildCodexSpawnContext({
11
14
  sessionKey,
@@ -18,6 +21,7 @@ async function buildCodexSpawnContext({
18
21
  getSessionLabel,
19
22
  logEvent,
20
23
  mutateSessionOnDrift = true,
24
+ strictResume = null,
21
25
  }) {
22
26
  const prepared = await runtimeController.prepareSession({
23
27
  sessionKey,
@@ -31,6 +35,38 @@ async function buildCodexSpawnContext({
31
35
  'codex',
32
36
  'codex',
33
37
  );
38
+ if (strictResume) {
39
+ const stored = db.getProviderSession(sessionKey, 'codex:app-server');
40
+ const existingSessionId = validateStrictResumeSpawn({
41
+ strictResume,
42
+ promptBackend: 'codex',
43
+ liveProcess,
44
+ runtime: stored,
45
+ resolved: {
46
+ runtime: 'codex',
47
+ backend: 'codex',
48
+ cwd: runtime.cwd,
49
+ model: runtime.model,
50
+ effort: runtime.effort,
51
+ spawnProfileId: runtime.spawnProfileId,
52
+ },
53
+ });
54
+ return {
55
+ runtime: 'codex',
56
+ spawnProfileId: runtime.spawnProfileId,
57
+ modelSettings: {
58
+ model: runtime.model,
59
+ effort: runtime.effort,
60
+ },
61
+ chatId,
62
+ threadId,
63
+ label: getSessionLabel(chatConfig, threadId),
64
+ existingSessionId,
65
+ resumePolicy: 'require-interrupted-turn',
66
+ expectedInterruptedTurnId: strictResume.expectedInterruptedTurnId,
67
+ noWaitForCapacity: true,
68
+ };
69
+ }
34
70
  const replacementReason = (
35
71
  liveCodexProcess
36
72
  ) ? runtimeController.processReplacementReason(
package/lib/db.js CHANGED
@@ -42,7 +42,7 @@ const {
42
42
  //
43
43
  // 0.15: bumped 13 → 14. Adds migration 014-secret-redactions.sql
44
44
  // (secret_redactions audit table + messages.secret_scanned_at).
45
- const SCHEMA_VERSION = 18;
45
+ const SCHEMA_VERSION = 19;
46
46
 
47
47
  // Sentinel `error` value for outbound rows whose API call may or may not
48
48
  // have reached Telegram. markStalePending writes it; hasOutboundReplyTo
@@ -248,11 +248,11 @@ function wrap(db) {
248
248
  INSERT INTO agent_runtime_sessions (
249
249
  session_key, namespace, provider, provider_session_id,
250
250
  app_server_session_id, agent, cwd, model, effort, pm_backend,
251
- created_ts, last_active_ts, generation_id
251
+ created_ts, last_active_ts, generation_id, spawn_profile_id
252
252
  ) VALUES (
253
253
  @session_key, @namespace, @provider, @provider_session_id,
254
254
  @app_server_session_id, @agent, @cwd, @model, @effort, @pm_backend,
255
- @ts, @ts, @generation_id
255
+ @ts, @ts, @generation_id, @spawn_profile_id
256
256
  )
257
257
  ON CONFLICT(session_key, namespace) DO UPDATE SET
258
258
  provider = excluded.provider,
@@ -263,9 +263,21 @@ function wrap(db) {
263
263
  model = excluded.model,
264
264
  effort = excluded.effort,
265
265
  pm_backend = excluded.pm_backend,
266
+ spawn_profile_id = excluded.spawn_profile_id,
266
267
  last_active_ts = excluded.last_active_ts,
267
268
  generation_id = CASE
268
269
  WHEN agent_runtime_sessions.provider_session_id = excluded.provider_session_id
270
+ AND agent_runtime_sessions.provider = excluded.provider
271
+ AND (
272
+ excluded.provider = 'claude'
273
+ OR (
274
+ agent_runtime_sessions.cwd IS excluded.cwd
275
+ AND agent_runtime_sessions.model IS excluded.model
276
+ AND agent_runtime_sessions.effort IS excluded.effort
277
+ AND agent_runtime_sessions.pm_backend IS excluded.pm_backend
278
+ AND agent_runtime_sessions.spawn_profile_id IS excluded.spawn_profile_id
279
+ )
280
+ )
269
281
  AND agent_runtime_sessions.generation_id IS NOT NULL
270
282
  AND agent_runtime_sessions.generation_id != ''
271
283
  THEN agent_runtime_sessions.generation_id
@@ -321,6 +333,7 @@ function wrap(db) {
321
333
  model: row.model || null,
322
334
  effort: row.effort || null,
323
335
  pm_backend: row.pm_backend || null,
336
+ spawn_profile_id: row.spawn_profile_id || null,
324
337
  generation_id: crypto.randomUUID(),
325
338
  ts: row.ts || Date.now(),
326
339
  };
@@ -551,8 +564,10 @@ function wrap(db) {
551
564
  const insertCleanRestartIntentStmt = db.prepare(`
552
565
  INSERT INTO clean_restart_resume_intents (
553
566
  bot_name, session_key, session_generation_id,
554
- source_message_id, shutdown_at, policy_version
555
- ) VALUES (?, ?, ?, ?, ?, ?)
567
+ source_message_id, shutdown_at, policy_version,
568
+ interrupted_provider_turn_id, interrupted_spawn_profile_id,
569
+ continuation_authorized
570
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
556
571
  `);
557
572
 
558
573
  function normalizeResumeIntent(intent) {
@@ -565,7 +580,54 @@ function wrap(db) {
565
580
  if (!Number.isSafeInteger(intent.policyVersion) || intent.policyVersion <= 0) {
566
581
  throw new TypeError('clean restart intent policy version is invalid');
567
582
  }
568
- return intent;
583
+ const interruptedProviderTurnId =
584
+ intent.interruptedProviderTurnId ?? null;
585
+ const interruptedSpawnProfileId =
586
+ intent.interruptedSpawnProfileId ?? null;
587
+ if (
588
+ (interruptedProviderTurnId === null)
589
+ !== (interruptedSpawnProfileId === null)
590
+ || (
591
+ interruptedProviderTurnId !== null
592
+ && (
593
+ typeof interruptedProviderTurnId !== 'string'
594
+ || interruptedProviderTurnId.length === 0
595
+ || typeof interruptedSpawnProfileId !== 'string'
596
+ || interruptedSpawnProfileId.length === 0
597
+ )
598
+ )
599
+ ) {
600
+ throw new TypeError(
601
+ 'clean restart Codex turn and spawn-profile IDs must be paired',
602
+ );
603
+ }
604
+ if (interruptedProviderTurnId !== null && intent.policyVersion !== 2) {
605
+ throw new TypeError('clean restart Codex intents require policy version 2');
606
+ }
607
+ const expectedCodexIdentity = interruptedProviderTurnId === null
608
+ ? null
609
+ : {
610
+ providerSessionId: intent.expectedProviderSessionId,
611
+ cwd: intent.expectedCwd,
612
+ model: intent.expectedModel,
613
+ effort: intent.expectedEffort,
614
+ };
615
+ if (
616
+ expectedCodexIdentity !== null
617
+ && Object.values(expectedCodexIdentity).some(
618
+ (value) => typeof value !== 'string' || value.length === 0,
619
+ )
620
+ ) {
621
+ throw new TypeError(
622
+ 'clean restart Codex intent requires exact retired runtime identity',
623
+ );
624
+ }
625
+ return {
626
+ ...intent,
627
+ interruptedProviderTurnId,
628
+ interruptedSpawnProfileId,
629
+ expectedCodexIdentity,
630
+ };
569
631
  }
570
632
 
571
633
  function recordShutdown({
@@ -574,7 +636,11 @@ function wrap(db) {
574
636
  since,
575
637
  clean,
576
638
  resumeIntents = [],
639
+ continuationAuthorized = false,
577
640
  }) {
641
+ if (resumeIntents.length > 0 && continuationAuthorized !== true) {
642
+ throw new TypeError('clean restart intents require deploy authorization');
643
+ }
578
644
  const cutoff = since ?? now - 30 * 60 * 1000;
579
645
  const txn = db.transaction(() => {
580
646
  const marked = markReplayPendingStmt.run(botName, cutoff);
@@ -597,7 +663,9 @@ function wrap(db) {
597
663
  }
598
664
  const session = getProviderSessionStmt.get(
599
665
  intent.sessionKey,
600
- CLAUDE_CHANNELS_NAMESPACE,
666
+ intent.interruptedProviderTurnId === null
667
+ ? CLAUDE_CHANNELS_NAMESPACE
668
+ : CODEX_APP_SERVER_NAMESPACE,
601
669
  );
602
670
  if (
603
671
  !session
@@ -608,6 +676,28 @@ function wrap(db) {
608
676
  `clean restart intent session generation is unavailable for ${intent.sessionKey}`,
609
677
  );
610
678
  }
679
+ if (
680
+ intent.interruptedSpawnProfileId !== null
681
+ && session.spawn_profile_id !== intent.interruptedSpawnProfileId
682
+ ) {
683
+ throw new Error(
684
+ `clean restart intent spawn profile changed for ${intent.sessionKey}`,
685
+ );
686
+ }
687
+ if (
688
+ intent.expectedCodexIdentity !== null
689
+ && (
690
+ session.provider_session_id
691
+ !== intent.expectedCodexIdentity.providerSessionId
692
+ || session.cwd !== intent.expectedCodexIdentity.cwd
693
+ || session.model !== intent.expectedCodexIdentity.model
694
+ || session.effort !== intent.expectedCodexIdentity.effort
695
+ )
696
+ ) {
697
+ throw new Error(
698
+ `clean restart intent provider session changed for ${intent.sessionKey}`,
699
+ );
700
+ }
611
701
  insertCleanRestartIntentStmt.run(
612
702
  botName,
613
703
  intent.sessionKey,
@@ -615,6 +705,8 @@ function wrap(db) {
615
705
  intent.sourceMessageId,
616
706
  now,
617
707
  intent.policyVersion,
708
+ intent.interruptedProviderTurnId,
709
+ intent.interruptedSpawnProfileId,
618
710
  );
619
711
  }
620
712
  db.prepare(`
@@ -4199,6 +4291,7 @@ function wrap(db) {
4199
4291
  now = Date.now(),
4200
4292
  since,
4201
4293
  resumeIntents = [],
4294
+ continuationAuthorized = false,
4202
4295
  } = {}) {
4203
4296
  return recordShutdown({
4204
4297
  botName,
@@ -4206,6 +4299,7 @@ function wrap(db) {
4206
4299
  since,
4207
4300
  clean: true,
4208
4301
  resumeIntents,
4302
+ continuationAuthorized,
4209
4303
  });
4210
4304
  },
4211
4305
 
@@ -4264,13 +4358,18 @@ function wrap(db) {
4264
4358
  runtime.generation_id AS current_generation_id,
4265
4359
  runtime.provider_session_id,
4266
4360
  runtime.namespace AS provider_namespace,
4267
- runtime.pm_backend
4361
+ runtime.pm_backend,
4362
+ runtime.spawn_profile_id AS current_spawn_profile_id
4268
4363
  FROM clean_restart_resume_intents AS intent
4269
4364
  LEFT JOIN messages AS source
4270
4365
  ON source.id = intent.source_message_id
4271
4366
  LEFT JOIN agent_runtime_sessions AS runtime
4272
4367
  ON runtime.session_key = intent.session_key
4273
- AND runtime.namespace = '${CLAUDE_CHANNELS_NAMESPACE}'
4368
+ AND runtime.namespace = CASE
4369
+ WHEN intent.interrupted_provider_turn_id IS NULL
4370
+ THEN '${CLAUDE_CHANNELS_NAMESPACE}'
4371
+ ELSE '${CODEX_APP_SERVER_NAMESPACE}'
4372
+ END
4274
4373
  WHERE intent.bot_name = ?
4275
4374
  AND intent.shutdown_at = ?
4276
4375
  ORDER BY intent.session_key
@@ -4295,8 +4394,28 @@ function wrap(db) {
4295
4394
  }
4296
4395
 
4297
4396
  let reason = null;
4397
+ const hasInterruptedTurn =
4398
+ typeof row.interrupted_provider_turn_id === 'string'
4399
+ && row.interrupted_provider_turn_id.length > 0;
4400
+ const hasInterruptedProfile =
4401
+ typeof row.interrupted_spawn_profile_id === 'string'
4402
+ && row.interrupted_spawn_profile_id.length > 0;
4403
+ const codexShaped = row.interrupted_provider_turn_id != null
4404
+ || row.interrupted_spawn_profile_id != null
4405
+ || row.provider_namespace === CODEX_APP_SERVER_NAMESPACE;
4298
4406
  if (!sourceMatches) {
4299
4407
  reason = 'source-message-missing';
4408
+ } else if (row.continuation_authorized !== 1) {
4409
+ reason = 'unauthorized-restart';
4410
+ } else if (
4411
+ codexShaped
4412
+ && (
4413
+ row.policy_version !== 2
4414
+ || !hasInterruptedTurn
4415
+ || !hasInterruptedProfile
4416
+ )
4417
+ ) {
4418
+ reason = 'unsupported-codex-policy';
4300
4419
  } else if (!supported.has(row.policy_version)) {
4301
4420
  reason = 'unsupported-policy-version';
4302
4421
  } else if (
@@ -4305,6 +4424,12 @@ function wrap(db) {
4305
4424
  || row.current_generation_id !== row.session_generation_id
4306
4425
  ) {
4307
4426
  reason = 'session-generation-replaced';
4427
+ } else if (
4428
+ row.interrupted_spawn_profile_id != null
4429
+ && row.current_spawn_profile_id
4430
+ !== row.interrupted_spawn_profile_id
4431
+ ) {
4432
+ reason = 'spawn-profile-replaced';
4308
4433
  }
4309
4434
 
4310
4435
  return {
@@ -4317,6 +4442,13 @@ function wrap(db) {
4317
4442
  provider_namespace: reason == null ? row.provider_namespace : null,
4318
4443
  provider_session_id: reason == null ? row.provider_session_id : null,
4319
4444
  pm_backend: reason == null ? row.pm_backend : null,
4445
+ interrupted_provider_turn_id:
4446
+ row.interrupted_provider_turn_id,
4447
+ interrupted_spawn_profile_id:
4448
+ row.interrupted_spawn_profile_id,
4449
+ current_spawn_profile_id: reason == null
4450
+ ? row.current_spawn_profile_id
4451
+ : null,
4320
4452
  executable: reason == null,
4321
4453
  reason,
4322
4454
  };
@@ -16,8 +16,14 @@ const { buildBusySnapshot } = require('../queue-utils');
16
16
  * captured: the dispatcher's map is assigned during boot and replaced on
17
17
  * reload, so a value bound at wiring time can go stale.
18
18
  * @param {(req) => any} opts.handleSendOverIpc
19
+ * @param {(req) => any} opts.requestDeployRestart
19
20
  */
20
- function createIpcHandlers({ botName, getInFlightHandlers, handleSendOverIpc } = {}) {
21
+ function createIpcHandlers({
22
+ botName,
23
+ getInFlightHandlers,
24
+ handleSendOverIpc,
25
+ requestDeployRestart,
26
+ } = {}) {
21
27
  return {
22
28
  ping: async () => ({ pong: true, bot: botName }),
23
29
 
@@ -28,6 +34,8 @@ function createIpcHandlers({ botName, getInFlightHandlers, handleSendOverIpc } =
28
34
  botName,
29
35
  }),
30
36
 
37
+ deploy_restart: (req) => requestDeployRestart(req),
38
+
31
39
  send: (req) => handleSendOverIpc(req),
32
40
  };
33
41
  }
@@ -1,7 +1,9 @@
1
1
  'use strict';
2
2
 
3
- const EXPECTED_NAMESPACE = 'claude:channels';
4
- const CLEAN_RESUME_POLICY_VERSION = 1;
3
+ const CLAUDE_NAMESPACE = 'claude:channels';
4
+ const CODEX_NAMESPACE = 'codex:app-server';
5
+ const CLEAN_RESUME_POLICY_VERSION = 2;
6
+ const SUPPORTED_CLEAN_RESUME_POLICY_VERSIONS = Object.freeze([1, 2]);
5
7
 
6
8
  function recoveryNoticeText({ ambiguous = false } = {}) {
7
9
  if (ambiguous) {
@@ -10,11 +12,23 @@ function recoveryNoticeText({ ambiguous = false } = {}) {
10
12
  return '↺ Restarted — I couldn\'t safely resume this message. If it still needs a reply, send it again.';
11
13
  }
12
14
 
13
- function attestsExactResume(attestation, expectedSessionId) {
14
- return attestation?.namespace === EXPECTED_NAMESPACE
15
+ function isCodexClaim(claim) {
16
+ return claim?.interrupted_provider_turn_id != null
17
+ || claim?.interrupted_spawn_profile_id != null
18
+ || claim?.provider_namespace === CODEX_NAMESPACE;
19
+ }
20
+
21
+ function attestsExactResume(attestation, expectedSessionId, claim = null) {
22
+ const codex = isCodexClaim(claim);
23
+ return attestation?.namespace === (codex ? CODEX_NAMESPACE : CLAUDE_NAMESPACE)
15
24
  && attestation.sessionId === expectedSessionId
16
25
  && attestation.resumed === true
17
- && attestation.freshFallback === false;
26
+ && attestation.freshFallback === false
27
+ && (!codex || (
28
+ attestation.interruptedTurnId
29
+ === claim.interrupted_provider_turn_id
30
+ && attestation.idle === true
31
+ ));
18
32
  }
19
33
 
20
34
  function validateStrictResumeSpawn({
@@ -24,17 +38,45 @@ function validateStrictResumeSpawn({
24
38
  runtime,
25
39
  resolved,
26
40
  } = {}) {
27
- if (promptBackend === 'codex') {
28
- const error = new Error('strict Claude resume cannot cross a backend change');
29
- error.code = 'CLEAN_RESUME_CONFIG_DRIFT';
30
- throw error;
31
- }
32
41
  if (liveProcess && !liveProcess.closed) {
33
42
  const error = new Error('strict clean resume requires an unspawned session');
34
43
  error.code = 'CLEAN_RESUME_SESSION_ALREADY_SPAWNED';
35
44
  throw error;
36
45
  }
37
46
 
47
+ if (promptBackend === 'codex') {
48
+ const providerDrift = runtime?.namespace !== CODEX_NAMESPACE
49
+ || resolved?.runtime !== 'codex'
50
+ || resolved?.backend !== 'codex';
51
+ const driftFields = [
52
+ ['cwd', 'cwd'],
53
+ ['model', 'model'],
54
+ ['effort', 'effort'],
55
+ ['spawn_profile_id', 'spawnProfileId'],
56
+ ].filter(([stored, current]) => (
57
+ (runtime?.[stored] || null) !== (resolved?.[current] || null)
58
+ ));
59
+ if (
60
+ !runtime
61
+ || runtime.namespace !== CODEX_NAMESPACE
62
+ || runtime.generation_id !== strictResume?.expectedGenerationId
63
+ || runtime.provider_session_id !== strictResume?.expectedSessionId
64
+ || runtime.spawn_profile_id !== strictResume?.expectedSpawnProfileId
65
+ || resolved?.runtime !== 'codex'
66
+ || resolved?.backend !== 'codex'
67
+ || driftFields.length > 0
68
+ ) {
69
+ const error = new Error(
70
+ 'strict Codex resume session identity or config changed',
71
+ );
72
+ error.code = providerDrift || driftFields.length > 0
73
+ ? 'CLEAN_RESUME_CONFIG_DRIFT'
74
+ : 'CLEAN_RESUME_SESSION_CHANGED';
75
+ throw error;
76
+ }
77
+ return strictResume.expectedSessionId;
78
+ }
79
+
38
80
  const driftFields = ['agent', 'cwd'].filter(
39
81
  (field) => (runtime?.[field] || null) !== (resolved?.[field] || null),
40
82
  );
@@ -86,6 +128,7 @@ async function executeCleanResumeClaim(claim, {
86
128
  discardSpawn,
87
129
  sendContinue,
88
130
  deliverResult,
131
+ settleProviderDelivery,
89
132
  sendNotice,
90
133
  complete,
91
134
  logEvent = () => {},
@@ -103,6 +146,7 @@ async function executeCleanResumeClaim(claim, {
103
146
  source_message_id: claim?.source_message_id ?? null,
104
147
  policy_version: claim?.policy_version ?? null,
105
148
  };
149
+ const codex = isCodexClaim(claim);
106
150
  const source = typeof loadSource === 'function'
107
151
  ? loadSource(claim.source_message_id)
108
152
  : null;
@@ -150,6 +194,20 @@ async function executeCleanResumeClaim(claim, {
150
194
  if (claim?.executable !== true) {
151
195
  return fallback(claim?.reason || 'claim-not-executable');
152
196
  }
197
+ const hasInterruptedTurn = typeof claim?.interrupted_provider_turn_id === 'string'
198
+ && claim.interrupted_provider_turn_id.length > 0;
199
+ const hasInterruptedProfile = typeof claim?.interrupted_spawn_profile_id === 'string'
200
+ && claim.interrupted_spawn_profile_id.length > 0;
201
+ if (
202
+ codex
203
+ && (
204
+ claim?.policy_version !== 2
205
+ || !hasInterruptedTurn
206
+ || !hasInterruptedProfile
207
+ )
208
+ ) {
209
+ return rejected('unsupported-codex-policy');
210
+ }
153
211
 
154
212
  if (
155
213
  !source
@@ -161,14 +219,22 @@ async function executeCleanResumeClaim(claim, {
161
219
  return fallback('source-message-mismatch');
162
220
  }
163
221
 
164
- const runtime = loadRuntimeSession(claim.session_key);
222
+ const runtime = loadRuntimeSession(claim.session_key, claim);
223
+ const expectedNamespace = codex ? CODEX_NAMESPACE : CLAUDE_NAMESPACE;
165
224
  if (
166
225
  !runtime
167
- || runtime.namespace !== EXPECTED_NAMESPACE
226
+ || runtime.namespace !== expectedNamespace
168
227
  || runtime.generation_id !== claim.session_generation_id
169
228
  || typeof runtime.provider_session_id !== 'string'
170
229
  || runtime.provider_session_id.length === 0
171
- || !['cli', 'channels'].includes(runtime.pm_backend)
230
+ || (codex
231
+ ? runtime.pm_backend !== 'codex'
232
+ : !['cli', 'channels'].includes(runtime.pm_backend))
233
+ || (codex && (
234
+ runtime.spawn_profile_id !== claim.interrupted_spawn_profile_id
235
+ || typeof claim.interrupted_provider_turn_id !== 'string'
236
+ || claim.interrupted_provider_turn_id.length === 0
237
+ ))
172
238
  ) {
173
239
  return rejected(
174
240
  runtime?.generation_id !== claim.session_generation_id
@@ -202,7 +268,11 @@ async function executeCleanResumeClaim(claim, {
202
268
  }
203
269
  if (
204
270
  !spawned?.process
205
- || !attestsExactResume(spawned.attestation, runtime.provider_session_id)
271
+ || !attestsExactResume(
272
+ spawned.attestation,
273
+ runtime.provider_session_id,
274
+ claim,
275
+ )
206
276
  ) {
207
277
  if (spawned?.process) {
208
278
  if (typeof discardSpawn !== 'function') {
@@ -223,7 +293,7 @@ async function executeCleanResumeClaim(claim, {
223
293
  result = await sendContinue({
224
294
  sessionKey: claim.session_key,
225
295
  text: 'continue',
226
- sourceMsgId: source.msg_id,
296
+ sourceMsgId: codex ? null : source.msg_id,
227
297
  expectedProcess: spawned.process,
228
298
  onDispatched: markReady,
229
299
  });
@@ -237,40 +307,60 @@ async function executeCleanResumeClaim(claim, {
237
307
  );
238
308
  }
239
309
 
310
+ const settleCodexDelivery = async (disposition) => {
311
+ if (!codex) return;
312
+ if (typeof settleProviderDelivery !== 'function') {
313
+ throw new TypeError('Codex clean resume requires delivery settlement');
314
+ }
315
+ await settleProviderDelivery({ claim, result, disposition });
316
+ };
317
+
240
318
  if (result?.deliveryAmbiguous === true) {
319
+ await settleCodexDelivery('failed');
241
320
  return failed('continuation-delivery-ambiguous', { ambiguous: true });
242
321
  }
243
322
  if (result?.error) {
323
+ await settleCodexDelivery('failed');
244
324
  return failed('continuation-failed');
245
325
  }
326
+ if (codex && result?.alreadyDelivered) {
327
+ await settleCodexDelivery('failed');
328
+ return failed('continuation-delivery-incompatible');
329
+ }
246
330
  if (!result?.alreadyDelivered) {
247
331
  if (typeof result?.text !== 'string' || result.text.trim().length === 0) {
332
+ await settleCodexDelivery('failed');
248
333
  return failed('continuation-empty');
249
334
  }
335
+ let delivered;
250
336
  try {
251
- const delivered = await deliverResult({
337
+ delivered = await deliverResult({
252
338
  claim,
253
339
  source,
254
340
  text: result.text,
255
341
  result,
256
342
  });
257
- if (delivered?.ok !== true) {
258
- return failed(
259
- delivered?.ambiguous
260
- ? 'continuation-delivery-ambiguous'
261
- : 'continuation-delivery-failed',
262
- { ambiguous: delivered?.ambiguous === true },
263
- );
264
- }
265
343
  } catch (error) {
344
+ await settleCodexDelivery('failed');
266
345
  const ambiguous = error?.deliveryAmbiguous === true;
267
346
  return failed(
268
347
  ambiguous ? 'continuation-delivery-ambiguous' : 'continuation-delivery-failed',
269
348
  { ambiguous },
270
349
  );
271
350
  }
351
+ if (delivered?.ok !== true) {
352
+ await settleCodexDelivery('failed');
353
+ return failed(
354
+ delivered?.ambiguous
355
+ ? 'continuation-delivery-ambiguous'
356
+ : 'continuation-delivery-failed',
357
+ { ambiguous: delivered?.ambiguous === true },
358
+ );
359
+ }
272
360
  }
273
361
 
362
+ await settleCodexDelivery('delivered');
363
+
274
364
  await complete({
275
365
  sourceMessageId: claim.source_message_id,
276
366
  status: 'replied',
@@ -284,7 +374,7 @@ async function startCleanRestartRecovery({
284
374
  botName,
285
375
  maxAgeMs,
286
376
  olderThanMs,
287
- supportedPolicyVersions = [CLEAN_RESUME_POLICY_VERSION],
377
+ supportedPolicyVersions = SUPPORTED_CLEAN_RESUME_POLICY_VERSIONS,
288
378
  sessionKeyForSource,
289
379
  executeClaim,
290
380
  trackTask = (task) => task,
@@ -349,6 +439,7 @@ async function startCleanRestartRecovery({
349
439
 
350
440
  module.exports = {
351
441
  CLEAN_RESUME_POLICY_VERSION,
442
+ SUPPORTED_CLEAN_RESUME_POLICY_VERSIONS,
352
443
  executeCleanResumeClaim,
353
444
  recoveryNoticeText,
354
445
  attestsExactResume,
@@ -49,6 +49,34 @@ function buildResumeIntents({
49
49
  reason: snapshot?.reason ?? null,
50
50
  };
51
51
  if (!result.eligible) return result;
52
+ const codex = result.runtime === 'codex'
53
+ || result.namespace === 'codex:app-server';
54
+ if (
55
+ codex
56
+ && (
57
+ result.runtime !== 'codex'
58
+ || result.namespace !== 'codex:app-server'
59
+ || typeof result.providerSessionId !== 'string'
60
+ || result.providerSessionId.length === 0
61
+ || typeof result.providerTurnId !== 'string'
62
+ || result.providerTurnId.length === 0
63
+ || typeof result.cwd !== 'string'
64
+ || result.cwd.length === 0
65
+ || typeof result.model !== 'string'
66
+ || result.model.length === 0
67
+ || typeof result.effort !== 'string'
68
+ || result.effort.length === 0
69
+ || typeof result.spawnProfileId !== 'string'
70
+ || result.spawnProfileId.length === 0
71
+ || policyVersion !== 2
72
+ )
73
+ ) {
74
+ return {
75
+ ...result,
76
+ eligible: false,
77
+ reason: 'codex-retirement-binding-missing',
78
+ };
79
+ }
52
80
  if (seenSessions.has(result.sessionKey)) {
53
81
  throw new Error(`duplicate retirement snapshot for ${result.sessionKey}`);
54
82
  }
@@ -69,6 +97,14 @@ function buildResumeIntents({
69
97
  sessionKey: result.sessionKey,
70
98
  sourceMessageId,
71
99
  policyVersion,
100
+ ...(codex ? {
101
+ interruptedProviderTurnId: result.providerTurnId,
102
+ interruptedSpawnProfileId: result.spawnProfileId,
103
+ expectedProviderSessionId: result.providerSessionId,
104
+ expectedCwd: result.cwd,
105
+ expectedModel: result.model,
106
+ expectedEffort: result.effort,
107
+ } : {}),
72
108
  });
73
109
  return {
74
110
  ...result,
@@ -21,6 +21,7 @@ function persistShutdownDisposition({
21
21
  since,
22
22
  observation,
23
23
  resumeIntents = [],
24
+ continuationAuthorized = false,
24
25
  forceCrashReason = null,
25
26
  } = {}) {
26
27
  const oomDetected = observation?.detected === true;
@@ -32,6 +33,7 @@ function persistShutdownDisposition({
32
33
  now,
33
34
  since,
34
35
  resumeIntents,
36
+ continuationAuthorized,
35
37
  });
36
38
  return {
37
39
  clean: !crashLike,
@@ -108,6 +108,7 @@ function createSdkCallbacks({
108
108
  model: entry.model,
109
109
  effort: entry.effort,
110
110
  pm_backend: 'codex',
111
+ spawn_profile_id: entry.spawnProfileId,
111
112
  }), `upsert Codex session ${sessionKey}`);
112
113
  return;
113
114
  }
@@ -0,0 +1,13 @@
1
+ ALTER TABLE agent_runtime_sessions ADD COLUMN spawn_profile_id TEXT;
2
+
3
+ ALTER TABLE clean_restart_resume_intents
4
+ ADD COLUMN interrupted_provider_turn_id TEXT;
5
+
6
+ ALTER TABLE clean_restart_resume_intents
7
+ ADD COLUMN interrupted_spawn_profile_id TEXT;
8
+
9
+ -- Rows written by a pre-v19 daemon during the one-time upgrade restart use
10
+ -- the default and therefore cannot authorize provider continuation.
11
+ ALTER TABLE clean_restart_resume_intents
12
+ ADD COLUMN continuation_authorized INTEGER NOT NULL DEFAULT 0
13
+ CHECK (continuation_authorized IN (0, 1));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.36.1",
3
+ "version": "0.37.0",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc/client.js",
6
6
  "bin": {
@@ -63,7 +63,7 @@
63
63
  "dependencies": {
64
64
  "@anthropic-ai/claude-agent-sdk": "0.2.123",
65
65
  "@modelcontextprotocol/sdk": "^1.29.0",
66
- "@shumkov/orchestra": "0.10.11",
66
+ "@shumkov/orchestra": "0.10.13",
67
67
  "better-sqlite3": "^12.9.0",
68
68
  "grammy": "^1.42.0",
69
69
  "markdown-it": "^14.1.1",
package/polygram.js CHANGED
@@ -191,6 +191,7 @@ const {
191
191
  } = require('./lib/ops/clean-shutdown');
192
192
  const {
193
193
  CLEAN_RESUME_POLICY_VERSION,
194
+ SUPPORTED_CLEAN_RESUME_POLICY_VERSIONS,
194
195
  createCleanResumeTurnContext,
195
196
  executeCleanResumeClaim,
196
197
  startCleanRestartRecovery,
@@ -800,12 +801,6 @@ async function buildSpawnContext(
800
801
  threadId: threadId || null,
801
802
  });
802
803
 
803
- if (strictResume && promptBackend === 'codex') {
804
- validateStrictResumeSpawn({
805
- strictResume,
806
- promptBackend,
807
- });
808
- }
809
804
  if (promptBackend === 'codex') {
810
805
  if (!codexRuntimeController) {
811
806
  const error = new Error('Codex runtime controller is not initialized');
@@ -823,6 +818,7 @@ async function buildSpawnContext(
823
818
  getSessionLabel,
824
819
  logEvent,
825
820
  mutateSessionOnDrift,
821
+ strictResume,
826
822
  });
827
823
  }
828
824
 
@@ -3443,6 +3439,7 @@ let startPollWatchdog = null;
3443
3439
  async function main() {
3444
3440
  const cgroupOomObserver = createCgroupOomObserver();
3445
3441
  const invocationId = parseSystemdInvocationId();
3442
+ let abnormalProviderTermination = null;
3446
3443
  let codexManagerOptions = Object.freeze({
3447
3444
  codexHostIdentity: null,
3448
3445
  codexBootSessionIdentity: null,
@@ -3572,6 +3569,7 @@ async function main() {
3572
3569
  codexRuntimeController = createCodexRuntimeController({
3573
3570
  config,
3574
3571
  db,
3572
+ sessionLauncher,
3575
3573
  defaultDaemonSecretRoots: [
3576
3574
  DATA_DIR,
3577
3575
  ipcRuntimeDir,
@@ -3887,6 +3885,18 @@ async function main() {
3887
3885
  renderQuestion: (payload) => questionHandlers?.renderAsk(payload),
3888
3886
  logger: console,
3889
3887
  });
3888
+ sdkCallbacks.onAbnormalTermination = (_sessionKey, evidence) => {
3889
+ if (abnormalProviderTermination !== null) return;
3890
+ abnormalProviderTermination = Object.freeze({
3891
+ backend: evidence?.backend ?? null,
3892
+ event: evidence?.event ?? null,
3893
+ exitCode: evidence?.exitCode ?? null,
3894
+ });
3895
+ logEvent('abnormal-provider-termination', {
3896
+ bot: BOT_NAME,
3897
+ ...abnormalProviderTermination,
3898
+ });
3899
+ };
3890
3900
  // 0.10.0: sdkCallbacks (the polygram-side lifecycle handlers — status
3891
3901
  // reactor, stream chunk → bubble edit, etc.) move from the underlying
3892
3902
  // SDK pm to the generic ProcessManager. The SDK pm gets legacyCallbacks
@@ -4409,7 +4419,7 @@ async function main() {
4409
4419
  // replay picks it up. Prevents "Sorry, I couldn't process that message"
4410
4420
  // from showing on every restart.
4411
4421
  const SHUTDOWN_DRAIN_MS = 30_000;
4412
- const shutdown = async () => {
4422
+ const shutdown = async ({ continuationAuthorized = false, trigger = 'signal' } = {}) => {
4413
4423
  if (isShuttingDown) return;
4414
4424
  const shutdownObservation = cgroupOomObserver.sample();
4415
4425
  // Sample in-flight work at SIGNAL time, alongside the OOM observation and
@@ -4419,7 +4429,7 @@ async function main() {
4419
4429
  // idle, and cannot answer "how much work did this restart cost?".
4420
4430
  const inFlightAtSignal = countInFlight(inFlightHandlers);
4421
4431
  isShuttingDown = true;
4422
- console.log('\nShutting down...');
4432
+ console.log(`\nShutting down (${trigger})...`);
4423
4433
  // 1. Stop accepting new inbound first so nothing new queues behind the drain.
4424
4434
  if (bot && bot._stop) bot._stop();
4425
4435
 
@@ -4508,7 +4518,9 @@ async function main() {
4508
4518
  // which CLI turn was still active; replay rows alone cannot establish
4509
4519
  // that fact.
4510
4520
  let pmRetired = false;
4511
- let forceCrashReason = null;
4521
+ let forceCrashReason = abnormalProviderTermination
4522
+ ? 'provider-process-terminated'
4523
+ : null;
4512
4524
  let resumeIntents = [];
4513
4525
  if (shutdownObservation?.detected !== true) {
4514
4526
  try {
@@ -4519,7 +4531,7 @@ async function main() {
4519
4531
  settlementTimeoutMs: SHUTDOWN_DRAIN_MS,
4520
4532
  });
4521
4533
  pmRetired = true;
4522
- const projected = buildResumeIntents({
4534
+ const projected = continuationAuthorized ? buildResumeIntents({
4523
4535
  snapshots: retirement.snapshots,
4524
4536
  policyVersion: CLEAN_RESUME_POLICY_VERSION,
4525
4537
  resolveSourceMessageId: ({ sessionKey, sourceMsgId }) => {
@@ -4542,10 +4554,12 @@ async function main() {
4542
4554
  ? messageId
4543
4555
  : null;
4544
4556
  },
4545
- });
4546
- if (config.bot?.resumeInterruptedCliTurns === true) {
4547
- resumeIntents = projected.resumeIntents;
4548
- }
4557
+ }) : { resumeIntents: [], snapshots: retirement.snapshots };
4558
+ resumeIntents = projected.resumeIntents.filter((intent) => (
4559
+ intent.interruptedProviderTurnId == null
4560
+ ? config.bot?.resumeInterruptedCliTurns === true
4561
+ : config.bot?.resumeInterruptedCodexTurns === true
4562
+ ));
4549
4563
  for (const snapshot of projected.snapshots) {
4550
4564
  logEvent('clean-retirement-snapshot', {
4551
4565
  bot: BOT_NAME,
@@ -4576,6 +4590,7 @@ async function main() {
4576
4590
  botName: BOT_NAME,
4577
4591
  observation: shutdownObservation,
4578
4592
  resumeIntents,
4593
+ continuationAuthorized,
4579
4594
  forceCrashReason,
4580
4595
  });
4581
4596
  if (res.clean) {
@@ -4642,8 +4657,8 @@ async function main() {
4642
4657
  if (PID_PATH) processGuard.releasePidFile(PID_PATH);
4643
4658
  setTimeout(() => process.exit(0), 100);
4644
4659
  };
4645
- process.on('SIGINT', shutdown);
4646
- process.on('SIGTERM', shutdown);
4660
+ process.on('SIGINT', () => shutdown());
4661
+ process.on('SIGTERM', () => shutdown());
4647
4662
  // rc.28: also catch SIGHUP. The shumabit deploy path runs polygram
4648
4663
  // inside `tmux new-session`; on `launchctl kickstart -k` the
4649
4664
  // shumabit-start launcher does `tmux kill-session -t polygram`
@@ -4655,7 +4670,7 @@ async function main() {
4655
4670
  // been mid-tool-use longer than the replayWindowMs gets silently
4656
4671
  // dropped. Surface symptom: bot stops responding after deploy on
4657
4672
  // long agent runs (the §7.4 incident).
4658
- process.on('SIGHUP', shutdown);
4673
+ process.on('SIGHUP', () => shutdown());
4659
4674
 
4660
4675
  try {
4661
4676
  // Fresh per-boot secret, persisted 0600 for same-UID readers (cron
@@ -4669,6 +4684,21 @@ async function main() {
4669
4684
  botName: BOT_NAME,
4670
4685
  getInFlightHandlers: () => inFlightHandlers,
4671
4686
  handleSendOverIpc: (req) => handleSendOverIpc(req),
4687
+ requestDeployRestart: () => {
4688
+ if (isShuttingDown) {
4689
+ const error = new Error('shutdown already in progress');
4690
+ error.code = 'SHUTDOWN_IN_PROGRESS';
4691
+ throw error;
4692
+ }
4693
+ const oldPid = process.pid;
4694
+ shutdown({
4695
+ continuationAuthorized: true,
4696
+ trigger: 'deploy-ipc',
4697
+ }).catch((error) => {
4698
+ console.error(`[shutdown] deploy restart failed: ${error.message}`);
4699
+ });
4700
+ return { accepted: true, old_pid: oldPid };
4701
+ },
4672
4702
  }),
4673
4703
  logger: console,
4674
4704
  });
@@ -4721,7 +4751,7 @@ async function main() {
4721
4751
  botName: BOT_NAME,
4722
4752
  maxAgeMs: 2 * (replayWindowMs || 3 * 60 * 1000),
4723
4753
  olderThanMs: replayWindowMs || 30 * 60 * 1000,
4724
- supportedPolicyVersions: [CLEAN_RESUME_POLICY_VERSION],
4754
+ supportedPolicyVersions: SUPPORTED_CLEAN_RESUME_POLICY_VERSIONS,
4725
4755
  sessionKeyForSource: sourceSessionKey,
4726
4756
  trackTask: trackHandlerTask,
4727
4757
  onClaim: (kind, claim) => {
@@ -4739,11 +4769,18 @@ async function main() {
4739
4769
  );
4740
4770
  },
4741
4771
  executeClaim: (claim, { onReady }) => executeCleanResumeClaim(claim, {
4742
- enabled: config.bot?.resumeInterruptedCliTurns === true,
4772
+ enabled: claim.interrupted_provider_turn_id == null
4773
+ ? config.bot?.resumeInterruptedCliTurns === true
4774
+ : config.bot?.resumeInterruptedCodexTurns === true,
4743
4775
  loadSource: (messageId) => db.getInboundMessageById(messageId),
4744
4776
  sessionKeyForSource: sourceSessionKey,
4745
- loadRuntimeSession: (sessionKey) => (
4746
- db.getProviderSession(sessionKey, 'claude:channels')
4777
+ loadRuntimeSession: (sessionKey, activeClaim) => (
4778
+ db.getProviderSession(
4779
+ sessionKey,
4780
+ activeClaim.interrupted_provider_turn_id == null
4781
+ ? 'claude:channels'
4782
+ : 'codex:app-server',
4783
+ )
4747
4784
  ),
4748
4785
  resolveStrictSpawnContext: async ({ claim: activeClaim, runtime }) => {
4749
4786
  try {
@@ -4753,6 +4790,14 @@ async function main() {
4753
4790
  strictResume: {
4754
4791
  expectedGenerationId: activeClaim.session_generation_id,
4755
4792
  expectedSessionId: runtime.provider_session_id,
4793
+ ...(activeClaim.interrupted_provider_turn_id == null
4794
+ ? {}
4795
+ : {
4796
+ expectedInterruptedTurnId:
4797
+ activeClaim.interrupted_provider_turn_id,
4798
+ expectedSpawnProfileId:
4799
+ activeClaim.interrupted_spawn_profile_id,
4800
+ }),
4756
4801
  },
4757
4802
  }),
4758
4803
  };
@@ -4818,6 +4863,15 @@ async function main() {
4818
4863
  ambiguous: failures > 0 || delivered.deliveryAmbiguous,
4819
4864
  };
4820
4865
  },
4866
+ settleProviderDelivery: ({
4867
+ claim: activeClaim,
4868
+ result,
4869
+ disposition,
4870
+ }) => codexRuntimeController.settleTelegramDelivery(
4871
+ activeClaim.session_key,
4872
+ result,
4873
+ { disposition },
4874
+ ),
4821
4875
  sendNotice: async ({ source, text, ambiguous }) => {
4822
4876
  if (!source) throw new Error('clean resume source message is missing');
4823
4877
  await tg(bot, 'sendMessage', {
@@ -5159,6 +5213,9 @@ async function main() {
5159
5213
  console.error(`[compact-orphan-handler] failed: ${err.message}`);
5160
5214
  }
5161
5215
 
5216
+ logEvent('polygram-admission-open', lifecycleDetail({
5217
+ bot: BOT_NAME,
5218
+ }, invocationId));
5162
5219
  console.log(`[${BOT_NAME}] Starting...`);
5163
5220
  const pollPromise = pollBot(bot).catch(err => {
5164
5221
  console.error(`[${BOT_NAME}] Fatal:`, err.message);
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Quick IPC round-trip probe.
4
- * Usage: node scripts/ipc-smoke.js <bot-name> [busy]
4
+ * Usage: node scripts/ipc-smoke.js <bot-name> [busy|restart]
5
5
  */
6
6
 
7
7
  const { call, readSecret, socketPathFor } = require('../lib/ipc/client');
@@ -32,6 +32,29 @@ const { call, readSecret, socketPathFor } = require('../lib/ipc/client');
32
32
  return;
33
33
  }
34
34
 
35
+ if (command === 'restart') {
36
+ const result = await call({
37
+ path,
38
+ op: 'deploy_restart',
39
+ secret: readSecret(bot),
40
+ callTimeoutMs: 60_000,
41
+ });
42
+ if (
43
+ result.ok !== true
44
+ || result.accepted !== true
45
+ || !Number.isSafeInteger(result.old_pid)
46
+ || result.old_pid <= 1
47
+ ) {
48
+ throw new Error(result.error || 'invalid deploy restart response');
49
+ }
50
+ console.log(JSON.stringify({
51
+ bot,
52
+ accepted: true,
53
+ old_pid: result.old_pid,
54
+ }));
55
+ return;
56
+ }
57
+
35
58
  console.log('path:', path);
36
59
  console.log('ping:', JSON.stringify(await call({ path, op: 'ping' })));
37
60
  console.log('DONE');