polygram 0.17.11 → 0.17.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/lib/error/classify.js +14 -0
  3. package/lib/handlers/dispatcher.js +54 -1
  4. package/lib/handlers/drop-redeliver.js +19 -0
  5. package/lib/handlers/should-handle.js +52 -0
  6. package/lib/media-group-buffer.js +29 -0
  7. package/lib/ops/auth-disabled-gate.js +43 -0
  8. package/lib/ops/heartbeat.js +56 -0
  9. package/lib/process/channels-tool-dispatcher.js +12 -1
  10. package/lib/sdk/callbacks.js +2 -1
  11. package/lib/secret-detect.js +13 -1
  12. package/lib/telegram/chunk.js +20 -6
  13. package/lib/telegram/process-agent-reply.js +5 -3
  14. package/lib/telegram/streamer.js +6 -1
  15. package/package.json +2 -1
  16. package/polygram.js +188 -41
  17. package/lib/async-lock.js +0 -49
  18. package/lib/claude-bin.js +0 -246
  19. package/lib/compaction-warn.js +0 -59
  20. package/lib/context-usage.js +0 -93
  21. package/lib/process/channels-bridge-protocol.js +0 -199
  22. package/lib/process/channels-bridge-server.js +0 -274
  23. package/lib/process/channels-bridge.mjs +0 -477
  24. package/lib/process/cli-process.js +0 -4029
  25. package/lib/process/factory.js +0 -215
  26. package/lib/process/hook-event-tail.js +0 -162
  27. package/lib/process/hook-settings.js +0 -181
  28. package/lib/process/polygram-hook-append.js +0 -71
  29. package/lib/process/process.js +0 -215
  30. package/lib/process/sdk-process.js +0 -880
  31. package/lib/process-guard.js +0 -296
  32. package/lib/process-manager.js +0 -628
  33. package/lib/tmux/log-tail.js +0 -334
  34. package/lib/tmux/orphan-sweep.js +0 -79
  35. package/lib/tmux/poll-scheduler.js +0 -110
  36. package/lib/tmux/startup-gate.js +0 -250
  37. package/lib/tmux/tmux-runner.js +0 -412
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.11.0-rc.15",
4
+ "version": "0.17.12",
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",
@@ -236,6 +236,20 @@ const CODES = {
236
236
  isTransient: false,
237
237
  autoRecover: null,
238
238
  },
239
+ // AUTH_DISABLED: Anthropic has disabled Claude Code / subscription access on
240
+ // this account (e.g. non-payment) — orchestra's cli-process.js detects the
241
+ // streamed policy-string signature and rejects every pending turn
242
+ // immediately (see docs/AUTH_DISABLED_HANDLING_SPEC.md) instead of letting
243
+ // it wedge for 10 minutes into a generic TURN_TIMEOUT. Distinct from
244
+ // AUTH_EXPIRED (a token refresh problem, user-recoverable by re-login):
245
+ // this is an infra/billing condition on our end, so userMessage is null —
246
+ // the operator gets notified (dispatcher.js), the chat does not.
247
+ AUTH_DISABLED: {
248
+ kind: 'authDisabled',
249
+ userMessage: null,
250
+ isTransient: false,
251
+ autoRecover: null,
252
+ },
239
253
  };
240
254
 
241
255
  /**
@@ -22,6 +22,8 @@
22
22
 
23
23
  'use strict';
24
24
 
25
+ const { createAuthDisabledGate } = require('../ops/auth-disabled-gate');
26
+
25
27
  const CONCURRENT_WARN_THRESHOLD_DEFAULT = 20;
26
28
 
27
29
  // Startup auto-retry (option a, 2026-06-04): a short breath before silently
@@ -58,6 +60,13 @@ function createDispatcher({
58
60
  // Delay before a silent startup auto-retry re-dispatches (TMUX_SESSION_GONE).
59
61
  // Injected so tests can drive it to 0; production uses STARTUP_RETRY_DELAY_MS.
60
62
  startupRetryDelayMs = STARTUP_RETRY_DELAY_MS,
63
+ // AUTH_DISABLED dedupe/re-arm gate (docs/AUTH_DISABLED_HANDLING_SPEC.md).
64
+ // Defaulted (rather than required) so a missing DI param at some future
65
+ // call site degrades to "always notify, never dedupe" instead of throwing
66
+ // — AUTH_DISABLED is account-wide, so an unguarded throw here would hit
67
+ // every concurrent chat within seconds and risk tripping the storm circuit
68
+ // breaker (polygram.js uncaughtException handler) on a wiring bug alone.
69
+ authDisabledGate = createAuthDisabledGate(),
61
70
  // State accessors (need late binding because polygram.js mutates):
62
71
  getIsShuttingDown, // () → boolean
63
72
  logger = console,
@@ -224,12 +233,56 @@ function createDispatcher({
224
233
  chat_id: chatId, session_key: sessionKey, msg_id: msg?.message_id, code: err.code,
225
234
  });
226
235
  }
236
+ // AUTH_DISABLED (docs/AUTH_DISABLED_HANDLING_SPEC.md): Anthropic has
237
+ // disabled Claude Code access on this account (e.g. non-payment).
238
+ // Runs unconditionally — independent of wasAborted/isReplay/
239
+ // isShuttingDown — because the account-disabled condition is real
240
+ // regardless of what else was happening to this particular message.
241
+ // The chat itself is never told (classify.js maps this code to
242
+ // userMessage: null, so errorReplyText(err) below naturally suppresses
243
+ // it) — this only notifies the operator, once per outage window.
244
+ if (err.code === 'AUTH_DISABLED') {
245
+ logger.error?.(`[auth] (${botName}) Claude access DISABLED by Anthropic — turn rejected immediately instead of wedging; check the account/billing.`);
246
+ logEvent('auth-disabled', {
247
+ chat_id: chatId, session_key: sessionKey, msg_id: msg?.message_id,
248
+ error: err.message?.slice(0, 500),
249
+ });
250
+ // Never let the gate itself become a new failure mode — AUTH_DISABLED
251
+ // is account-wide, so a bad gate would throw on every concurrent
252
+ // chat within seconds of each other (see the storm circuit breaker
253
+ // this repo already has for exactly that shape of cascading failure).
254
+ let shouldNotify = false;
255
+ try {
256
+ shouldNotify = authDisabledGate.noteFailure();
257
+ } catch (gateErr) {
258
+ logger.error?.(`[auth] authDisabledGate.noteFailure failed: ${gateErr.message}`);
259
+ }
260
+ if (shouldNotify) {
261
+ const adminChatId = config.bot?.approvals?.adminChatId;
262
+ if (adminChatId) {
263
+ tg(bot, 'sendMessage', {
264
+ chat_id: adminChatId,
265
+ text: `🚫 Claude Code access appears DISABLED for this account (Anthropic-side, e.g. non-payment) — turns are failing instead of replying. Check the Anthropic account/billing. (bot: ${botName})`,
266
+ }, { source: 'auth-disabled-notify', botName }).catch((notifyErr) => {
267
+ logger.error?.(`[auth] operator notify failed: ${notifyErr.message}`);
268
+ });
269
+ } else {
270
+ logger.error?.(`[auth] AUTH_DISABLED fired but no config.bot.approvals.adminChatId configured — operator was not notified`);
271
+ }
272
+ }
273
+ }
227
274
  // rc.55: surface replay failures with a meaningful message.
228
275
  // Pre-rc.55 any boot-replay turn that failed for ANY reason
229
276
  // was silently dropped. The rc.51-onward boot-replay path is
230
277
  // a recovery primitive, not stale-message handling — when it
231
278
  // fails, the user IS still waiting.
232
- if (isReplay && !wasAborted && !isShuttingDown) {
279
+ // AUTH_DISABLED is excluded: it's an unconditional block like this
280
+ // one, gated on isReplay rather than err.code, so without this
281
+ // exclusion a replayed message failing with AUTH_DISABLED would fall
282
+ // through into this hardcoded reply — contradicting the "chat is
283
+ // never told, only the operator is" contract classify.js establishes
284
+ // via userMessage: null (found in code review).
285
+ if (isReplay && !wasAborted && !isShuttingDown && err.code !== 'AUTH_DISABLED') {
233
286
  tg(bot, 'sendMessage', {
234
287
  chat_id: chatId,
235
288
  text: '⚠️ This turn was interrupted and didn\'t complete on retry — please rephrase or simplify, or split into smaller steps.',
@@ -59,6 +59,25 @@ function createDropRedeliverer({ db, redeliver, logEvent = () => {}, logger = co
59
59
  ...(row.thread_id && { message_thread_id: Number(row.thread_id) }),
60
60
  ...(row.reply_to_id && { reply_to_message: { message_id: row.reply_to_id } }),
61
61
  };
62
+ // Persisted attachments ride _mergedAttachments (boot-replay parity):
63
+ // the native photo/voice fields can't be restored from the DB, and
64
+ // without this an attachment-only drop is blocked by the presence
65
+ // gate while a text+media drop silently loses its attachments.
66
+ // Isolated in its own try/catch (review: reliability+adversarial): this
67
+ // redelivery only fires ONCE, so a lookup failure here must degrade to
68
+ // a text-only redelivery, not abandon the whole drop.
69
+ let attRows = [];
70
+ try {
71
+ attRows = db.getAttachmentsByMessage?.(row.id) || [];
72
+ } catch (err) {
73
+ logger.error?.(`[drop-redeliver] attachment lookup failed for msg ${row.id}: ${err?.message || err}`);
74
+ }
75
+ if (attRows.length) {
76
+ reconstructed._mergedAttachments = attRows.map((a) => ({
77
+ kind: a.kind, name: a.name, mime_type: a.mime_type,
78
+ size: a.size_bytes, file_id: a.file_id, file_unique_id: a.file_unique_id,
79
+ }));
80
+ }
62
81
  await redeliver({ chatId: String(chatId), msg: reconstructed, source: 'drop' });
63
82
  } catch (err) {
64
83
  logger.error?.(`[drop-redeliver] ${err?.message || err}`);
@@ -0,0 +1,52 @@
1
+ /**
2
+ * shouldHandle — the content-presence + chat-allowlist + mention/pairing
3
+ * intake gate. Runs for every tier of the D5 unified gate (fresh, edit,
4
+ * redelivery — see lib/handlers/gate-inbound.js).
5
+ *
6
+ * Extracted from polygram.js as a factory so the logic is unit-testable;
7
+ * deps are getters because polygram wires config / pairings / BOT_NAME
8
+ * at boot, after module load.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ function createShouldHandle({ getConfig, getPairings, getBotName } = {}) {
14
+ return function shouldHandle(msg, chatConfig, botUsername) {
15
+ const config = getConfig();
16
+ // A replayed / drop-redelivered message is reconstructed from the DB and
17
+ // carries its attachments ONLY in _mergedAttachments — the native
18
+ // photo/document/voice fields can't be restored. Count them as content,
19
+ // or every attachment-only turn (voice note, caption-less photo) becomes
20
+ // unrecoverable after a crash: blocked here as "empty" before dispatch.
21
+ const hasAttachment = !!(msg.document || msg.photo || msg.voice || msg.audio || msg.video)
22
+ || (Array.isArray(msg._mergedAttachments) && msg._mergedAttachments.length > 0);
23
+ if (!msg.text && !msg.caption && !hasAttachment) return false;
24
+ const chatId = msg.chat.id.toString();
25
+ if (!config.chats[chatId]) return false;
26
+
27
+ if (chatConfig.requireMention && msg.chat.type !== 'private') {
28
+ const text = msg.text || msg.caption || '';
29
+ const isReplyToBot = msg.reply_to_message?.from?.username === botUsername;
30
+ const hasMention = text.includes(`@${botUsername}`);
31
+ // A reply targeting some other user (not the bot) is a strong signal
32
+ // "this message is for that person, not me". Paired users normally
33
+ // bypass requireMention, but not in this case — without the guard a
34
+ // paired user saying "Gotcha!" to a teammate gets processed by the
35
+ // bot just because the user is paired, which is what bit us in
36
+ // UMI Group on 0.5.9 (bot leaked reasoning as a reply to "Gotcha!").
37
+ const repliesToOtherUser = !!msg.reply_to_message
38
+ && msg.reply_to_message.from?.username !== botUsername;
39
+ // Paired users bypass requireMention — operator-trusted, no @ needed
40
+ // every time. Skipped when they're replying to a non-bot user (above).
41
+ const pairings = getPairings();
42
+ const paired = !repliesToOtherUser && pairings && msg.from?.id
43
+ ? pairings.hasLivePairing({ bot_name: getBotName(), user_id: msg.from.id, chat_id: chatId })
44
+ : false;
45
+ if (!isReplyToBot && !hasMention && !paired) return false;
46
+ }
47
+
48
+ return true;
49
+ };
50
+ }
51
+
52
+ module.exports = { createShouldHandle };
@@ -101,9 +101,38 @@ function createMediaGroupBuffer({
101
101
  }
102
102
  };
103
103
 
104
+ // Read-only view of everything still buffered, without flushing.
105
+ // Shutdown uses this to mark undispatched album siblings replay-pending
106
+ // in the DB — flushing at exit would race process.exit and lose them.
107
+ const peekAll = () => {
108
+ const out = [];
109
+ for (const entry of entries.values()) out.push(...entry.messages);
110
+ return out;
111
+ };
112
+
113
+ // Atomically cancel every group's pending flush timer AND remove it from
114
+ // the buffer, WITHOUT calling onFlush. Unlike peekAll(), the caller now
115
+ // OWNS these groups — none of them can self-flush afterward. Shutdown
116
+ // uses this (not peekAll()) so a timer firing during the shutdown drain
117
+ // can't double-process a group that's already been DB-marked
118
+ // replay-pending. Groups stay separated by key (not flattened) so the
119
+ // caller can coalesce each one onto a single primary row, matching the
120
+ // live onFlush path's own semantics.
121
+ const takeAllForShutdown = () => {
122
+ const out = [];
123
+ for (const [key, entry] of entries) {
124
+ if (entry.timer) clearTimerFn(entry.timer);
125
+ out.push({ key, messages: entry.messages });
126
+ }
127
+ entries.clear();
128
+ return out;
129
+ };
130
+
104
131
  return {
105
132
  add,
106
133
  flushAll,
134
+ peekAll,
135
+ takeAllForShutdown,
107
136
  get size() { return entries.size; },
108
137
  };
109
138
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Dedupe/re-arm gate for AUTH_DISABLED operator notifications
3
+ * (docs/AUTH_DISABLED_HANDLING_SPEC.md).
4
+ *
5
+ * Fires the operator DM once per outage window: the first AUTH_DISABLED
6
+ * occurrence after construction (or after the last successful Claude turn)
7
+ * notifies; every occurrence after that is deduped until a turn succeeds
8
+ * again. `noteSuccess()` must only be called on a genuine Claude-turn
9
+ * success (see polygram.js handleMessage's non-error branch) — calling it
10
+ * on unrelated bot traffic (slash commands, unconfigured chats) would
11
+ * falsely clear an ongoing outage and cause repeat pages.
12
+ *
13
+ * `count`/`lastAt` track every occurrence regardless of dedupe, for the
14
+ * heartbeat counter (lib/ops/heartbeat.js).
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ function createAuthDisabledGate({ now = Date.now } = {}) {
20
+ let armed = true;
21
+ let count = 0;
22
+ let lastAt = null;
23
+
24
+ function noteFailure() {
25
+ count += 1;
26
+ lastAt = now();
27
+ if (!armed) return false;
28
+ armed = false;
29
+ return true;
30
+ }
31
+
32
+ function noteSuccess() {
33
+ armed = true;
34
+ }
35
+
36
+ function snapshot() {
37
+ return { count, lastAt, armed };
38
+ }
39
+
40
+ return { noteFailure, noteSuccess, snapshot };
41
+ }
42
+
43
+ module.exports = { createAuthDisabledGate };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Minimal file-based heartbeat for AUTH_DISABLED Netdata visibility
3
+ * (docs/AUTH_DISABLED_HANDLING_SPEC.md, Layer 3.3).
4
+ *
5
+ * File-only equivalent of water's lib/ops/heartbeat.js pattern — no
6
+ * /healthz endpoint, since this repo has no HTTP server to hang a route on.
7
+ * Wiring heartbeat.json into an actual Netdata alert (netdata_watch_units /
8
+ * a filecheck-or-cron-parses-JSON entry) is a VPS-side ops/ansible change
9
+ * outside this repo's scope; this only builds the code-side signal.
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const fs = require('node:fs');
15
+ const path = require('node:path');
16
+
17
+ function createHeartbeat({ dataDir, authDisabledGate, intervalMs = 60_000, now = Date.now }) {
18
+ const file = path.join(dataDir, 'heartbeat.json');
19
+ let timer = null;
20
+
21
+ function snapshot() {
22
+ const gate = authDisabledGate.snapshot();
23
+ return { ts: now(), authDisabled: gate.count, authDisabledLastAt: gate.lastAt };
24
+ }
25
+
26
+ function beat() {
27
+ const snap = snapshot();
28
+ try {
29
+ const tmp = `${file}.tmp`;
30
+ fs.writeFileSync(tmp, JSON.stringify(snap));
31
+ fs.renameSync(tmp, file); // atomic
32
+ } catch (err) {
33
+ // Best-effort (matches water's heartbeat.js) — must never throw into the
34
+ // interval timer or affect a turn. Logged (water's version doesn't) because
35
+ // this file's whole purpose is surfacing a silent failure; a heartbeat that
36
+ // can go silently stale defeats that purpose the same way the bug it's
37
+ // meant to catch does.
38
+ console.error(`[auth] heartbeat write failed: ${err.message}`);
39
+ }
40
+ return snap;
41
+ }
42
+
43
+ function start() {
44
+ beat();
45
+ timer = setInterval(beat, intervalMs);
46
+ timer.unref?.();
47
+ }
48
+
49
+ function stop() {
50
+ if (timer) clearInterval(timer);
51
+ }
52
+
53
+ return { start, stop, beat, snapshot, file };
54
+ }
55
+
56
+ module.exports = { createHeartbeat };
@@ -142,12 +142,23 @@ function createChannelsToolDispatcher({
142
142
  // session's bubble (review 2026-06-12). Bounded per session (insertion-order
143
143
  // eviction) so a long session can't grow it without limit.
144
144
  const ownedMessageIds = new Map(); // sessionKey → Set<number>
145
+ // The dispatcher is created once per daemon and shared by every CliProcess,
146
+ // so the OUTER map also needs a bound: one entry per sessionKey ever seen,
147
+ // never dropped on session kill/eviction → slow unbounded growth on a
148
+ // long-lived multi-chat bot. Insertion-order eviction; re-touching a key
149
+ // re-inserts it so active sessions stay resident.
150
+ const OWNED_SESSION_CAP = 128;
145
151
  const rememberOwned = (sk, id) => {
146
152
  if (id == null || sk == null) return;
147
153
  const n = Number(id);
148
154
  if (!Number.isFinite(n)) return;
149
155
  let set = ownedMessageIds.get(sk);
150
- if (!set) { set = new Set(); ownedMessageIds.set(sk, set); }
156
+ if (!set) { set = new Set(); }
157
+ else ownedMessageIds.delete(sk); // re-insert → freshest in iteration order
158
+ ownedMessageIds.set(sk, set);
159
+ while (ownedMessageIds.size > OWNED_SESSION_CAP) {
160
+ ownedMessageIds.delete(ownedMessageIds.keys().next().value);
161
+ }
151
162
  set.add(n);
152
163
  while (set.size > OWNED_MSG_CAP) set.delete(set.values().next().value);
153
164
  };
@@ -21,7 +21,7 @@
21
21
  'use strict';
22
22
 
23
23
  const { getTopicConfig } = require('../session-key');
24
- const { pickBackend } = require('../process/factory');
24
+ const { pickBackend } = require('@shumkov/orchestra');
25
25
 
26
26
  function createSdkCallbacks({
27
27
  db,
@@ -102,6 +102,7 @@ function createSdkCallbacks({
102
102
  effort: topicConfig.effort || chatConfig.effort || null,
103
103
  pm_backend: pickBackend({
104
104
  config, chatId: entry.chatId, threadId: entry.threadId || null,
105
+ pmDefault: 'sdk', // polygram's default backend (orchestra defaults to 'cli')
105
106
  }),
106
107
  }), `upsert session ${sessionKey}`);
107
108
  },
@@ -31,7 +31,19 @@ const RULES = [
31
31
  { name: 'github-pat', tier: 'high', re: /\bgithub_pat_[A-Za-z0-9_]{60,}\b/g },
32
32
  { name: 'github-token', tier: 'high', re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
33
33
  { name: 'anthropic', tier: 'high', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
34
- { name: 'openai', tier: 'high', re: /\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{20,}\b/g },
34
+ // OpenAI keys come in two real shapes: sk-proj-<token> (project keys,
35
+ // hyphens/underscores allowed in the body) and legacy sk-<base62, 40+>.
36
+ // The body class must NOT allow hyphens after a bare `sk-` — ordinary
37
+ // hyphenated slugs ("sk-refactor-the-database-layer") would auto-redact
38
+ // at high tier, irreversibly destroying user/bot text. `sk-ant-` and
39
+ // `sk-proj-` can't reach the legacy rule: their 3-4 alnum chars end at
40
+ // a hyphen long before the 40-char floor.
41
+ { name: 'openai', tier: 'high', re: /\bsk-proj-[A-Za-z0-9_-]{20,}\b/g },
42
+ // Review (security + adversarial): sk-proj- isn't the only hyphenated-
43
+ // prefix shape OpenAI issues — service-account and admin keys follow the
44
+ // same pattern and were slipping past undetected after the tighten above.
45
+ { name: 'openai', tier: 'high', re: /\bsk-(?:svcacct|admin)-[A-Za-z0-9_-]{20,}\b/g },
46
+ { name: 'openai', tier: 'high', re: /\bsk-[A-Za-z0-9]{40,}\b/g },
35
47
  { name: 'slack', tier: 'high', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
36
48
  { name: 'stripe', tier: 'high', re: /\bsk_live_[A-Za-z0-9]{20,}\b/g },
37
49
  { name: 'tg-bot-token', tier: 'high', re: /\b\d{8,10}:[A-Za-z0-9_-]{35}\b/g },
@@ -124,6 +124,20 @@ function resolveChunkEarlyReturn(text, limit) {
124
124
  return undefined;
125
125
  }
126
126
 
127
+ // A hard cut at a fixed UTF-16 offset can land between the two code units
128
+ // of a surrogate pair (astral chars: emoji, rare CJK, math symbols) — the
129
+ // pair then renders as U+FFFD on both sides of the boundary. Back the cut
130
+ // off one unit when it would split a pair. Never returns 0 (a degenerate
131
+ // limit of 1 keeps the raw cut rather than looping forever on an empty
132
+ // chunk).
133
+ function surrogateSafeCut(text, idx) {
134
+ if (idx > 1 && idx < text.length) {
135
+ const c = text.charCodeAt(idx - 1);
136
+ if (c >= 0xD800 && c <= 0xDBFF) return idx - 1;
137
+ }
138
+ return idx;
139
+ }
140
+
127
141
  // Generic break-resolver loop shared with markdown variant. The resolver
128
142
  // receives a `window` (text.slice(0, limit)) and returns where to break.
129
143
  // Negative / out-of-range break indices fall back to hard-cut at limit.
@@ -136,7 +150,7 @@ function chunkTextByBreakResolver(text, limit, resolveBreakIndex) {
136
150
  const candidateBreak = resolveBreakIndex(remaining.slice(0, limit));
137
151
  const breakIdx = Number.isFinite(candidateBreak) && candidateBreak > 0 && candidateBreak <= limit
138
152
  ? candidateBreak
139
- : limit;
153
+ : surrogateSafeCut(remaining, limit);
140
154
  const chunk = remaining.slice(0, breakIdx).trimEnd();
141
155
  if (chunk.length > 0) chunks.push(chunk);
142
156
  // If we broke on a separator (whitespace), consume it — don't carry it
@@ -202,7 +216,7 @@ function chunkMarkdownText(text, limit) {
202
216
  while (remaining.length > limit) {
203
217
  const spans = parseFenceSpans(remaining);
204
218
  const softBreak = pickSafeBreakIndex(remaining.slice(0, limit), spans);
205
- let breakIdx = softBreak > 0 ? softBreak : limit;
219
+ let breakIdx = softBreak > 0 ? softBreak : surrogateSafeCut(remaining, limit);
206
220
  const initialFence = isSafeFenceBreak(spans, breakIdx) ? undefined : findFenceSpanAt(spans, breakIdx);
207
221
  let fenceToSplit = initialFence;
208
222
  if (initialFence) {
@@ -215,7 +229,7 @@ function chunkMarkdownText(text, limit) {
215
229
  // Caller will see a malformed chunk, but that's a degenerate
216
230
  // input case (limit smaller than the close marker).
217
231
  fenceToSplit = undefined;
218
- breakIdx = limit;
232
+ breakIdx = surrogateSafeCut(remaining, limit);
219
233
  } else {
220
234
  // Look for a newline inside the fence body that's late enough
221
235
  // to make progress (past the open line + at least one body line)
@@ -243,11 +257,11 @@ function chunkMarkdownText(text, limit) {
243
257
  // No safe in-fence newline found and no room to add one —
244
258
  // give up on splitting this fence; hard-cut at limit.
245
259
  fenceToSplit = undefined;
246
- breakIdx = limit;
260
+ breakIdx = surrogateSafeCut(remaining, limit);
247
261
  } else {
248
262
  // Force the break; chunker will append a synthetic newline
249
263
  // before the close line.
250
- breakIdx = Math.max(minProgressIdx, maxIdxIfNeedNewline);
264
+ breakIdx = surrogateSafeCut(remaining, Math.max(minProgressIdx, maxIdxIfNeedNewline));
251
265
  }
252
266
  }
253
267
  }
@@ -294,7 +308,7 @@ function chunkMarkdownText(text, limit) {
294
308
  // limit (cheap heuristic — much better than mid-word).
295
309
  const window = rest.slice(0, limit);
296
310
  const lastNl = window.lastIndexOf('\n', limit - 1);
297
- const cutAt = lastNl > limit - 200 ? lastNl + 1 : limit;
311
+ const cutAt = lastNl > limit - 200 ? lastNl + 1 : surrogateSafeCut(rest, limit);
298
312
  safe.push(rest.slice(0, cutAt));
299
313
  rest = rest.slice(cutAt);
300
314
  }
@@ -51,7 +51,9 @@
51
51
  * @param {object} opts.bot — grammy bot instance
52
52
  * @param {Function} opts.tg — (bot, method, params, meta) → Promise<res>
53
53
  * @param {string|number} opts.chatId
54
- * @param {?number} opts.threadId — Telegram message_thread_id (or null)
54
+ * @param {?(number|string)} opts.threadId — Telegram message_thread_id (or null).
55
+ * Treated as opaque, deliver.js-style: the cli backend passes a STRING, so an
56
+ * integer-only guard would drop the id and send stickers to the General topic.
55
57
  * @param {?number} opts.replyToMessageId — inbound msg_id this reply targets; null for unsolicited
56
58
  * @param {boolean} [opts.applyReactions] — apply [react:EMOJI] tags to replyToMessageId. False for
57
59
  * unsolicited (autonomous-wakeup) where there IS no target;
@@ -173,7 +175,7 @@ async function processAndDeliverAgentText({
173
175
  await tg(bot, 'sendSticker', {
174
176
  chat_id: chatId,
175
177
  sticker: parsed.sticker,
176
- ...(Number.isInteger(threadId) && { message_thread_id: threadId }),
178
+ ...(threadId != null && { message_thread_id: threadId }),
177
179
  }, {
178
180
  ...meta,
179
181
  source: `${source}-sticker`,
@@ -192,7 +194,7 @@ async function processAndDeliverAgentText({
192
194
  await tg(bot, 'sendSticker', {
193
195
  chat_id: chatId,
194
196
  sticker: s.fileId,
195
- ...(Number.isInteger(threadId) && { message_thread_id: threadId }),
197
+ ...(threadId != null && { message_thread_id: threadId }),
196
198
  }, {
197
199
  ...meta,
198
200
  source: `${source}-inline-sticker`,
@@ -121,7 +121,12 @@ function createStreamer({
121
121
  // which preserves all content without any byte-cut.
122
122
  function truncateForLive(s) {
123
123
  if (s.length <= maxLen) return s;
124
- return s.slice(0, maxLen - 3) + '...';
124
+ // Back off one unit if the cut would split a surrogate pair (emoji /
125
+ // astral chars) — a lone high surrogate renders as � in the live bubble.
126
+ let cut = maxLen - 3;
127
+ const cc = s.charCodeAt(cut - 1);
128
+ if (cc >= 0xD800 && cc <= 0xDBFF) cut -= 1;
129
+ return s.slice(0, cut) + '...';
125
130
  }
126
131
 
127
132
  // rc.67: scrub recognised inline tags BEFORE the streamer commits text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.17.11",
3
+ "version": "0.17.12",
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,6 +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.0",
66
67
  "better-sqlite3": "^12.9.0",
67
68
  "grammy": "^1.42.0",
68
69
  "markdown-it": "^14.1.1",