polygram 0.38.0 → 0.38.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.38.0",
4
+ "version": "0.38.2",
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
@@ -459,6 +462,25 @@ function wrap(db) {
459
462
  ORDER BY id DESC LIMIT 1
460
463
  `);
461
464
 
465
+ const getForegroundCanaryTargetStmt = db.prepare(`
466
+ SELECT message.id AS message_id,
467
+ message.chat_id,
468
+ message.thread_id,
469
+ selection.telegram_message_id,
470
+ message.handler_status,
471
+ selection.session_key,
472
+ selection.provider
473
+ FROM messages AS message
474
+ JOIN inbound_runtime_selections AS selection
475
+ ON selection.bot_name = message.bot_name
476
+ AND selection.telegram_chat_id = message.chat_id
477
+ AND selection.telegram_message_id = ?
478
+ WHERE message.direction = 'in'
479
+ AND message.bot_name = ?
480
+ AND message.chat_id = ?
481
+ AND message.msg_id = ?
482
+ `);
483
+
462
484
  const setMessageTextStmt = db.prepare(`
463
485
  UPDATE messages
464
486
  SET text = @text
@@ -894,6 +916,7 @@ function wrap(db) {
894
916
  if (value != null) detail[key] = value;
895
917
  }
896
918
  }
919
+ Object.assign(detail, sanitizeCodexFaultProvenance(input));
897
920
  return Object.keys(detail).length > 0 ? JSON.stringify(detail) : null;
898
921
  }
899
922
 
@@ -4694,6 +4717,32 @@ function wrap(db) {
4694
4717
  `).get(messageId);
4695
4718
  },
4696
4719
 
4720
+ getForegroundCanaryTarget({ botName, chatId, telegramMessageId }) {
4721
+ const messageIdText = String(telegramMessageId);
4722
+ const messageId = Number(messageIdText);
4723
+ if (
4724
+ !Number.isSafeInteger(messageId)
4725
+ || messageId <= 0
4726
+ || String(messageId) !== messageIdText
4727
+ ) return null;
4728
+ const row = getForegroundCanaryTargetStmt.get(
4729
+ messageIdText,
4730
+ String(botName),
4731
+ String(chatId),
4732
+ messageId,
4733
+ );
4734
+ if (!row) return null;
4735
+ return {
4736
+ messageId: row.message_id,
4737
+ chatId: String(row.chat_id),
4738
+ threadId: row.thread_id == null ? null : String(row.thread_id),
4739
+ telegramMessageId: row.telegram_message_id,
4740
+ sessionKey: row.session_key,
4741
+ provider: row.provider,
4742
+ handlerStatus: row.handler_status,
4743
+ };
4744
+ },
4745
+
4697
4746
  listFailedAttachments({ since = null, limit = 100 } = {}) {
4698
4747
  const cutoff = since ?? Date.now() - 24 * 60 * 60 * 1000;
4699
4748
  return db.prepare(`
@@ -77,6 +77,7 @@ function createDispatcher({
77
77
  // Per-session in-flight handler count.
78
78
  const inFlightHandlers = new Map();
79
79
  const activeHandlers = new Set();
80
+ const activeHandlerTargets = new Set();
80
81
 
81
82
  function trackTask(task) {
82
83
  activeHandlers.add(task);
@@ -265,6 +266,15 @@ function createDispatcher({
265
266
  }
266
267
 
267
268
  function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
269
+ const activeHandlerTarget = Object.freeze({
270
+ sessionKey: String(sessionKey),
271
+ chatId: String(chatId),
272
+ threadId: msg?.message_thread_id == null
273
+ ? null
274
+ : String(msg.message_thread_id),
275
+ telegramMessageId: String(msg?.message_id),
276
+ });
277
+ activeHandlerTargets.add(activeHandlerTarget);
268
278
  const count = (inFlightHandlers.get(sessionKey) || 0) + 1;
269
279
  inFlightHandlers.set(sessionKey, count);
270
280
  const warnAt = queueWarnThreshold();
@@ -562,6 +572,7 @@ function createDispatcher({
562
572
  }
563
573
  }
564
574
  }).finally(() => {
575
+ activeHandlerTargets.delete(activeHandlerTarget);
565
576
  const n = (inFlightHandlers.get(sessionKey) || 1) - 1;
566
577
  if (n <= 0) inFlightHandlers.delete(sessionKey);
567
578
  else inFlightHandlers.set(sessionKey, n);
@@ -575,6 +586,8 @@ function createDispatcher({
575
586
  errorReplyText,
576
587
  queueWarnThreshold,
577
588
  inFlightHandlers, // exposed so polygram.js can introspect for shutdown drain
589
+ getActiveHandlerCount: () => activeHandlers.size,
590
+ getActiveHandlerTargets: () => [...activeHandlerTargets],
578
591
  awaitSettlement,
579
592
  trackTask,
580
593
  };
@@ -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,37 @@
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 {(req) => any} opts.requestForegroundCanaryTarget
26
+ * @param {() => any} opts.inspectCleanRestartQualification
20
27
  */
21
28
  function createIpcHandlers({
22
29
  botName,
30
+ daemonIdentity,
23
31
  getInFlightHandlers,
24
32
  handleSendOverIpc,
25
33
  requestDeployRestart,
34
+ requestForegroundCanaryTarget,
35
+ inspectCleanRestartQualification,
26
36
  } = {}) {
27
37
  return {
28
38
  ping: async () => ({ pong: true, bot: botName }),
29
39
 
40
+ identity: async () => ({ bot: botName, ...daemonIdentity }),
41
+
30
42
  // Deploy pre-flight: what would restarting this daemon interrupt right now?
31
43
  // Counts and session keys only — enough to decide, nothing quotable.
32
44
  busy: async () => buildBusySnapshot({
@@ -36,6 +48,23 @@ function createIpcHandlers({
36
48
 
37
49
  deploy_restart: (req) => requestDeployRestart(req),
38
50
 
51
+ foreground_canary_target: (req) => requestForegroundCanaryTarget(req),
52
+
53
+ clean_restart_qualification: async (req) => {
54
+ assertClosedQualificationRequest(req);
55
+ let observation = null;
56
+ try {
57
+ observation = typeof inspectCleanRestartQualification === 'function'
58
+ ? await inspectCleanRestartQualification()
59
+ : null;
60
+ } catch {}
61
+ return projectQualificationResponse({
62
+ botName,
63
+ daemonIdentity,
64
+ observation,
65
+ });
66
+ },
67
+
39
68
  send: (req) => handleSendOverIpc(req),
40
69
  };
41
70
  }