polygram 0.37.2 → 0.38.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.37.2",
4
+ "version": "0.38.1",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ const ROOT_ERROR_CODES = new Set([
4
+ 'CODEX_PROTOCOL_ERROR',
5
+ 'CODEX_TRANSPORT_ERROR',
6
+ 'CODEX_PROCESS_EXITED',
7
+ 'CODEX_PROCESS_ERROR',
8
+ 'CODEX_RPC_TIMEOUT',
9
+ 'CODEX_SINK_TIMEOUT',
10
+ 'CODEX_PROCESS_CLOSE_TIMEOUT',
11
+ 'CODEX_PROCESS_CLEANUP_UNVERIFIED',
12
+ 'unknown',
13
+ ]);
14
+
15
+ const FAULT_CLASSES = new Set([
16
+ 'stderr-limit',
17
+ 'transport',
18
+ 'protocol',
19
+ 'process-exit',
20
+ 'rpc-timeout',
21
+ 'sink',
22
+ 'cleanup',
23
+ 'unknown',
24
+ ]);
25
+
26
+ const NOTIFICATION_METHODS = new Set([
27
+ 'error',
28
+ 'thread/status/changed',
29
+ 'thread/settings/updated',
30
+ 'turn/started',
31
+ 'turn/completed',
32
+ 'item/started',
33
+ 'item/completed',
34
+ 'item/agentMessage/delta',
35
+ ]);
36
+
37
+ const PROCESS_STATES = new Set([
38
+ 'Spawning',
39
+ 'Initializing',
40
+ 'AttachingThread',
41
+ 'Idle',
42
+ 'StartingTurn',
43
+ 'Active',
44
+ 'BackgroundWorking',
45
+ 'BackgroundSettling',
46
+ 'Settling',
47
+ 'Quiescing',
48
+ 'Stopped',
49
+ 'Closing',
50
+ 'Closed',
51
+ 'FailedAmbiguous',
52
+ 'RecoveryConflict',
53
+ 'DurabilityBlocked',
54
+ 'ContainmentFailed',
55
+ 'unknown',
56
+ ]);
57
+
58
+ function sanitizeCodexFaultProvenance(input) {
59
+ const safe = {};
60
+ if (ROOT_ERROR_CODES.has(input?.clientRootErrorCode)) {
61
+ safe.clientRootErrorCode = input.clientRootErrorCode;
62
+ }
63
+ if (FAULT_CLASSES.has(input?.clientFaultClass)) {
64
+ safe.clientFaultClass = input.clientFaultClass;
65
+ }
66
+ if (NOTIFICATION_METHODS.has(input?.notificationMethod)) {
67
+ safe.notificationMethod = input.notificationMethod;
68
+ }
69
+ if (PROCESS_STATES.has(input?.observedProcessState)) {
70
+ safe.observedProcessState = input.observedProcessState;
71
+ }
72
+ return safe;
73
+ }
74
+
75
+ module.exports = { sanitizeCodexFaultProvenance };
@@ -6,6 +6,9 @@ const orchestraDefault = require('@shumkov/orchestra');
6
6
  const { resolvePinnedCodexBinary } = require('./binary');
7
7
  const { resolveCodexHostIdentity } = require('./host-identity');
8
8
  const { CodexModelCatalog } = require('./model-catalog');
9
+ const {
10
+ sanitizeCodexFaultProvenance,
11
+ } = require('./fault-provenance');
9
12
  const {
10
13
  createCodexRuntimeProfileBuilder,
11
14
  } = require('./runtime-profile');
@@ -1432,6 +1435,7 @@ function createCodexRuntimeController({
1432
1435
  app_server_session_id: expectedAppServerSessionId,
1433
1436
  reason: deliveryResultString(payload.reason, 'containment reason'),
1434
1437
  source: 'managed-group-empty',
1438
+ ...sanitizeCodexFaultProvenance(payload),
1435
1439
  allow_missing_generation: !record.durable,
1436
1440
  ts: payload.ts ?? Date.now(),
1437
1441
  });
@@ -7,6 +7,9 @@ const {
7
7
  const {
8
8
  CODEX_APP_SERVER_NAMESPACE,
9
9
  } = require('./sessions');
10
+ const {
11
+ sanitizeCodexFaultProvenance,
12
+ } = require('../codex/fault-provenance');
10
13
  const SETTLEMENT_SOURCES = new Set([
11
14
  'managed-group-empty',
12
15
  'exclusive-takeover-grace',
@@ -428,7 +431,11 @@ function settleCodexFailedGeneration(rawDb, input) {
428
431
  `).run(
429
432
  generationId,
430
433
  providerSessionId,
431
- JSON.stringify({ source, reason }),
434
+ JSON.stringify({
435
+ source,
436
+ reason,
437
+ ...sanitizeCodexFaultProvenance(input),
438
+ }),
432
439
  ts,
433
440
  );
434
441
 
@@ -44,6 +44,7 @@ const DEFAULT_POLICY = {
44
44
  // silently lost? Kept so "why didn't my message get answered after the
45
45
  // restart?" is answerable beyond the 90d default (still capped).
46
46
  'replay-on-boot', 'replay-notice-sent', 'replay-notice-failed',
47
+ 'clean-restart-qualification-observed',
47
48
  // 0.15 secret-redaction audit — what was redacted/flagged, kept for review.
48
49
  'secret-sweep', 'secret-sweep-failed',
49
50
  'secret-redacted-by-agent', 'secret-redact-requested-no-match',
package/lib/db.js CHANGED
@@ -23,6 +23,9 @@ const {
23
23
  codexError,
24
24
  requiredString: requiredCodexString,
25
25
  } = require('./db/codex-input');
26
+ const {
27
+ sanitizeCodexFaultProvenance,
28
+ } = require('./codex/fault-provenance');
26
29
 
27
30
  // 0.8.0 Phase 1: bumped from 9 → 10. Adds migration
28
31
  // 010-tool-use-id.sql (pending_approvals.tool_use_id column for the
@@ -894,6 +897,7 @@ function wrap(db) {
894
897
  if (value != null) detail[key] = value;
895
898
  }
896
899
  }
900
+ Object.assign(detail, sanitizeCodexFaultProvenance(input));
897
901
  return Object.keys(detail).length > 0 ? JSON.stringify(detail) : null;
898
902
  }
899
903
 
@@ -18,10 +18,10 @@
18
18
 
19
19
  const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
20
20
  const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
21
- const CODEX_MODEL_OPTIONS = new Set([
22
- 'gpt-5.6-sol',
23
- 'gpt-5.6-terra',
24
- 'gpt-5.6-luna',
21
+ const CODEX_MODEL_OPTIONS = Object.freeze([
22
+ Object.freeze({ model: 'gpt-5.6-sol', label: 'Sol' }),
23
+ Object.freeze({ model: 'gpt-5.6-terra', label: 'Terra' }),
24
+ Object.freeze({ model: 'gpt-5.6-luna', label: 'Luna' }),
25
25
  ]);
26
26
  // Which runtimes the card offers. The sdk backend stays fully supported in
27
27
  // code and config files — it is per-token and nobody picks it from a chat, so
@@ -63,20 +63,30 @@ function getCodexCatalogModels(runtimeView) {
63
63
  if (!isCodexRuntimeView(runtimeView) || !Array.isArray(runtimeView.models)) {
64
64
  return [];
65
65
  }
66
- const seen = new Set();
67
- return runtimeView.models.filter((entry) => {
68
- if (
69
- !entry
70
- || typeof entry.model !== 'string'
71
- || entry.model.length === 0
72
- || !CODEX_MODEL_OPTIONS.has(entry.model)
73
- || seen.has(entry.model)
74
- ) {
75
- return false;
66
+ const catalogByModel = new Map();
67
+ for (const entry of runtimeView.models) {
68
+ if (entry && typeof entry.model === 'string' && entry.model.length > 0) {
69
+ if (!catalogByModel.has(entry.model)) {
70
+ catalogByModel.set(entry.model, entry);
71
+ }
76
72
  }
77
- seen.add(entry.model);
78
- return true;
79
- });
73
+ }
74
+ return CODEX_MODEL_OPTIONS.flatMap(({ model }) => (
75
+ catalogByModel.has(model) ? [catalogByModel.get(model)] : []
76
+ ));
77
+ }
78
+
79
+ function codexModelLabel(model) {
80
+ return CODEX_MODEL_OPTIONS.find((entry) => entry.model === model)?.label
81
+ ?? model;
82
+ }
83
+
84
+ function hasCodexCatalogModels(runtimeView) {
85
+ return isCodexRuntimeView(runtimeView)
86
+ && Array.isArray(runtimeView.models)
87
+ && runtimeView.models.some(
88
+ (entry) => typeof entry?.model === 'string' && entry.model.length > 0,
89
+ );
80
90
  }
81
91
 
82
92
  function getCodexCatalogEfforts(runtimeView, model = runtimeView?.model) {
@@ -262,10 +272,15 @@ function buildConfigKeyboard(
262
272
  }
263
273
  if ((show === 'model' || show === 'all') && modelOptions.length > 0) {
264
274
  rows.push(modelOptions.map((entry) => {
265
- const label = (
275
+ let label = entry.model;
276
+ if (codex) {
277
+ label = codexModelLabel(entry.model);
278
+ } else if (
266
279
  typeof entry.displayName === 'string'
267
280
  && entry.displayName.length > 0
268
- ) ? entry.displayName : entry.model;
281
+ ) {
282
+ label = entry.displayName;
283
+ }
269
284
  return {
270
285
  text: entry.model === model ? `✓ ${label}` : label,
271
286
  callback_data: `cfg:model:${entry.model}`,
@@ -325,33 +340,30 @@ function createFormatConfigInfoText({
325
340
  ? codexModels.length > 0 ? [
326
341
  '',
327
342
  '**Models**',
328
- ...codexModels.map((entry) => {
329
- const displayName = (
330
- typeof entry.displayName === 'string'
331
- && entry.displayName.length > 0
332
- && entry.displayName !== entry.model
333
- ) ? ` — ${entry.displayName}` : '';
334
- return `• **${entry.model}**${displayName}`;
335
- }),
343
+ ...codexModels.map(
344
+ (entry) => `• **${codexModelLabel(entry.model)}** — ${entry.model}`,
345
+ ),
336
346
  ].join('\n') : [
337
347
  '',
338
348
  '**Models**',
339
- 'Models are unavailable until Codex preflight succeeds.',
349
+ hasCodexCatalogModels(runtimeView)
350
+ ? 'Sol, Terra, and Luna are unavailable for this account.'
351
+ : 'Models are unavailable until Codex preflight succeeds.',
340
352
  ].join('\n')
341
353
  : [
342
354
  '',
343
355
  '**Models**',
344
- '🧠 **opus** — deep analysis, code refactor, multi-source reconciliation. ~1.7× sonnet cost.',
345
- '🤖 **sonnet** — default. Most ops, code review, document summary.',
346
- '⚡ **haiku** — quick simple tasks, classification, lookup.',
356
+ '🧠 **opus** — deepest reasoning',
357
+ '🤖 **sonnet** — balanced default',
358
+ '⚡ **haiku** — fastest',
347
359
  ].join('\n');
348
360
 
349
361
  const effortHelp = codex
350
362
  ? codexEfforts.length > 0 ? [
351
363
  '',
352
- `**Effort** — authenticated options for ${codexModel}:`,
353
- ...codexEfforts.map((value) => `• **${value}**`),
354
- 'Model and effort changes apply to the next turn; an active turn is unchanged.',
364
+ `**Effort for ${codexModelLabel(codexModel)}**`,
365
+ codexEfforts.map((value) => `**${value}**`).join(' · '),
366
+ 'Changes apply on the next turn; an active turn is unchanged.',
355
367
  ].join('\n') : [
356
368
  '',
357
369
  '**Effort**',
@@ -359,11 +371,9 @@ function createFormatConfigInfoText({
359
371
  ].join('\n')
360
372
  : [
361
373
  '',
362
- '**Effort** — ceiling on how much Claude can think. Simple questions get fast replies; hard ones spend more tokens. Safe to set higher — Claude scales down automatically when it doesn\'t need to think.',
363
- '• **low** — fast replies, minimum reasoning. Casual chat, simple lookups.',
364
- '• **medium** — balanced default. Fits most use cases.',
365
- '• **high** — multi-step tasks. Audit, debug, multi-source analysis.',
366
- '• **xhigh** / **max** — heaviest. Hard reasoning, edge cases.',
374
+ '**Effort**',
375
+ '**low** · **medium** · **high** · **xhigh** · **max**',
376
+ 'Higher values allow more reasoning when needed.',
367
377
  ].join('\n');
368
378
 
369
379
  // The card is its buttons. Everything the body used to state — model,
@@ -373,11 +383,10 @@ function createFormatConfigInfoText({
373
383
  // button to live on.
374
384
  let body = '⚙️ Settings';
375
385
  if (codex) {
376
- body += '\n\nNative Codex beta: command network and web search are disabled. '
377
- + 'Product MCP tools and interactive approvals are unavailable. '
378
- + 'Detached/background servers are unsupported and may survive hard runtime loss. '
379
- + 'Native goals are disabled for Polygram-managed Codex sessions until '
380
- + 'native goal support is implemented.';
386
+ body += '\n\n_Codex beta: no command network, web search, product MCP tools, '
387
+ + 'or interactive approvals. Detached/background servers are unsupported '
388
+ + 'and may survive hard runtime loss. '
389
+ + 'Native goals are currently disabled._';
381
390
  }
382
391
  if (show === 'model' || show === 'all') body += '\n' + modelHelp;
383
392
  if (show === 'effort' || show === 'all') body += '\n' + effortHelp;
@@ -33,76 +33,150 @@ function createPollLoop({
33
33
  getTopicName,
34
34
  logger = console,
35
35
  } = {}) {
36
- async function pollBot(bot) {
37
- await bot.init();
38
- bot._setBotUsername(bot.botInfo.username);
39
- logger.log?.(`[${botName}] Bot @${bot.botInfo.username} ready`);
36
+ let running = true;
37
+ let pollStarted = false;
38
+ let pollSettled = false;
39
+ let lastAdmittedUpdateId = null;
40
+ let resolvePollQuiescence;
41
+ let rejectPollQuiescence;
42
+ const pollQuiescence = new Promise((resolve, reject) => {
43
+ resolvePollQuiescence = resolve;
44
+ rejectPollQuiescence = reject;
45
+ });
46
+ pollQuiescence.catch(() => {});
40
47
 
41
- await bot.api.deleteWebhook();
48
+ const settlePollSuccess = () => {
49
+ if (pollSettled) return;
50
+ pollSettled = true;
51
+ resolvePollQuiescence({ lastAdmittedUpdateId });
52
+ };
53
+ const settlePollFailure = (error) => {
54
+ if (pollSettled) return;
55
+ pollSettled = true;
56
+ rejectPollQuiescence(error);
57
+ };
58
+ const stopPolling = () => {
59
+ running = false;
60
+ if (!pollStarted) settlePollSuccess();
61
+ return pollQuiescence;
62
+ };
63
+ const awaitPollSettlement = ({ timeoutMs } = {}) => {
64
+ if (timeoutMs == null) return pollQuiescence;
65
+ let timer;
66
+ const timeout = new Promise((_, reject) => {
67
+ timer = setTimeout(() => {
68
+ const error = new Error(`polling ingress did not settle within ${timeoutMs}ms`);
69
+ error.code = 'POLL_SETTLEMENT_TIMEOUT';
70
+ reject(error);
71
+ }, timeoutMs);
72
+ });
73
+ return Promise.race([pollQuiescence, timeout])
74
+ .finally(() => clearTimeout(timer));
75
+ };
42
76
 
43
- // Restore polling offset from DB so a restart doesn't re-process
44
- // the backlog Telegram accumulated while we were down.
45
- let offset = 0;
77
+ async function pollBot(bot) {
78
+ if (!running) return;
79
+ pollStarted = true;
80
+ bot._stop = stopPolling;
46
81
  try {
47
- const saved = db?.getPollingOffset?.(botName);
48
- if (saved && saved > 0) {
49
- offset = saved + 1;
50
- logger.log?.(`[${botName}] resuming polling from update_id ${saved}`);
82
+ await bot.init();
83
+ if (!running) {
84
+ settlePollSuccess();
85
+ return;
51
86
  }
52
- } catch (err) {
53
- logger.error?.(`[${botName}] getPollingOffset failed: ${err.message}`);
54
- }
55
- let running = true;
56
- bot._lastPollTs = Date.now();
57
- bot._stop = () => { running = false; };
87
+ bot._setBotUsername(bot.botInfo.username);
88
+ logger.log?.(`[${botName}] Bot @${bot.botInfo.username} ready`);
58
89
 
59
- while (running) {
90
+ await bot.api.deleteWebhook();
91
+ if (!running) {
92
+ settlePollSuccess();
93
+ return;
94
+ }
95
+
96
+ // Restore polling offset from DB so a restart doesn't re-process
97
+ // the backlog Telegram accumulated while we were down.
98
+ let offset = 0;
60
99
  try {
61
- const updates = await bot.api.getUpdates({
62
- offset,
63
- timeout: 25,
64
- allowed_updates: ['message', 'edited_message', 'callback_query'],
65
- });
66
- bot._lastPollTs = Date.now();
100
+ const saved = db?.getPollingOffset?.(botName);
101
+ if (saved && saved > 0) {
102
+ offset = saved + 1;
103
+ logger.log?.(`[${botName}] resuming polling from update_id ${saved}`);
104
+ }
105
+ } catch (err) {
106
+ logger.error?.(`[${botName}] getPollingOffset failed: ${err.message}`);
107
+ }
108
+ bot._lastPollTs = Date.now();
109
+
110
+ while (running) {
111
+ try {
112
+ const updates = await bot.api.getUpdates({
113
+ offset,
114
+ timeout: 25,
115
+ allowed_updates: ['message', 'edited_message', 'callback_query'],
116
+ });
117
+ bot._lastPollTs = Date.now();
118
+ if (!running) break;
67
119
 
68
- for (const update of updates) {
69
- offset = update.update_id + 1;
70
- if (update.message && isWellFormedMessage(update.message)) {
71
- const m = update.message;
72
- const chatId = m.chat.id.toString();
73
- const chatConfig = config.chats[chatId];
74
- const threadId = m.message_thread_id?.toString();
75
- const topicName = threadId
76
- ? (chatConfig ? getTopicName(chatConfig, threadId) : threadId)
77
- : null;
78
- const chatLabel = chatConfig?.name || chatId;
79
- const label = topicName ? `${chatLabel}/${topicName}` : chatLabel;
80
- logger.log?.(`[${botName}] ← ${label}: ${(m.text || m.caption || '(media)').slice(0, 60)}`);
120
+ let batchLastAdmittedId = null;
121
+ for (const update of updates) {
122
+ if (!running) break;
123
+ offset = update.update_id + 1;
124
+ batchLastAdmittedId = update.update_id;
125
+ lastAdmittedUpdateId = update.update_id;
126
+ if (update.message && isWellFormedMessage(update.message)) {
127
+ const m = update.message;
128
+ const chatId = m.chat.id.toString();
129
+ const chatConfig = config.chats[chatId];
130
+ const threadId = m.message_thread_id?.toString();
131
+ const topicName = threadId
132
+ ? (chatConfig ? getTopicName(chatConfig, threadId) : threadId)
133
+ : null;
134
+ const chatLabel = chatConfig?.name || chatId;
135
+ const label = topicName ? `${chatLabel}/${topicName}` : chatLabel;
136
+ logger.log?.(`[${botName}] ← ${label}: ${(m.text || m.caption || '(media)').slice(0, 60)}`);
137
+ }
138
+ try {
139
+ await bot.handleUpdate(update);
140
+ } catch (err) {
141
+ logger.error?.(`[${botName}] Handler error: ${err.message}`);
142
+ }
81
143
  }
82
- try {
83
- await bot.handleUpdate(update);
84
- } catch (err) {
85
- logger.error?.(`[${botName}] Handler error: ${err.message}`);
144
+ // Persist offset after batch dispatch — only on non-empty
145
+ // batches to avoid churning the row on every 25s idle poll.
146
+ if (batchLastAdmittedId != null) {
147
+ dbWrite(() => db.savePollingOffset(botName, batchLastAdmittedId),
148
+ 'save polling offset');
149
+ }
150
+ // No sleep on the success path: long-poll already blocks
151
+ // up to 25s when idle.
152
+ } catch (err) {
153
+ if (!running) break;
154
+ if (err.error_code === 409) {
155
+ logger.log?.(`[${botName}] 409, waiting 3s...`);
156
+ await new Promise((r) => setTimeout(r, 3000));
157
+ } else {
158
+ logger.error?.(`[${botName}] Poll error: ${err.message}`);
159
+ await new Promise((r) => setTimeout(r, 3000));
86
160
  }
87
161
  }
88
- // Persist offset after batch dispatch — only on non-empty
89
- // batches to avoid churning the row on every 25s idle poll.
90
- if (updates.length > 0) {
91
- dbWrite(() => db.savePollingOffset(botName, updates[updates.length - 1].update_id),
92
- 'save polling offset');
93
- }
94
- // No sleep on the success path: long-poll already blocks
95
- // up to 25s when idle.
96
- } catch (err) {
97
- if (!running) break;
98
- if (err.error_code === 409) {
99
- logger.log?.(`[${botName}] 409, waiting 3s...`);
100
- await new Promise((r) => setTimeout(r, 3000));
101
- } else {
102
- logger.error?.(`[${botName}] Poll error: ${err.message}`);
103
- await new Promise((r) => setTimeout(r, 3000));
162
+ }
163
+
164
+ if (lastAdmittedUpdateId != null) {
165
+ try {
166
+ await db.savePollingOffset(botName, lastAdmittedUpdateId);
167
+ } catch (error) {
168
+ const persistenceError = new Error(
169
+ `failed to persist the shutdown polling offset: ${error.message}`,
170
+ { cause: error },
171
+ );
172
+ persistenceError.code = 'POLL_OFFSET_PERSISTENCE_FAILED';
173
+ throw persistenceError;
104
174
  }
105
175
  }
176
+ settlePollSuccess();
177
+ } catch (error) {
178
+ settlePollFailure(error);
179
+ throw error;
106
180
  }
107
181
  }
108
182
 
@@ -131,7 +205,12 @@ function createPollLoop({
131
205
  }, 30_000);
132
206
  }
133
207
 
134
- return { pollBot, startPollWatchdog };
208
+ return {
209
+ pollBot,
210
+ startPollWatchdog,
211
+ stopPolling,
212
+ awaitPollSettlement,
213
+ };
135
214
  }
136
215
 
137
216
  module.exports = {
@@ -8,6 +8,8 @@
8
8
  * tryConsumeAsAnswer — dispatcher hook: a typed message while a question is
9
9
  * open (free-text "Other", or a nudge for the wrong user).
10
10
  * expireQuestion — timeout sweep / cancel: answer {timedout}/{cancelled}, strip.
11
+ * beginShutdownDisposition — cancel locally during provider retirement without
12
+ * sending a synthetic answer into the retiring turn.
11
13
  *
12
14
  * Security (review): per-row 128-bit token in callback_data + claim-on-first-tap
13
15
  * respondent authz + plain-text body (no parse_mode) for agent content.
@@ -246,6 +248,22 @@ function createQuestionHandlers({
246
248
  logEvent('question-expired', { session_key: row.session_key, tool_call_id: row.tool_call_id, status });
247
249
  }
248
250
 
251
+ function beginShutdownDisposition(row, {
252
+ message = 'Bot is restarting — this question was cancelled.',
253
+ } = {}) {
254
+ const ids = JSON.parse(row.message_ids_json || '[]');
255
+ const editing = ids[0]
256
+ ? strip(row.chat_id, ids[0], row.thread_id, message)
257
+ : Promise.resolve();
258
+ questions.resolve(row.id, 'cancelled');
259
+ logEvent('question-expired', {
260
+ session_key: row.session_key,
261
+ tool_call_id: row.tool_call_id,
262
+ status: 'cancelled',
263
+ });
264
+ return editing;
265
+ }
266
+
249
267
  // shared: record an advanced result, post a receipt, then next-Q or resolve.
250
268
  async function advance(ctx, row, res, fromText) {
251
269
  const msgId = (JSON.parse(row.message_ids_json || '[]'))[0];
@@ -283,7 +301,14 @@ function createQuestionHandlers({
283
301
  return ctx.answerCallbackQuery(text ? { text, show_alert: alert } : undefined).catch(() => {});
284
302
  }
285
303
 
286
- return { renderAsk, handleQuestionCallback, tryConsumeAsAnswer, expireQuestion, isAwaitingOtherFrom };
304
+ return {
305
+ renderAsk,
306
+ handleQuestionCallback,
307
+ tryConsumeAsAnswer,
308
+ expireQuestion,
309
+ beginShutdownDisposition,
310
+ isAwaitingOtherFrom,
311
+ };
287
312
  }
288
313
 
289
314
  module.exports = { createQuestionHandlers };
@@ -8,25 +8,35 @@
8
8
  'use strict';
9
9
 
10
10
  const { buildBusySnapshot } = require('../queue-utils');
11
+ const {
12
+ assertClosedQualificationRequest,
13
+ projectQualificationResponse,
14
+ } = require('../ops/clean-restart-qualification');
11
15
 
12
16
  /**
13
17
  * @param {object} opts
14
18
  * @param {string} opts.botName
19
+ * @param {object} opts.daemonIdentity
15
20
  * @param {() => Map<string, number>} opts.getInFlightHandlers — read late, not
16
21
  * captured: the dispatcher's map is assigned during boot and replaced on
17
22
  * reload, so a value bound at wiring time can go stale.
18
23
  * @param {(req) => any} opts.handleSendOverIpc
19
24
  * @param {(req) => any} opts.requestDeployRestart
25
+ * @param {() => any} opts.inspectCleanRestartQualification
20
26
  */
21
27
  function createIpcHandlers({
22
28
  botName,
29
+ daemonIdentity,
23
30
  getInFlightHandlers,
24
31
  handleSendOverIpc,
25
32
  requestDeployRestart,
33
+ inspectCleanRestartQualification,
26
34
  } = {}) {
27
35
  return {
28
36
  ping: async () => ({ pong: true, bot: botName }),
29
37
 
38
+ identity: async () => ({ bot: botName, ...daemonIdentity }),
39
+
30
40
  // Deploy pre-flight: what would restarting this daemon interrupt right now?
31
41
  // Counts and session keys only — enough to decide, nothing quotable.
32
42
  busy: async () => buildBusySnapshot({
@@ -36,6 +46,21 @@ function createIpcHandlers({
36
46
 
37
47
  deploy_restart: (req) => requestDeployRestart(req),
38
48
 
49
+ clean_restart_qualification: async (req) => {
50
+ assertClosedQualificationRequest(req);
51
+ let observation = null;
52
+ try {
53
+ observation = typeof inspectCleanRestartQualification === 'function'
54
+ ? await inspectCleanRestartQualification()
55
+ : null;
56
+ } catch {}
57
+ return projectQualificationResponse({
58
+ botName,
59
+ daemonIdentity,
60
+ observation,
61
+ });
62
+ },
63
+
39
64
  send: (req) => handleSendOverIpc(req),
40
65
  };
41
66
  }