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
package/polygram.js CHANGED
@@ -22,7 +22,7 @@
22
22
  const { Bot } = require('grammy');
23
23
  const fs = require('fs');
24
24
  const path = require('path');
25
- const processGuard = require('./lib/process-guard');
25
+ const processGuard = require('@shumkov/orchestra').processGuard;
26
26
  const dbClient = require('./lib/db');
27
27
  const {
28
28
  migrateJsonToDb, getClaudeSessionId, resolveSessionForSpawn,
@@ -39,9 +39,10 @@ const { filterAttachments, resolveFileCaps, resolveMaxFileOverride, MAX_TOTAL_BY
39
39
  // Process subclasses (SdkProcess now, TmuxProcess in Phase 2) provide
40
40
  // per-session mechanics. The pre-0.10.0 monolithic ProcessManagerSdk
41
41
  // is deleted; SdkProcess inherits its per-entry guts.
42
- const { ProcessManager } = require('./lib/process-manager');
43
- const { createProcessFactory, pickBackend } = require('./lib/process/factory');
44
- const { extractAssistantText } = require('./lib/process/sdk-process');
42
+ const { ProcessManager } = require('@shumkov/orchestra');
43
+ const { createProcessFactory, pickBackend } = require('@shumkov/orchestra');
44
+ const { checkClaudeAuthHealth } = require('@shumkov/orchestra').claudeBin;
45
+ const { extractAssistantText } = require('@shumkov/orchestra');
45
46
  // 0.11.0: channels backend tool dispatcher — adapts CliProcess's reply
46
47
  // tool callback into polygram's existing chunkText + deliverReplies primitives.
47
48
  // ADV-14: chunkMarkdownText (fence-aware) is imported once below (~line 88)
@@ -49,8 +50,8 @@ const { extractAssistantText } = require('./lib/process/sdk-process');
49
50
  // containing code blocks or HTML-style tags aren't split mid-element by the
50
51
  // size cap.
51
52
  const { createChannelsToolDispatcher } = require('./lib/process/channels-tool-dispatcher');
52
- const { createTmuxRunner } = require('./lib/tmux/tmux-runner');
53
- const { sweepTmuxOrphans } = require('./lib/tmux/orphan-sweep');
53
+ const { createTmuxRunner } = require('@shumkov/orchestra');
54
+ const { sweepTmuxOrphans } = require('@shumkov/orchestra').orphanSweep;
54
55
  // rc.42: autosteer-buffer module deleted. Native SDK priority push
55
56
  // (pm.injectUserMessage) replaces the buffer + PostToolBatch detour.
56
57
  const { createAutosteeredRefs } = require('./lib/autosteered-refs');
@@ -68,6 +69,7 @@ const { createAutosteerHandlers } = require('./lib/handlers/autosteer');
68
69
  const { createEditCorrectionInjector } = require('./lib/handlers/edit-correction');
69
70
  const { createEditRedelivery } = require('./lib/handlers/edit-redelivery');
70
71
  const { createGateInbound, ADMIN_CMD_RE, PAIR_CLAIM_RE } = require('./lib/handlers/gate-inbound');
72
+ const { createShouldHandle } = require('./lib/handlers/should-handle');
71
73
  const { createRedeliver } = require('./lib/handlers/redeliver');
72
74
  const { classifyReplay, executeReplayPlan } = require('./lib/handlers/replay-disposition');
73
75
  const { createDropRedeliverer } = require('./lib/handlers/drop-redeliver');
@@ -87,7 +89,7 @@ const { formatContextReply, maybeContextFullHint } = require('./lib/context-form
87
89
  const { createAbortGrace } = require('./lib/abort-grace');
88
90
  const agentLoader = require('./lib/agents/loader');
89
91
  const { createSender } = require('./lib/telegram/api');
90
- const { createAsyncLock } = require('./lib/async-lock');
92
+ const { createAsyncLock } = require('@shumkov/orchestra');
91
93
  const { sweepInbox } = require('./lib/db/inbox');
92
94
  const { parseBotArg, parseDbArg, filterConfigToBot, activeBotConfig } = require('./lib/config-scope');
93
95
  const { createStore: createPairingsStore, parseTtl: parsePairingTtl } = require('./lib/db/pairings');
@@ -110,6 +112,8 @@ const { createMediaGroupBuffer } = require('./lib/media-group-buffer');
110
112
  const { applyReactionToMessages } = require('./lib/telegram/album-reactions');
111
113
  const { classify: classifyError, classifyTurnEndError, detectWedgedSessionError, isTransientHttpError } = require('./lib/error/classify');
112
114
  const { createAutoResumeTracker, isAutoResumable } = require('./lib/db/auto-resume');
115
+ const { createAuthDisabledGate } = require('./lib/ops/auth-disabled-gate');
116
+ const { createHeartbeat } = require('./lib/ops/heartbeat');
113
117
  const { resolveReplayWindowMs } = require('./lib/db/replay-window');
114
118
  const { pruneEvents, resolveRetentionPolicy, validatePolicy } = require('./lib/db/events-retention');
115
119
  const { sweepSecrets, resolveSecretSweepConfig } = require('./lib/db/secret-sweep');
@@ -169,6 +173,8 @@ let ipcCloser = null;
169
173
  // single-valued), we keep them as plain module-level variables — not a map.
170
174
  let BOT_NAME = null; // string, frozen after boot
171
175
  let bot = null; // grammy Bot for BOT_NAME
176
+ let mediaBuffer = null; // media-group coalescing buffer, created in createBot;
177
+ // module-level so shutdown can replay-mark buffered albums
172
178
  // 0.4.8 note: streamer + reactor are per-turn, not per-session. They live
173
179
  // on the pending's `context` object in the pm pendingQueue, keyed to the
174
180
  // specific turn (not the session). The old per-session Maps were a bug
@@ -473,7 +479,7 @@ function buildSpawnContext(sessionKey) {
473
479
  const resolved = {
474
480
  agent: topicConfig.agent || chatConfig.agent || null,
475
481
  cwd: topicConfig.cwd || chatConfig.cwd || null,
476
- backend: pickBackend({ config, chatId, threadId: threadId || null }),
482
+ backend: pickBackend({ config, chatId, threadId: threadId || null, pmDefault: 'sdk' }),
477
483
  };
478
484
  const r = resolveSessionForSpawn(db, sessionKey, resolved);
479
485
  existingSessionId = r.existingSessionId;
@@ -502,7 +508,15 @@ function buildSpawnContext(sessionKey) {
502
508
  // the inbound filter and the send() choke point use, so CliProcess's
503
509
  // pre-check + system-prompt line can't drift from actual enforcement.
504
510
  localApi: !!config.bot?.apiRoot,
505
- outboundCapOverride: resolveMaxFileOverride(config, chatId, threadId || null),
511
+ // Pre-resolve through the SAME backend-aware resolver origin's CliProcess used, so
512
+ // orchestra (which now uses opts.outboundCapOverride verbatim, without its own
513
+ // resolveFileCaps clamp) gets the backend default (2GB local / 50MB cloud) AND the
514
+ // ceiling clamp — not a flat 100MB. resolveFileCaps always returns a number, so this
515
+ // is always non-null and orchestra's injected fallback never fires.
516
+ outboundCapOverride: resolveFileCaps({
517
+ localApi: !!config.bot?.apiRoot,
518
+ override: resolveMaxFileOverride(config, chatId, threadId || null),
519
+ }).outBytes,
506
520
  };
507
521
  }
508
522
 
@@ -595,6 +609,14 @@ let errorReplyText = null;
595
609
  let queueWarnThreshold = null;
596
610
  let inFlightHandlers = null;
597
611
 
612
+ // AUTH_DISABLED (docs/AUTH_DISABLED_HANDLING_SPEC.md): dedupe/re-arm gate for
613
+ // the operator notification (dispatcher.js) + Netdata-visibility heartbeat
614
+ // counter, both process-scoped like autoResumeTracker/contextHintShown above.
615
+ // heartbeat.start() is called in main() once DATA_DIR is settled; the gate
616
+ // needs no runtime config, so it's constructed here directly.
617
+ const authDisabledGate = createAuthDisabledGate();
618
+ const authDisabledHeartbeat = createHeartbeat({ dataDir: DATA_DIR, authDisabledGate });
619
+
598
620
  // rc.59: once-per-cycle gate for the contextHint. A session is added
599
621
  // to this Set when the hint fires, removed when the SDK emits
600
622
  // compact_boundary (so the next cycle can fire its own hint). Without
@@ -811,6 +833,37 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
811
833
  cmdUser, cmdUserId, label, sendReply,
812
834
  })) return;
813
835
 
836
+ // Claude-auth gate: if the CLI login has expired, every claude turn wedges
837
+ // INVISIBLY — the 401 fires inside the subprocess, never reaches the
838
+ // classifier, and the turn degrades to a silent "⏱ went quiet" across every
839
+ // topic. Refuse up-front with a clear message + a loud log instead of
840
+ // spawning a doomed turn. Slash commands above don't need claude, so they
841
+ // still work. Free check (reads the credentials file, no API call). Guarded
842
+ // (fail-open to 'unknown' on throw): this sits on every turn's hot path, so
843
+ // a broken health check must never itself become the silent wedge this gate
844
+ // exists to catch. Applies to replays/redeliveries too (boot-replay,
845
+ // drop-redeliver, edit-redelivery) — the check is a cheap stateless file
846
+ // read, and a token can expire between an original dispatch and its later
847
+ // redelivery, so skipping it there would silently re-wedge exactly the
848
+ // messages most likely to hit an expired-auth window (post-restart backlog).
849
+ let auth;
850
+ try {
851
+ auth = checkClaudeAuthHealth();
852
+ } catch (err) {
853
+ console.error(`[auth] health check failed: ${err.message}`);
854
+ auth = { state: 'unknown' };
855
+ }
856
+ if (auth.state === 'expired') {
857
+ const expIso = new Date(auth.refreshTokenExpiresAt).toISOString();
858
+ console.error(`[auth] Claude login EXPIRED (refresh token ${expIso}) — refusing turn for ${label}; re-login on the host.`);
859
+ logEvent('auth-expired', {
860
+ session_key: sessionKey, chat_id: chatId, source: 'dispatch-gate',
861
+ refresh_token_expires_at: auth.refreshTokenExpiresAt,
862
+ });
863
+ await sendReply('🔑 Claude login has expired and needs to be re-authenticated. Messages can\'t be processed until the login is refreshed on the host.');
864
+ return;
865
+ }
866
+
814
867
  const t0 = Date.now();
815
868
 
816
869
  const sessionCtx = !pm.has(sessionKey) ? await readSessionContext(sessionKey, chatConfig.cwd) : '';
@@ -1307,6 +1360,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1307
1360
  const chatCtxHint = chatConfig.contextHint != null
1308
1361
  ? chatConfig.contextHint
1309
1362
  : config.bot?.contextHint;
1363
+ // AUTH_DISABLED re-arm (docs/AUTH_DISABLED_HANDLING_SPEC.md): only a
1364
+ // genuine, non-error turn result counts as "recovered" — slash
1365
+ // commands and other early-returns in handleMessage never reach this
1366
+ // branch, so they can't falsely clear an ongoing outage and cause the
1367
+ // operator to be re-paged before it's actually fixed.
1368
+ try { authDisabledGate.noteSuccess(); } catch (e) { console.error(`[auth] authDisabledGate.noteSuccess failed: ${e.message}`); }
1310
1369
  // rc.59: gate the hint to once-per-cycle. Pre-rc.59 the hint
1311
1370
  // fired on EVERY turn that landed over threshold — so a user
1312
1371
  // saw "📚 70% full…" then "71% full…" then "72% full…"
@@ -1753,34 +1812,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1753
1812
 
1754
1813
  // ─── Bot setup ──────────────────────────────────────────────────────
1755
1814
 
1756
- function shouldHandle(msg, chatConfig, botUsername) {
1757
- const hasAttachment = !!(msg.document || msg.photo || msg.voice || msg.audio || msg.video);
1758
- if (!msg.text && !msg.caption && !hasAttachment) return false;
1759
- const chatId = msg.chat.id.toString();
1760
- if (!config.chats[chatId]) return false;
1761
-
1762
- if (chatConfig.requireMention && msg.chat.type !== 'private') {
1763
- const text = msg.text || msg.caption || '';
1764
- const isReplyToBot = msg.reply_to_message?.from?.username === botUsername;
1765
- const hasMention = text.includes(`@${botUsername}`);
1766
- // A reply targeting some other user (not the bot) is a strong signal
1767
- // "this message is for that person, not me". Paired users normally
1768
- // bypass requireMention, but not in this case — without the guard a
1769
- // paired user saying "Gotcha!" to a teammate gets processed by the
1770
- // bot just because the user is paired, which is what bit us in
1771
- // UMI Group on 0.5.9 (bot leaked reasoning as a reply to "Gotcha!").
1772
- const repliesToOtherUser = !!msg.reply_to_message
1773
- && msg.reply_to_message.from?.username !== botUsername;
1774
- // Paired users bypass requireMention — operator-trusted, no @ needed
1775
- // every time. Skipped when they're replying to a non-bot user (above).
1776
- const paired = !repliesToOtherUser && pairings && msg.from?.id
1777
- ? pairings.hasLivePairing({ bot_name: BOT_NAME, user_id: msg.from.id, chat_id: chatId })
1778
- : false;
1779
- if (!isReplyToBot && !hasMention && !paired) return false;
1780
- }
1781
-
1782
- return true;
1783
- }
1815
+ const shouldHandle = createShouldHandle({
1816
+ getConfig: () => config,
1817
+ getPairings: () => pairings,
1818
+ getBotName: () => BOT_NAME,
1819
+ });
1784
1820
 
1785
1821
  function createBot(token) {
1786
1822
  // Optional self-hosted Telegram Bot API server. When config.bot.apiRoot is
@@ -1945,7 +1981,7 @@ function createBot(token) {
1945
1981
  // a single synthetic turn with all attachments merged. Timer resets on
1946
1982
  // every new sibling, so as long as messages arrive faster than the
1947
1983
  // DEFAULT_FLUSH_MS window apart they stay in the same bundle.
1948
- const mediaBuffer = createMediaGroupBuffer({
1984
+ mediaBuffer = createMediaGroupBuffer({
1949
1985
  onFlush: (messages) => {
1950
1986
  if (!messages || messages.length === 0) return;
1951
1987
  // Primary = the (usually first) message with text/caption; that's
@@ -2224,7 +2260,14 @@ async function main() {
2224
2260
  // TmuxProcess.start() hits EEXIST on session spawn for any chat
2225
2261
  // routed to pm:'tmux'. See lib/tmux/orphan-sweep.js for rationale.
2226
2262
  try {
2227
- const sweep = await sweepTmuxOrphans({ botName: BOT_NAME, logger: console });
2263
+ // Sweep must look for polygram's session prefix orchestra's default runner
2264
+ // filters 'orchestra-<bot>-', which would never match our 'polygram-<bot>-'
2265
+ // sessions (dead safety net + EEXIST on cli respawn of a leaked session).
2266
+ const sweep = await sweepTmuxOrphans({
2267
+ botName: BOT_NAME,
2268
+ runner: createTmuxRunner({ logger: console, sessionPrefix: 'polygram' }),
2269
+ logger: console,
2270
+ });
2228
2271
  if (sweep.swept.length > 0) {
2229
2272
  console.log(`[orphan-sweep] killed ${sweep.swept.length} stale tmux session(s)`);
2230
2273
  }
@@ -2324,6 +2367,38 @@ async function main() {
2324
2367
  setInterval(() => runSecretSweep('interval'), secretSweepCfg.intervalMs).unref?.();
2325
2368
  }
2326
2369
 
2370
+ // Claude-auth health monitor — a FREE credentials-file check (no API call, no
2371
+ // model spawn). An expired refresh token makes the CLI 401 silently and wedges
2372
+ // every channels turn; the handleMessage gate refuses turns + tells the user,
2373
+ // and this surfaces the same state loudly in the operator logs at boot + every
2374
+ // 30 min, warning a few days ahead so re-login happens before it breaks.
2375
+ const runAuthCheck = (trigger) => {
2376
+ try {
2377
+ const auth = checkClaudeAuthHealth();
2378
+ const expIso = auth.refreshTokenExpiresAt ? new Date(auth.refreshTokenExpiresAt).toISOString() : 'n/a';
2379
+ if (auth.state === 'expired') {
2380
+ console.error(`[auth] (${trigger}) Claude login EXPIRED (refresh token ${expIso}) — turns are being refused; re-login on the host.`);
2381
+ logEvent('auth-expired', { source: 'monitor', trigger, refresh_token_expires_at: auth.refreshTokenExpiresAt });
2382
+ } else if (auth.state === 'expiring') {
2383
+ console.warn(`[auth] (${trigger}) Claude login expires in ${auth.daysLeft}d (${expIso}) — re-login soon.`);
2384
+ logEvent('auth-expiring', { source: 'monitor', trigger, days_left: auth.daysLeft, refresh_token_expires_at: auth.refreshTokenExpiresAt });
2385
+ } else if (auth.state === 'unknown') {
2386
+ console.warn(`[auth] (${trigger}) Claude credentials check inconclusive: ${auth.reason}`);
2387
+ }
2388
+ } catch (err) {
2389
+ console.error(`[auth] health check failed (${trigger}): ${err.message}`);
2390
+ }
2391
+ };
2392
+ runAuthCheck('boot');
2393
+ setInterval(() => runAuthCheck('interval'), 30 * 60_000).unref?.();
2394
+
2395
+ // AUTH_DISABLED Netdata-visibility heartbeat (docs/AUTH_DISABLED_HANDLING_SPEC.md,
2396
+ // Layer 3.3) — writes <DATA_DIR>/heartbeat.json every 60s with the
2397
+ // authDisabledGate's occurrence counter. File-only (no HTTP server in this
2398
+ // repo to hang a /healthz route on); wiring the file into an actual Netdata
2399
+ // alert is a separate VPS-side ops change, out of scope here.
2400
+ authDisabledHeartbeat.start();
2401
+
2327
2402
  // 0.8.0 Phase 1 step 11 + rc.50: defensive uncaughtException +
2328
2403
  // unhandledRejection handlers. The new pm wraps every Query
2329
2404
  // iteration in try/catch so SDK throws never leak — but if a
@@ -2482,7 +2557,7 @@ async function main() {
2482
2557
  // tmux backend runner — one per daemon, shared across all TmuxProcess
2483
2558
  // instances. Construction is cheap (no system call until first
2484
2559
  // spawn/send). Only used if any chat in config has pm:'tmux'.
2485
- const tmuxRunner = createTmuxRunner({ logger: console });
2560
+ const tmuxRunner = createTmuxRunner({ logger: console, sessionPrefix: 'polygram' });
2486
2561
  // Verify the pinned claude CLI binary is present. The tmux
2487
2562
  // backend spawns this exact binary by absolute path (see
2488
2563
  // lib/claude-bin.js + TmuxProcess.start) — it never resolves
@@ -2498,7 +2573,13 @@ async function main() {
2498
2573
  // auto-pruner (keeps only ~3 newest, deletes the rest) can't take cli chats
2499
2574
  // down. Spawns from ~/.local/share/polygram/claude-bin/<version>, immune to
2500
2575
  // pruning. Self-heals on boot (copy from the system install, else install).
2501
- const { CLAUDE_CLI_PINNED_VERSION, ensureVendoredClaudeBin } = require('./lib/claude-bin');
2576
+ // orchestra's claude-bin defaults its vendor dir to ~/.local/share/orchestra;
2577
+ // point it at polygram's EXISTING vendored dir so boot reuses the pinned binary
2578
+ // that's already there (no 223 MB re-vendor / `claude install` network call).
2579
+ if (!process.env.ORCHESTRA_CLAUDE_VENDOR_DIR) {
2580
+ process.env.ORCHESTRA_CLAUDE_VENDOR_DIR = require('node:path').join(require('node:os').homedir(), '.local', 'share', 'polygram', 'claude-bin');
2581
+ }
2582
+ const { CLAUDE_CLI_PINNED_VERSION, ensureVendoredClaudeBin } = require('@shumkov/orchestra').claudeBin;
2502
2583
  const binCheck = ensureVendoredClaudeBin(CLAUDE_CLI_PINNED_VERSION, { logger: console });
2503
2584
  if (binCheck.ok) {
2504
2585
  console.log(
@@ -2542,6 +2623,23 @@ async function main() {
2542
2623
  // channels backend
2543
2624
  toolDispatcher: channelsToolDispatcher,
2544
2625
  channelsClaudeBin,
2626
+ // orchestra identity — polygram's names so behavior is byte-identical to the
2627
+ // copied engine. bridge/tmux/hook/attachment prefixes, product/surface prose,
2628
+ // the SDK backend (injected), and the default backend all match polygram's.
2629
+ sessionPrefix: 'polygram',
2630
+ bridgeServerName: 'polygram-bridge',
2631
+ appDataDir: require('node:path').join(require('node:os').homedir(), '.polygram'),
2632
+ attachmentBase: require('node:path').join(require('node:os').tmpdir(), 'polygram-attachments'),
2633
+ productName: 'polygram',
2634
+ surfaceName: 'Telegram',
2635
+ pmDefault: 'sdk',
2636
+ // The CLI append-system-prompt's FIRST block — origin hard-required this into every
2637
+ // CliProcess. Without it, cli chats lose the Telegram table/markdown display rules.
2638
+ displayHint: require('./lib/telegram/display-hint').POLYGRAM_DISPLAY_HINT,
2639
+ // Backend-default outbound cap fallback (per-spawn buildSpawnContext override
2640
+ // normally supersedes this; kept so any context-less spawn still gets the backend
2641
+ // default rather than orchestra's neutral 100MB).
2642
+ maxOutboundFileBytes: resolveFileCaps({ localApi: !!config.bot?.apiRoot }).outBytes,
2545
2643
  });
2546
2644
  // Route in-process approval prompts through the SAME canUseTool plumbing
2547
2645
  // that SDK chats use:
@@ -2633,6 +2731,7 @@ async function main() {
2633
2731
  chunkMarkdownText, deliverReplies,
2634
2732
  chunkBudget: TG_CHUNK_BUDGET,
2635
2733
  getIsShuttingDown: () => isShuttingDown,
2734
+ authDisabledGate,
2636
2735
  logger: console,
2637
2736
  }));
2638
2737
  // 0.12.0 post-turn edit re-delivery: constructed AFTER dispatchHandleMessage is assigned (above).
@@ -2737,6 +2836,53 @@ async function main() {
2737
2836
  console.error(`[shutdown] question expiry failed: ${err.message}`);
2738
2837
  }
2739
2838
 
2839
+ // 1.6 Album siblings still buffered (arrived <flushMs before shutdown,
2840
+ // never dispatched) sit at handler_status NULL — invisible to both
2841
+ // the drain below (not in inFlightHandlers) and recordCleanShutdown
2842
+ // (only re-marks dispatched/processing). Mark them replay-pending so
2843
+ // boot treats them like any interrupted turn: crash → recovered,
2844
+ // deliberate restart → the visible skip notice.
2845
+ //
2846
+ // takeAllForShutdown() (not peekAll()) atomically cancels each
2847
+ // group's pending flush timer as it reads it (review: adversarial)
2848
+ // — a live timer firing during the drain below could otherwise
2849
+ // self-flush a group already marked replay-pending here, double-
2850
+ // processing it. Each group is coalesced onto ONE primary row,
2851
+ // mirroring onFlush's own primary-selection + attachment-reassign
2852
+ // logic above (review: correctness) — marking every sibling
2853
+ // individually meant a crash recovered an album as N fragmented
2854
+ // single-photo turns instead of the one multi-photo turn the live
2855
+ // path guarantees.
2856
+ try {
2857
+ const groups = mediaBuffer?.takeAllForShutdown() || [];
2858
+ let markedCount = 0;
2859
+ for (const { messages } of groups) {
2860
+ if (!messages || messages.length === 0) continue;
2861
+ const primary = messages.find((m) => m.text || m.caption) || messages[0];
2862
+ const chatId = String(primary.chat.id);
2863
+ const siblingMsgIds = messages
2864
+ .filter((m) => m.message_id !== primary.message_id)
2865
+ .map((m) => m.message_id);
2866
+ if (siblingMsgIds.length) {
2867
+ const primaryDbId = db.getInboundMessageId({ chat_id: chatId, msg_id: primary.message_id });
2868
+ if (primaryDbId) {
2869
+ dbWrite(() => db.reassignAttachmentsToMessage({
2870
+ chat_id: chatId, msg_ids: siblingMsgIds, target_message_id: primaryDbId,
2871
+ }), 'shutdown-media-buffer-reassign');
2872
+ }
2873
+ }
2874
+ dbWrite(() => db.setInboundHandlerStatus({
2875
+ chat_id: chatId, msg_id: primary.message_id, status: 'replay-pending',
2876
+ }), 'shutdown-media-buffer');
2877
+ markedCount += 1;
2878
+ }
2879
+ if (markedCount) {
2880
+ logEvent('shutdown-media-buffer-marked', { count: markedCount });
2881
+ }
2882
+ } catch (err) {
2883
+ console.error(`[shutdown] media-buffer replay-mark failed: ${err.message}`);
2884
+ }
2885
+
2740
2886
  // 2. Drain in-flight handlers. Wait for inFlightHandlers to empty or
2741
2887
  // SHUTDOWN_DRAIN_MS to elapse. pm handlers resolve naturally when
2742
2888
  // result events arrive; the dispatcher's .finally decrements.
@@ -2777,6 +2923,7 @@ async function main() {
2777
2923
  // 4. Remaining shutdown: approvals sweeper, IPC, resolve hook waiters,
2778
2924
  // kill pm subprocesses, close DB.
2779
2925
  if (approvalSweepTimer) clearInterval(approvalSweepTimer);
2926
+ authDisabledHeartbeat.stop();
2780
2927
  if (ipcCloser) ipcCloser.close().catch(() => {});
2781
2928
  try { fs.unlinkSync(ipcServer.secretPathFor(BOT_NAME)); } catch {}
2782
2929
  // Reject every parked canUseTool waiter so the SDK doesn't
package/lib/async-lock.js DELETED
@@ -1,49 +0,0 @@
1
- /**
2
- * Per-key chain lock. Each acquire() returns a release function; the next
3
- * acquire() awaits the previous one's release.
4
- *
5
- * Used by polygram to serialise stdin writes per session. Pre-work
6
- * (attachment download, voice transcription, prompt formatting) runs
7
- * concurrently; only the stdin write itself is serialised so Claude
8
- * reads messages in arrival order and replies come out in the same
9
- * order.
10
- *
11
- * Deliberately minimal — no timeouts, no cancellation, no fairness
12
- * guarantees beyond FIFO. Callers are expected to ALWAYS call release,
13
- * even on error paths, or the lock leaks (blocks all future acquires
14
- * for that key forever).
15
- */
16
-
17
- function createAsyncLock() {
18
- const chains = new Map(); // key → Promise of last release
19
-
20
- return {
21
- async acquire(key) {
22
- const prev = chains.get(key) || Promise.resolve();
23
- let release;
24
- const next = new Promise((resolve) => { release = resolve; });
25
- // Save the chain-entry promise so the cleanup branch can compare
26
- // against the SAME reference. Pre-fix this re-evaluated
27
- // `prev.then(() => next)` (a fresh promise each call), so the
28
- // === compare was always false and the Map leaked one entry per
29
- // unique key.
30
- const myEntry = prev.then(() => next);
31
- chains.set(key, myEntry);
32
- await prev;
33
- // Return a wrapper that also clears the chain entry when this is
34
- // the last holder — avoids the Map growing unbounded across the
35
- // lifetime of the process. Idempotent: a double-release call is
36
- // harmless (release() is a Promise resolver; calling resolve
37
- // twice is a no-op).
38
- return () => {
39
- if (chains.get(key) === myEntry) {
40
- chains.delete(key);
41
- }
42
- release();
43
- };
44
- },
45
- get size() { return chains.size; },
46
- };
47
- }
48
-
49
- module.exports = { createAsyncLock };