polygram 0.20.1 → 0.20.3

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.20.1",
4
+ "version": "0.20.3",
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",
package/lib/db.js CHANGED
@@ -239,6 +239,38 @@ function wrap(db) {
239
239
  UPDATE messages SET status = 'failed', error = '${CRASHED_MID_SEND}'
240
240
  WHERE status = 'pending' AND ts < ? AND bot_name = ?
241
241
  `);
242
+ const markReplayPendingStmt = db.prepare(`
243
+ UPDATE messages SET handler_status = 'replay-pending'
244
+ WHERE direction = 'in'
245
+ AND handler_status IN ('dispatched', 'processing')
246
+ AND bot_name = ?
247
+ AND ts > ?
248
+ `);
249
+ const clearCleanShutdownMarkerStmt = db.prepare(`
250
+ UPDATE polling_state SET clean_shutdown_at = NULL
251
+ WHERE bot_name = ?
252
+ `);
253
+
254
+ function recordShutdown({ botName, now = Date.now(), since, clean }) {
255
+ const cutoff = since ?? now - 30 * 60 * 1000;
256
+ const txn = db.transaction(() => {
257
+ const marked = markReplayPendingStmt.run(botName, cutoff);
258
+ if (clean) {
259
+ db.prepare(`
260
+ INSERT INTO polling_state (bot_name, last_update_id, ts, clean_shutdown_at)
261
+ VALUES (?,
262
+ COALESCE((SELECT last_update_id FROM polling_state WHERE bot_name = ?), 0),
263
+ COALESCE((SELECT ts FROM polling_state WHERE bot_name = ?), ?),
264
+ ?)
265
+ ON CONFLICT(bot_name) DO UPDATE SET clean_shutdown_at = excluded.clean_shutdown_at
266
+ `).run(botName, botName, botName, now, now);
267
+ } else {
268
+ clearCleanShutdownMarkerStmt.run(botName);
269
+ }
270
+ return marked.changes;
271
+ });
272
+ return { replayMarked: txn() };
273
+ }
242
274
 
243
275
  return {
244
276
  raw: db,
@@ -635,13 +667,7 @@ function wrap(db) {
635
667
  // null to mark all dispatched/processing rows for a bot).
636
668
  markReplayPending({ botName, since }) {
637
669
  const cutoff = since ?? Date.now() - 30 * 60 * 1000;
638
- return db.prepare(`
639
- UPDATE messages SET handler_status = 'replay-pending'
640
- WHERE direction = 'in'
641
- AND handler_status IN ('dispatched', 'processing')
642
- AND bot_name = ?
643
- AND ts > ?
644
- `).run(botName, cutoff);
670
+ return markReplayPendingStmt.run(botName, cutoff);
645
671
  },
646
672
 
647
673
  // 0.14 boot-replay: record a DELIBERATE (clean) shutdown. Atomically, in ONE
@@ -657,26 +683,14 @@ function wrap(db) {
657
683
  // only on a non-empty getUpdates batch): COALESCE the existing values, else
658
684
  // seed (0, now).
659
685
  recordCleanShutdown({ botName, now = Date.now(), since } = {}) {
660
- const cutoff = since ?? now - 30 * 60 * 1000;
661
- const txn = db.transaction(() => {
662
- const marked = db.prepare(`
663
- UPDATE messages SET handler_status = 'replay-pending'
664
- WHERE direction = 'in'
665
- AND handler_status IN ('dispatched', 'processing')
666
- AND bot_name = ?
667
- AND ts > ?
668
- `).run(botName, cutoff);
669
- db.prepare(`
670
- INSERT INTO polling_state (bot_name, last_update_id, ts, clean_shutdown_at)
671
- VALUES (?,
672
- COALESCE((SELECT last_update_id FROM polling_state WHERE bot_name = ?), 0),
673
- COALESCE((SELECT ts FROM polling_state WHERE bot_name = ?), ?),
674
- ?)
675
- ON CONFLICT(bot_name) DO UPDATE SET clean_shutdown_at = excluded.clean_shutdown_at
676
- `).run(botName, botName, botName, now, now);
677
- return marked.changes;
678
- });
679
- return { replayMarked: txn() };
686
+ return recordShutdown({ botName, now, since, clean: true });
687
+ },
688
+
689
+ // A handled stop can still be crash-like when the supervisor is stopping
690
+ // the service after a child OOM. Mark current work replayable and clear any
691
+ // clean marker in one transaction so the next boot fails toward recovery.
692
+ recordCrashShutdown({ botName, now = Date.now(), since } = {}) {
693
+ return recordShutdown({ botName, now, since, clean: false });
680
694
  },
681
695
 
682
696
  // 0.14: read AND clear the clean-shutdown marker in one txn. "Clean" iff a
@@ -689,7 +703,7 @@ function wrap(db) {
689
703
  const row = db.prepare('SELECT clean_shutdown_at FROM polling_state WHERE bot_name = ?').get(botName);
690
704
  const at = row ? row.clean_shutdown_at : null;
691
705
  if (row && at != null) {
692
- db.prepare('UPDATE polling_state SET clean_shutdown_at = NULL WHERE bot_name = ?').run(botName);
706
+ clearCleanShutdownMarkerStmt.run(botName);
693
707
  }
694
708
  return at;
695
709
  });
@@ -69,6 +69,7 @@ function createDispatcher({
69
69
  authDisabledGate = createAuthDisabledGate(),
70
70
  // State accessors (need late binding because polygram.js mutates):
71
71
  getIsShuttingDown, // () → boolean
72
+ getOomObservation = () => null, // () → cgroup OOM observation
72
73
  logger = console,
73
74
  } = {}) {
74
75
  // Per-session in-flight handler count.
@@ -188,7 +189,11 @@ function createDispatcher({
188
189
  handleMessage(sessionKey, chatId, msg, bot).catch((err) => {
189
190
  const wasAborted = abortGrace.isRecent(sessionKey);
190
191
  const isReplay = msg._isReplay === true;
191
- const isShuttingDown = getIsShuttingDown();
192
+ let oomShutdown = false;
193
+ try {
194
+ oomShutdown = getOomObservation()?.detected === true;
195
+ } catch {}
196
+ const isShuttingDown = getIsShuttingDown() || oomShutdown;
192
197
  logger.error?.(`[${sessionKey}] Error: ${err.message}`);
193
198
  // Mark the row terminal so the right thing happens on next
194
199
  // boot:
@@ -212,6 +217,7 @@ function createDispatcher({
212
217
  stack: err.stack?.split('\n').slice(0, 5).join('\n'),
213
218
  aborted: wasAborted || undefined,
214
219
  replay: isReplay || undefined,
220
+ oom_shutdown: oomShutdown || undefined,
215
221
  });
216
222
  // Startup-gate death (claude exited during spawn / the dialog gate timed
217
223
  // out) of a likely-aged RESUMED session — the persisted claude_session_id
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const CGROUP_MEMBERSHIP_PATH = '/proc/self/cgroup';
7
+ const CGROUP_ROOT = '/sys/fs/cgroup';
8
+
9
+ function parseCgroupV2Path(contents) {
10
+ if (typeof contents !== 'string') throw new TypeError('cgroup membership must be text');
11
+ const matches = contents
12
+ .split(/\r?\n/)
13
+ .filter((line) => line.startsWith('0::'));
14
+ if (matches.length === 0) return null;
15
+ if (matches.length !== 1) throw new Error('multiple cgroup v2 memberships');
16
+
17
+ const cgroupPath = matches[0].slice(3);
18
+ if (!cgroupPath.startsWith('/')) throw new Error('cgroup v2 path is not absolute');
19
+ if (cgroupPath.includes('\0')) throw new Error('cgroup v2 path contains NUL');
20
+ if (/\s+\(deleted\)$/.test(cgroupPath)) throw new Error('cgroup v2 path is deleted');
21
+
22
+ const relative = cgroupPath.slice(1);
23
+ if (relative !== '') {
24
+ const components = relative.split('/');
25
+ if (components.some((component) => component === '' || component === '.' || component === '..')) {
26
+ throw new Error('cgroup v2 path contains an invalid component');
27
+ }
28
+ }
29
+ return cgroupPath;
30
+ }
31
+
32
+ function resolveMemoryEventsPath(cgroupPath, cgroupRoot = CGROUP_ROOT) {
33
+ if (typeof cgroupPath !== 'string' || !cgroupPath.startsWith('/')) {
34
+ throw new Error('cgroup v2 path is invalid');
35
+ }
36
+ const root = path.resolve(cgroupRoot);
37
+ const cgroupDir = path.resolve(root, cgroupPath.slice(1));
38
+ const relative = path.relative(root, cgroupDir);
39
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
40
+ throw new Error('cgroup v2 path escapes the cgroup root');
41
+ }
42
+ return path.join(cgroupDir, 'memory.events');
43
+ }
44
+
45
+ function parseOomKillCounter(contents) {
46
+ if (typeof contents !== 'string') throw new TypeError('memory.events must be text');
47
+ const values = [];
48
+ for (const line of contents.split(/\r?\n/)) {
49
+ const fields = line.trim().split(/\s+/);
50
+ if (fields[0] !== 'oom_kill') continue;
51
+ if (fields.length !== 2 || !/^\d+$/.test(fields[1])) {
52
+ throw new Error('memory.events oom_kill counter is invalid');
53
+ }
54
+ values.push(fields[1]);
55
+ }
56
+ if (values.length !== 1) {
57
+ throw new Error('memory.events oom_kill counter is missing or invalid');
58
+ }
59
+ return BigInt(values[0]);
60
+ }
61
+
62
+ function observation(status, {
63
+ detected = false,
64
+ delta = null,
65
+ reason = null,
66
+ } = {}) {
67
+ return Object.freeze({
68
+ status,
69
+ detected,
70
+ delta,
71
+ ...(reason ? { reason } : {}),
72
+ });
73
+ }
74
+
75
+ function createCgroupOomObserver({
76
+ platform = process.platform,
77
+ readFileSync = fs.readFileSync,
78
+ membershipPath = CGROUP_MEMBERSHIP_PATH,
79
+ cgroupRoot = CGROUP_ROOT,
80
+ } = {}) {
81
+ if (platform !== 'linux') {
82
+ const startup = observation('unsupported', { reason: 'platform' });
83
+ return { startup, sample: () => startup };
84
+ }
85
+
86
+ let cgroupPath;
87
+ try {
88
+ cgroupPath = parseCgroupV2Path(readFileSync(membershipPath, 'utf8'));
89
+ } catch {
90
+ const startup = observation('unavailable', { reason: 'membership-invalid' });
91
+ return { startup, sample: () => startup };
92
+ }
93
+ if (cgroupPath == null) {
94
+ const startup = observation('unsupported', { reason: 'cgroup-v2-unavailable' });
95
+ return { startup, sample: () => startup };
96
+ }
97
+
98
+ let memoryEventsPath;
99
+ try {
100
+ memoryEventsPath = resolveMemoryEventsPath(cgroupPath, cgroupRoot);
101
+ } catch {
102
+ const startup = observation('unavailable', { reason: 'membership-invalid' });
103
+ return { startup, sample: () => startup };
104
+ }
105
+
106
+ let baseline;
107
+ try {
108
+ baseline = parseOomKillCounter(readFileSync(memoryEventsPath, 'utf8'));
109
+ } catch {
110
+ const startup = observation('unavailable', { reason: 'memory-events-unavailable' });
111
+ return { startup, sample: () => startup };
112
+ }
113
+
114
+ const startup = observation('unchanged', { delta: 0n });
115
+ return {
116
+ startup,
117
+ sample() {
118
+ let current;
119
+ try {
120
+ current = parseOomKillCounter(readFileSync(memoryEventsPath, 'utf8'));
121
+ } catch {
122
+ return observation('unavailable', { reason: 'memory-events-unavailable' });
123
+ }
124
+ if (current < baseline) {
125
+ return observation('unavailable', { reason: 'oom-kill-counter-decreased' });
126
+ }
127
+ const delta = current - baseline;
128
+ if (delta > 0n) return observation('detected', { detected: true, delta });
129
+ return observation('unchanged', { delta });
130
+ },
131
+ };
132
+ }
133
+
134
+ module.exports = {
135
+ parseCgroupV2Path,
136
+ resolveMemoryEventsPath,
137
+ parseOomKillCounter,
138
+ createCgroupOomObserver,
139
+ };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ function shutdownReasonFor(observation) {
4
+ if (observation?.detected === true) return 'cgroup-oom-kill';
5
+ if (observation?.status === 'unchanged') return 'no-oom-delta';
6
+ if (observation?.status === 'unsupported') return 'oom-observer-unsupported';
7
+ return 'oom-observer-unavailable';
8
+ }
9
+
10
+ /**
11
+ * Persist the replay state associated with a handled shutdown signal.
12
+ *
13
+ * Keeping this decision outside the signal handler makes the shutdown
14
+ * classification independently testable while the caller retains ownership of
15
+ * draining processes and emitting telemetry.
16
+ */
17
+ function persistShutdownDisposition({
18
+ db,
19
+ botName,
20
+ now = Date.now(),
21
+ since,
22
+ observation,
23
+ } = {}) {
24
+ const oomDetected = observation?.detected === true;
25
+ const result = oomDetected
26
+ ? db.recordCrashShutdown({ botName, now, since })
27
+ : db.recordCleanShutdown({ botName, now, since });
28
+ return {
29
+ clean: !oomDetected,
30
+ shutdownReason: shutdownReasonFor(observation),
31
+ replayMarked: result?.replayMarked ?? 0,
32
+ ...(oomDetected ? { oomKillDelta: String(observation.delta) } : {}),
33
+ };
34
+ }
35
+
36
+ module.exports = { persistShutdownDisposition };
package/lib/prompt.js CHANGED
@@ -5,23 +5,37 @@
5
5
  * Telegram payload → polygram DB → unresolvable marker.
6
6
  */
7
7
 
8
+ const { pickBackend } = require('@shumkov/orchestra');
9
+
10
+ function resolvePromptBackend({ config, chatId, threadId = null }) {
11
+ return pickBackend({
12
+ config,
13
+ chatId: String(chatId),
14
+ threadId: threadId == null || threadId === '' ? null : String(threadId),
15
+ pmDefault: 'sdk',
16
+ });
17
+ }
18
+
8
19
  /**
9
20
  * The <polygram-info> preamble. Sticker guidance is included ONLY when this bot
10
21
  * has a sticker set loaded (`stickerEmojis` non-empty) — a bot with no stickers.json
11
22
  * must not be told to emit stickers it doesn't have. The emoji list is the bot's OWN
12
23
  * pack (its emojiToSticker keys), not a hard-coded set, so it always matches reality.
13
24
  *
14
- * @param {{ stickerEmojis?: string[] }} [opts]
25
+ * @param {{ stickerEmojis?: string[], backend?: 'sdk'|'cli' }} [opts]
15
26
  */
16
- function polygramInfo({ stickerEmojis = [] } = {}) {
27
+ function polygramInfo({ stickerEmojis = [], backend = 'sdk' } = {}) {
17
28
  const hasStickers = stickerEmojis.length > 0;
29
+ const deliveryContract = backend === 'cli'
30
+ ? `You are connected via a Telegram daemon (polygram) in channels mode. Inline final text is invisible to the Telegram user. For every user-visible response, you MUST call \`mcp__polygram-bridge__reply\` within this turn. Only a successful reply-tool receipt proves delivery. Do not claim something was already sent or posted above based only on the transcript; transcript content is not a delivery receipt. If delivery is uncertain, send it now with the reply tool.`
31
+ : `You are connected via a Telegram daemon (polygram). Just reply with text — polygram delivers your response automatically. Do NOT use Telegram MCP tools.`;
18
32
  const emojiLine = hasStickers
19
33
  ? `Single emoji reply = auto-converted: ${stickerEmojis.join('')} become your stickers, any other emoji (🔥👍💪❤️) becomes a reaction on the user's message.`
20
34
  : `Single emoji reply = auto-converted to a reaction on the user's message (any Telegram emoji: 🔥👍💪❤️ …).`;
21
35
  const stickerTag = hasStickers
22
36
  ? `\n- \`[sticker:NAME]\` anywhere in your reply sends that sticker after the text. NAME must match polygram's sticker map.`
23
37
  : '';
24
- return `You are connected via a Telegram daemon (polygram). Just reply with text — polygram delivers your response automatically. Do NOT use Telegram MCP tools.
38
+ return `${deliveryContract}
25
39
  ${emojiLine}
26
40
  Inline tags (rc.63):${stickerTag}
27
41
  - \`[react:EMOJI]\` anywhere in your reply adds that emoji as a reaction on the user's message. Use any Telegram-supported emoji (👍 🔥 ❤️ 🎉 😢 …). Only the FIRST [react:] tag in a reply is applied; additional ones are dropped.
@@ -182,8 +196,9 @@ function buildVoiceTags(attachments) {
182
196
  * @param {string} params.sessionCtx - session context file contents (optional)
183
197
  * @param {Array} params.attachments - downloaded attachments
184
198
  * @param {Object} params.replyTo - input for buildReplyToBlock (optional)
199
+ * @param {'sdk'|'cli'} params.backend - delivery contract for the selected process backend
185
200
  */
186
- function buildPrompt({ msg, topicName = '', sessionCtx = '', attachments = [], replyTo = null, polygramHistory = '', stickerEmojis = [] }) {
201
+ function buildPrompt({ msg, topicName = '', sessionCtx = '', attachments = [], replyTo = null, polygramHistory = '', stickerEmojis = [], backend = 'sdk' }) {
187
202
  const chatId = msg.chat.id.toString();
188
203
  const msgId = msg.message_id.toString();
189
204
  const user = msg.from?.first_name || msg.from?.username || 'Unknown';
@@ -207,7 +222,7 @@ function buildPrompt({ msg, topicName = '', sessionCtx = '', attachments = [], r
207
222
  if (polygramHistory) {
208
223
  prompt += polygramHistory + '\n\n';
209
224
  }
210
- prompt += `<polygram-info>${polygramInfo({ stickerEmojis })}</polygram-info>\n\n`;
225
+ prompt += `<polygram-info>${polygramInfo({ stickerEmojis, backend })}</polygram-info>\n\n`;
211
226
 
212
227
  const replyBlock = buildReplyToBlock(replyTo);
213
228
  const attachmentTags = buildAttachmentTags(attachments);
@@ -225,6 +240,7 @@ function buildPrompt({ msg, topicName = '', sessionCtx = '', attachments = [], r
225
240
  }
226
241
 
227
242
  module.exports = {
243
+ resolvePromptBackend,
228
244
  xmlEscape,
229
245
  truncateReplyText,
230
246
  buildReplyToBlock,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.20.1",
3
+ "version": "0.20.3",
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.4.2",
66
+ "@shumkov/orchestra": "0.4.3",
67
67
  "better-sqlite3": "^12.9.0",
68
68
  "grammy": "^1.42.0",
69
69
  "markdown-it": "^14.1.1",
package/polygram.js CHANGED
@@ -27,7 +27,7 @@ const dbClient = require('./lib/db');
27
27
  const {
28
28
  migrateJsonToDb, getClaudeSessionId, resolveSessionForSpawn,
29
29
  } = require('./lib/db/sessions');
30
- const { buildPrompt } = require('./lib/prompt');
30
+ const { buildPrompt, resolvePromptBackend } = require('./lib/prompt');
31
31
  const { filterAttachments, resolveFileCaps, resolveMaxFileOverride, MAX_TOTAL_BYTES } = require('./lib/attachments');
32
32
  // 0.9.0: SDK ProcessManager is the only pm. CLI pm
33
33
  // (lib/process-manager.js) deleted in commit 6.
@@ -129,6 +129,8 @@ const { classify: classifyError, classifyTurnEndError, detectWedgedSessionError,
129
129
  const { createAutoResumeTracker, isAutoResumable } = require('./lib/db/auto-resume');
130
130
  const { createAuthDisabledGate } = require('./lib/ops/auth-disabled-gate');
131
131
  const { createHeartbeat } = require('./lib/ops/heartbeat');
132
+ const { createCgroupOomObserver } = require('./lib/ops/cgroup-oom-observer');
133
+ const { persistShutdownDisposition } = require('./lib/ops/shutdown-disposition');
132
134
  const { resolveReplayWindowMs } = require('./lib/db/replay-window');
133
135
  const { pruneEvents, resolveRetentionPolicy, validatePolicy } = require('./lib/db/events-retention');
134
136
  const { sweepSecrets, resolveSecretSweepConfig } = require('./lib/db/secret-sweep');
@@ -430,6 +432,7 @@ function formatPrompt(msg, sessionCtx, attachments = [], { sessionKey = null } =
430
432
  attachments,
431
433
  replyTo: resolveReplyTo(msg),
432
434
  polygramHistory,
435
+ backend: resolvePromptBackend({ config, chatId, threadId }),
433
436
  // Only advertise stickers this bot actually has — empty for a bot with no
434
437
  // sticker set, so its prompt never mentions stickers.
435
438
  stickerEmojis: Object.keys(emojiToSticker),
@@ -2372,6 +2375,7 @@ let startPollWatchdog = null;
2372
2375
  // ─── Main ───────────────────────────────────────────────────────────
2373
2376
 
2374
2377
  async function main() {
2378
+ const cgroupOomObserver = createCgroupOomObserver();
2375
2379
  loadConfig();
2376
2380
 
2377
2381
  let dbOverride;
@@ -2468,6 +2472,10 @@ async function main() {
2468
2472
  db.logEvent('inbox-swept', { files: swept.swept, bytes: swept.bytes, retention_days: inboxRetentionMs / 86_400_000 });
2469
2473
  }
2470
2474
  db.logEvent('polygram-start', { migration: migration.reason, imported: migration.imported });
2475
+ if (cgroupOomObserver.startup.status === 'unavailable') {
2476
+ console.warn(`[oom-observer] unavailable (${cgroupOomObserver.startup.reason}); handled shutdown signals will retain clean-restart behavior`);
2477
+ db.logEvent('oom-observer-unavailable', { reason: cgroupOomObserver.startup.reason });
2478
+ }
2471
2479
  } catch (err) {
2472
2480
  console.error(`[db] FATAL: ${err.message}`);
2473
2481
  console.error('Bridge cannot run without a DB (Phase 2: DB is source of truth).');
@@ -2907,6 +2915,7 @@ async function main() {
2907
2915
  chunkMarkdownText, deliverReplies,
2908
2916
  chunkBudget: TG_CHUNK_BUDGET,
2909
2917
  getIsShuttingDown: () => isShuttingDown,
2918
+ getOomObservation: () => cgroupOomObserver.sample(),
2910
2919
  authDisabledGate,
2911
2920
  logger: console,
2912
2921
  }));
@@ -2985,6 +2994,7 @@ async function main() {
2985
2994
  const SHUTDOWN_DRAIN_MS = 30_000;
2986
2995
  const shutdown = async () => {
2987
2996
  if (isShuttingDown) return;
2997
+ const shutdownObservation = cgroupOomObserver.sample();
2988
2998
  isShuttingDown = true;
2989
2999
  console.log('\nShutting down...');
2990
3000
  // 1. Stop accepting new inbound first so nothing new queues behind the drain.
@@ -3071,28 +3081,30 @@ async function main() {
3071
3081
  let remaining = 0;
3072
3082
  for (const n of inFlightHandlers.values()) remaining += n;
3073
3083
 
3074
- // 3. This handler only runs on a DELIBERATE shutdown (SIGINT/SIGTERM/SIGHUP);
3075
- // a crash (SIGKILL/OOM/panic) never reaches here. Record a clean-shutdown
3076
- // marker AND mark any still-in-flight rows replay-pending, atomically, on
3077
- // EVERY clean shutdown (not just when in-flight>0) — boot uses the marker
3078
- // to SKIP re-answering stale messages on a deliberate restart while still
3079
- // recovering everything on a crash (0.14, §H1). markReplayPending alone
3080
- // (the old behavior) couldn't distinguish the two, so every deploy
3081
- // re-answered. A stale replay-pending row from a prior life would also be
3082
- // crash-recovered on a deliberate restart without the unconditional marker.
3084
+ // 3. A handled signal is normally deliberate, but a Linux supervisor can
3085
+ // also stop the service after an OOM-killed child. Persist crash-like
3086
+ // state when the process cgroup recorded an OOM kill; otherwise retain
3087
+ // the clean-restart marker. Both paths mark in-flight work replayable in
3088
+ // the same transaction as the marker update.
3083
3089
  if (db) {
3084
3090
  try {
3085
- const res = db.recordCleanShutdown({ botName: BOT_NAME });
3091
+ const res = persistShutdownDisposition({
3092
+ db,
3093
+ botName: BOT_NAME,
3094
+ observation: shutdownObservation,
3095
+ });
3086
3096
  logEvent('shutdown-drain', {
3087
3097
  bot: BOT_NAME,
3088
3098
  in_flight: remaining,
3089
- replay_marked: res?.replayMarked ?? 0,
3099
+ replay_marked: res.replayMarked,
3090
3100
  elapsed_ms: drainElapsed,
3091
- clean: true,
3101
+ clean: res.clean,
3102
+ shutdown_reason: res.shutdownReason,
3103
+ ...(res.oomKillDelta != null ? { oom_kill_delta: res.oomKillDelta } : {}),
3092
3104
  });
3093
- console.log(`[shutdown] clean shutdown recorded; drained ${drainElapsed}ms, ${remaining} in-flight, ${res?.replayMarked ?? 0} marked replay-pending`);
3105
+ console.log(`[shutdown] ${res.clean ? 'clean' : 'crash-like'} shutdown recorded (${res.shutdownReason}); drained ${drainElapsed}ms, ${remaining} in-flight, ${res.replayMarked} marked replay-pending`);
3094
3106
  } catch (err) {
3095
- console.error(`[shutdown] recordCleanShutdown failed: ${err.message}`);
3107
+ console.error(`[shutdown] persistence failed: ${err.message}`);
3096
3108
  }
3097
3109
  }
3098
3110
 
@@ -1,25 +1,45 @@
1
1
  ---
2
2
  name: polygram-send
3
- description: Send messages, photos, documents, or stickers from a script (or from agent Bash) into the same Telegram chat the bot is in. Use when an agent or cron job needs to post a file, photo, or out-of-band text to a polygram chat — typically Xero invoices (PDF/.docx), generated reports, screenshots, or status pings tied to long-running work. Do NOT use for normal turn replies (those go through the agent's text reply, polygram delivers automatically). Do NOT use Telegram MCP — polygram-send is the supported path.
3
+ description: Choose the correct polygram delivery path for SDK turns, CLI/channels turns, and detached scripts. Use IPC for cron jobs or genuinely out-of-turn sends; use the reply MCP for interactive CLI text and files; return inline text for interactive SDK replies.
4
4
  ---
5
5
 
6
6
  # polygram-send
7
7
 
8
8
  Polygram exposes a per-bot Unix socket at `/tmp/polygram-<bot>.sock`. Authorized callers (same UID, with the per-bot secret) can send Telegram API methods through it. Polygram routes them to the live bot connection, logs them in the `messages` table, and applies markdown / chunking / dedup.
9
9
 
10
- This is the **supported path** for any agent or script that needs to deliver a file or out-of-band message into a polygram-managed chat. Telegram MCP is **not** wired up; agents that try to call Telegram directly will hit auth or routing issues.
10
+ This is the **supported IPC path** for a detached script that needs to deliver a file or out-of-band message into a polygram-managed chat. Interactive turns have backend-specific delivery paths described below. Generic Telegram MCP is not wired up; CLI/channels turns use polygram's own reply MCP instead.
11
11
 
12
- ## When to use
12
+ ## Choose exactly one delivery lane
13
13
 
14
- - Agent generated a file (PDF, .docx, image, etc.) and the user asked for it in chat
14
+ ### SDK interactive turn
15
+
16
+ Return the user-visible reply as inline assistant text. Polygram delivers it through the streamed SDK reply path. Do not also call the reply MCP or IPC, because that duplicates the message.
17
+
18
+ Conversation transcript text does not prove an earlier reply reached Telegram. Do not claim a report was "already sent" or "posted above" based only on transcript history. If delivery is uncertain, include the content in the current inline reply.
19
+
20
+ ### CLI/channels interactive turn
21
+
22
+ Inline final text is not delivered to Telegram. Send every user-visible text reply through `mcp__polygram-bridge__reply` within the turn. Attach generated files through that tool's `files` array. Do not use IPC for an interactive text or file reply.
23
+
24
+ Only a successful reply-tool receipt proves delivery. Transcript text and an attempted call without a successful receipt do not. If delivery is uncertain, call the reply tool now instead of claiming the message was sent earlier.
25
+
26
+ ### Cron or detached script
27
+
28
+ Use the `polygram-send` IPC client described below. This lane is for code running outside an interactive turn, including cron jobs, detached background scripts that generated files, and genuinely out-of-turn status updates. Check the IPC response and treat only a successful response as proof of delivery.
29
+
30
+ ## When to use IPC
31
+
32
+ - A detached script generated a file (PDF, .docx, image, etc.) after the interactive turn ended
15
33
  - Cron job needs to post a status update to the relevant chat
16
34
  - Long-running work needs an out-of-turn nudge (e.g. "build finished, here's the artifact")
17
35
 
18
- ## When NOT to use
36
+ ## When NOT to use IPC
19
37
 
20
- - Your normal text reply to the user — return text from the turn, polygram delivers it through the streamed reply path. Don't double-send.
38
+ - A normal SDK text reply — return inline text.
39
+ - A normal CLI/channels text reply — use `mcp__polygram-bridge__reply`.
40
+ - A CLI/channels file reply during an interactive turn — pass absolute paths in the reply tool's `files` array.
21
41
  - Anything that needs the message attributed to "user" — IPC always sends as the bot.
22
- - The Telegram MCP — explicitly NOT supported. Polygram blocks outbound message routing through anything other than its own bridge.
42
+ - Generic Telegram MCP — it is not a polygram delivery path.
23
43
 
24
44
  ## Allowed methods
25
45
 
@@ -139,8 +159,9 @@ The IPC socket validates a per-bot secret (`/tmp/polygram-<bot>.secret`, mode 06
139
159
 
140
160
  ## Don't
141
161
 
142
- - Don't call this for normal text replies. Just emit text from the turn — polygram delivers it.
143
- - Don't use Telegram MCP.
162
+ - Don't use IPC for a normal interactive text reply; follow the SDK or CLI lane above.
163
+ - Don't use generic Telegram MCP.
164
+ - Don't infer successful delivery from transcript text.
144
165
  - Don't pass localhost URLs to `document`/`photo`.
145
166
  - Don't hardcode `message_thread_id` from previous sessions.
146
167
  - Don't omit `source` from agent-triggered sends.