polygram 0.17.10 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/config.js +21 -0
- package/lib/error/classify.js +14 -0
- package/lib/handlers/dispatcher.js +54 -1
- package/lib/handlers/drop-redeliver.js +19 -0
- package/lib/handlers/should-handle.js +52 -0
- package/lib/media-group-buffer.js +29 -0
- package/lib/ops/auth-disabled-gate.js +43 -0
- package/lib/ops/heartbeat.js +56 -0
- package/lib/process/channels-tool-dispatcher.js +12 -1
- package/lib/prompt.js +22 -7
- package/lib/sdk/callbacks.js +2 -1
- package/lib/secret-detect.js +13 -1
- package/lib/telegram/chunk.js +20 -6
- package/lib/telegram/process-agent-reply.js +5 -3
- package/lib/telegram/streamer.js +6 -1
- package/package.json +2 -1
- package/polygram.js +202 -45
- package/lib/async-lock.js +0 -49
- package/lib/claude-bin.js +0 -246
- package/lib/compaction-warn.js +0 -59
- package/lib/context-usage.js +0 -93
- package/lib/process/channels-bridge-protocol.js +0 -199
- package/lib/process/channels-bridge-server.js +0 -274
- package/lib/process/channels-bridge.mjs +0 -477
- package/lib/process/cli-process.js +0 -4029
- package/lib/process/factory.js +0 -215
- package/lib/process/hook-event-tail.js +0 -162
- package/lib/process/hook-settings.js +0 -181
- package/lib/process/polygram-hook-append.js +0 -71
- package/lib/process/process.js +0 -215
- package/lib/process/sdk-process.js +0 -880
- package/lib/process-guard.js +0 -296
- package/lib/process-manager.js +0 -628
- package/lib/tmux/log-tail.js +0 -334
- package/lib/tmux/orphan-sweep.js +0 -79
- package/lib/tmux/poll-scheduler.js +0 -110
- package/lib/tmux/startup-gate.js +0 -250
- 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('
|
|
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('
|
|
43
|
-
const { createProcessFactory, pickBackend } = require('
|
|
44
|
-
const {
|
|
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('
|
|
53
|
-
const { sweepTmuxOrphans } = require('
|
|
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('
|
|
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');
|
|
@@ -137,8 +141,6 @@ const DB_DIR = DATA_DIR;
|
|
|
137
141
|
// DB_PATH is resolved in main() from --db or <bot>.db default.
|
|
138
142
|
let DB_PATH = null;
|
|
139
143
|
let PID_PATH = null; // rc.50: orphan-detection PID file
|
|
140
|
-
const STICKERS_PATH = process.env.POLYGRAM_STICKERS
|
|
141
|
-
|| path.join(DATA_DIR, 'stickers.json');
|
|
142
144
|
const INBOX_DIR = process.env.POLYGRAM_INBOX || path.join(DATA_DIR, 'inbox');
|
|
143
145
|
const CLAUDE_BIN = process.env.POLYGRAM_CLAUDE_BIN
|
|
144
146
|
|| path.join(process.env.HOME || '', '.npm-global/bin/claude');
|
|
@@ -171,6 +173,8 @@ let ipcCloser = null;
|
|
|
171
173
|
// single-valued), we keep them as plain module-level variables — not a map.
|
|
172
174
|
let BOT_NAME = null; // string, frozen after boot
|
|
173
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
|
|
174
178
|
// 0.4.8 note: streamer + reactor are per-turn, not per-session. They live
|
|
175
179
|
// on the pending's `context` object in the pm pendingQueue, keyed to the
|
|
176
180
|
// specific turn (not the session). The old per-session Maps were a bug
|
|
@@ -195,7 +199,15 @@ function saveConfig() {
|
|
|
195
199
|
configIO.saveConfig({ configPath: CONFIG_PATH, botName: BOT_NAME, config });
|
|
196
200
|
}
|
|
197
201
|
function loadStickers() {
|
|
198
|
-
|
|
202
|
+
// Per-bot sticker set: config.bots.<bot>.stickersPath wins, else POLYGRAM_STICKERS,
|
|
203
|
+
// else the shared <DATA_DIR>/stickers.json. Resolved from config.bot, so this must
|
|
204
|
+
// run AFTER activeBotConfig() populates it.
|
|
205
|
+
const stickersPath = configIO.resolveStickersPath({
|
|
206
|
+
botConfig: config && config.bot,
|
|
207
|
+
dataDir: DATA_DIR,
|
|
208
|
+
envPath: process.env.POLYGRAM_STICKERS || null,
|
|
209
|
+
});
|
|
210
|
+
const { stickerMap: m, emojiToSticker: e } = configIO.loadStickers(stickersPath);
|
|
199
211
|
Object.assign(stickerMap, m);
|
|
200
212
|
Object.assign(emojiToSticker, e);
|
|
201
213
|
}
|
|
@@ -385,6 +397,9 @@ function formatPrompt(msg, sessionCtx, attachments = [], { sessionKey = null } =
|
|
|
385
397
|
attachments,
|
|
386
398
|
replyTo: resolveReplyTo(msg),
|
|
387
399
|
polygramHistory,
|
|
400
|
+
// Only advertise stickers this bot actually has — empty for a bot with no
|
|
401
|
+
// sticker set, so its prompt never mentions stickers.
|
|
402
|
+
stickerEmojis: Object.keys(emojiToSticker),
|
|
388
403
|
});
|
|
389
404
|
}
|
|
390
405
|
|
|
@@ -464,7 +479,7 @@ function buildSpawnContext(sessionKey) {
|
|
|
464
479
|
const resolved = {
|
|
465
480
|
agent: topicConfig.agent || chatConfig.agent || null,
|
|
466
481
|
cwd: topicConfig.cwd || chatConfig.cwd || null,
|
|
467
|
-
backend: pickBackend({ config, chatId, threadId: threadId || null }),
|
|
482
|
+
backend: pickBackend({ config, chatId, threadId: threadId || null, pmDefault: 'sdk' }),
|
|
468
483
|
};
|
|
469
484
|
const r = resolveSessionForSpawn(db, sessionKey, resolved);
|
|
470
485
|
existingSessionId = r.existingSessionId;
|
|
@@ -493,7 +508,15 @@ function buildSpawnContext(sessionKey) {
|
|
|
493
508
|
// the inbound filter and the send() choke point use, so CliProcess's
|
|
494
509
|
// pre-check + system-prompt line can't drift from actual enforcement.
|
|
495
510
|
localApi: !!config.bot?.apiRoot,
|
|
496
|
-
|
|
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,
|
|
497
520
|
};
|
|
498
521
|
}
|
|
499
522
|
|
|
@@ -586,6 +609,14 @@ let errorReplyText = null;
|
|
|
586
609
|
let queueWarnThreshold = null;
|
|
587
610
|
let inFlightHandlers = null;
|
|
588
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
|
+
|
|
589
620
|
// rc.59: once-per-cycle gate for the contextHint. A session is added
|
|
590
621
|
// to this Set when the hint fires, removed when the SDK emits
|
|
591
622
|
// compact_boundary (so the next cycle can fire its own hint). Without
|
|
@@ -802,6 +833,37 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
802
833
|
cmdUser, cmdUserId, label, sendReply,
|
|
803
834
|
})) return;
|
|
804
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
|
+
|
|
805
867
|
const t0 = Date.now();
|
|
806
868
|
|
|
807
869
|
const sessionCtx = !pm.has(sessionKey) ? await readSessionContext(sessionKey, chatConfig.cwd) : '';
|
|
@@ -1298,6 +1360,12 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1298
1360
|
const chatCtxHint = chatConfig.contextHint != null
|
|
1299
1361
|
? chatConfig.contextHint
|
|
1300
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}`); }
|
|
1301
1369
|
// rc.59: gate the hint to once-per-cycle. Pre-rc.59 the hint
|
|
1302
1370
|
// fired on EVERY turn that landed over threshold — so a user
|
|
1303
1371
|
// saw "📚 70% full…" then "71% full…" then "72% full…"
|
|
@@ -1744,34 +1812,11 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1744
1812
|
|
|
1745
1813
|
// ─── Bot setup ──────────────────────────────────────────────────────
|
|
1746
1814
|
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
if (chatConfig.requireMention && msg.chat.type !== 'private') {
|
|
1754
|
-
const text = msg.text || msg.caption || '';
|
|
1755
|
-
const isReplyToBot = msg.reply_to_message?.from?.username === botUsername;
|
|
1756
|
-
const hasMention = text.includes(`@${botUsername}`);
|
|
1757
|
-
// A reply targeting some other user (not the bot) is a strong signal
|
|
1758
|
-
// "this message is for that person, not me". Paired users normally
|
|
1759
|
-
// bypass requireMention, but not in this case — without the guard a
|
|
1760
|
-
// paired user saying "Gotcha!" to a teammate gets processed by the
|
|
1761
|
-
// bot just because the user is paired, which is what bit us in
|
|
1762
|
-
// UMI Group on 0.5.9 (bot leaked reasoning as a reply to "Gotcha!").
|
|
1763
|
-
const repliesToOtherUser = !!msg.reply_to_message
|
|
1764
|
-
&& msg.reply_to_message.from?.username !== botUsername;
|
|
1765
|
-
// Paired users bypass requireMention — operator-trusted, no @ needed
|
|
1766
|
-
// every time. Skipped when they're replying to a non-bot user (above).
|
|
1767
|
-
const paired = !repliesToOtherUser && pairings && msg.from?.id
|
|
1768
|
-
? pairings.hasLivePairing({ bot_name: BOT_NAME, user_id: msg.from.id, chat_id: chatId })
|
|
1769
|
-
: false;
|
|
1770
|
-
if (!isReplyToBot && !hasMention && !paired) return false;
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
return true;
|
|
1774
|
-
}
|
|
1815
|
+
const shouldHandle = createShouldHandle({
|
|
1816
|
+
getConfig: () => config,
|
|
1817
|
+
getPairings: () => pairings,
|
|
1818
|
+
getBotName: () => BOT_NAME,
|
|
1819
|
+
});
|
|
1775
1820
|
|
|
1776
1821
|
function createBot(token) {
|
|
1777
1822
|
// Optional self-hosted Telegram Bot API server. When config.bot.apiRoot is
|
|
@@ -1936,7 +1981,7 @@ function createBot(token) {
|
|
|
1936
1981
|
// a single synthetic turn with all attachments merged. Timer resets on
|
|
1937
1982
|
// every new sibling, so as long as messages arrive faster than the
|
|
1938
1983
|
// DEFAULT_FLUSH_MS window apart they stay in the same bundle.
|
|
1939
|
-
|
|
1984
|
+
mediaBuffer = createMediaGroupBuffer({
|
|
1940
1985
|
onFlush: (messages) => {
|
|
1941
1986
|
if (!messages || messages.length === 0) return;
|
|
1942
1987
|
// Primary = the (usually first) message with text/caption; that's
|
|
@@ -2168,7 +2213,6 @@ let startPollWatchdog = null;
|
|
|
2168
2213
|
|
|
2169
2214
|
async function main() {
|
|
2170
2215
|
loadConfig();
|
|
2171
|
-
loadStickers();
|
|
2172
2216
|
|
|
2173
2217
|
let dbOverride;
|
|
2174
2218
|
try {
|
|
@@ -2194,6 +2238,8 @@ async function main() {
|
|
|
2194
2238
|
console.error(`[fatal] ${err.message}`);
|
|
2195
2239
|
process.exit(2);
|
|
2196
2240
|
}
|
|
2241
|
+
// After config.bot is set, so a per-bot stickersPath is honored.
|
|
2242
|
+
loadStickers();
|
|
2197
2243
|
DB_PATH = dbOverride || path.join(DB_DIR, `${BOT_NAME}.db`);
|
|
2198
2244
|
console.log(`[polygram] bot: ${BOT_NAME} (${Object.keys(config.chats).length} chats) db: ${DB_PATH}`);
|
|
2199
2245
|
|
|
@@ -2214,7 +2260,14 @@ async function main() {
|
|
|
2214
2260
|
// TmuxProcess.start() hits EEXIST on session spawn for any chat
|
|
2215
2261
|
// routed to pm:'tmux'. See lib/tmux/orphan-sweep.js for rationale.
|
|
2216
2262
|
try {
|
|
2217
|
-
|
|
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
|
+
});
|
|
2218
2271
|
if (sweep.swept.length > 0) {
|
|
2219
2272
|
console.log(`[orphan-sweep] killed ${sweep.swept.length} stale tmux session(s)`);
|
|
2220
2273
|
}
|
|
@@ -2314,6 +2367,38 @@ async function main() {
|
|
|
2314
2367
|
setInterval(() => runSecretSweep('interval'), secretSweepCfg.intervalMs).unref?.();
|
|
2315
2368
|
}
|
|
2316
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
|
+
|
|
2317
2402
|
// 0.8.0 Phase 1 step 11 + rc.50: defensive uncaughtException +
|
|
2318
2403
|
// unhandledRejection handlers. The new pm wraps every Query
|
|
2319
2404
|
// iteration in try/catch so SDK throws never leak — but if a
|
|
@@ -2472,7 +2557,7 @@ async function main() {
|
|
|
2472
2557
|
// tmux backend runner — one per daemon, shared across all TmuxProcess
|
|
2473
2558
|
// instances. Construction is cheap (no system call until first
|
|
2474
2559
|
// spawn/send). Only used if any chat in config has pm:'tmux'.
|
|
2475
|
-
const tmuxRunner = createTmuxRunner({ logger: console });
|
|
2560
|
+
const tmuxRunner = createTmuxRunner({ logger: console, sessionPrefix: 'polygram' });
|
|
2476
2561
|
// Verify the pinned claude CLI binary is present. The tmux
|
|
2477
2562
|
// backend spawns this exact binary by absolute path (see
|
|
2478
2563
|
// lib/claude-bin.js + TmuxProcess.start) — it never resolves
|
|
@@ -2488,7 +2573,13 @@ async function main() {
|
|
|
2488
2573
|
// auto-pruner (keeps only ~3 newest, deletes the rest) can't take cli chats
|
|
2489
2574
|
// down. Spawns from ~/.local/share/polygram/claude-bin/<version>, immune to
|
|
2490
2575
|
// pruning. Self-heals on boot (copy from the system install, else install).
|
|
2491
|
-
|
|
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;
|
|
2492
2583
|
const binCheck = ensureVendoredClaudeBin(CLAUDE_CLI_PINNED_VERSION, { logger: console });
|
|
2493
2584
|
if (binCheck.ok) {
|
|
2494
2585
|
console.log(
|
|
@@ -2532,6 +2623,23 @@ async function main() {
|
|
|
2532
2623
|
// channels backend
|
|
2533
2624
|
toolDispatcher: channelsToolDispatcher,
|
|
2534
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,
|
|
2535
2643
|
});
|
|
2536
2644
|
// Route in-process approval prompts through the SAME canUseTool plumbing
|
|
2537
2645
|
// that SDK chats use:
|
|
@@ -2623,6 +2731,7 @@ async function main() {
|
|
|
2623
2731
|
chunkMarkdownText, deliverReplies,
|
|
2624
2732
|
chunkBudget: TG_CHUNK_BUDGET,
|
|
2625
2733
|
getIsShuttingDown: () => isShuttingDown,
|
|
2734
|
+
authDisabledGate,
|
|
2626
2735
|
logger: console,
|
|
2627
2736
|
}));
|
|
2628
2737
|
// 0.12.0 post-turn edit re-delivery: constructed AFTER dispatchHandleMessage is assigned (above).
|
|
@@ -2727,6 +2836,53 @@ async function main() {
|
|
|
2727
2836
|
console.error(`[shutdown] question expiry failed: ${err.message}`);
|
|
2728
2837
|
}
|
|
2729
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
|
+
|
|
2730
2886
|
// 2. Drain in-flight handlers. Wait for inFlightHandlers to empty or
|
|
2731
2887
|
// SHUTDOWN_DRAIN_MS to elapse. pm handlers resolve naturally when
|
|
2732
2888
|
// result events arrive; the dispatcher's .finally decrements.
|
|
@@ -2767,6 +2923,7 @@ async function main() {
|
|
|
2767
2923
|
// 4. Remaining shutdown: approvals sweeper, IPC, resolve hook waiters,
|
|
2768
2924
|
// kill pm subprocesses, close DB.
|
|
2769
2925
|
if (approvalSweepTimer) clearInterval(approvalSweepTimer);
|
|
2926
|
+
authDisabledHeartbeat.stop();
|
|
2770
2927
|
if (ipcCloser) ipcCloser.close().catch(() => {});
|
|
2771
2928
|
try { fs.unlinkSync(ipcServer.secretPathFor(BOT_NAME)); } catch {}
|
|
2772
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 };
|