polygram 0.12.0-rc.2 → 0.12.0-rc.20
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/config.example.json +5 -1
- package/lib/attachments.js +46 -2
- package/lib/claude-bin.js +8 -1
- package/lib/compaction-warn.js +59 -0
- package/lib/context-usage.js +93 -0
- package/lib/error/classify.js +12 -0
- package/lib/handlers/abort.js +38 -1
- package/lib/handlers/dispatcher.js +43 -0
- package/lib/handlers/download.js +101 -58
- package/lib/ipc/file-validator.js +8 -1
- package/lib/process/channels-tool-dispatcher.js +20 -2
- package/lib/process/cli-process.js +447 -73
- package/lib/process/factory.js +0 -5
- package/lib/process/hook-event-tail.js +7 -0
- package/lib/process/hook-settings.js +7 -0
- package/lib/process-manager.js +6 -0
- package/lib/sdk/callbacks.js +61 -7
- package/lib/telegram/album-reactions.js +50 -0
- package/lib/telegram/api.js +9 -0
- package/lib/telegram/input-file.js +76 -0
- package/lib/telegram/reactions.js +5 -0
- package/lib/tmux/log-tail.js +11 -1
- package/lib/tmux/startup-gate.js +65 -1
- package/package.json +1 -1
- package/polygram.js +57 -19
package/config.example.json
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"bots": {
|
|
5
5
|
"admin-bot": {
|
|
6
6
|
"token": "REPLACE_WITH_BOT_TOKEN_FROM_BOTFATHER",
|
|
7
|
+
"_comment_apiRoot": "Optional. Point grammy at a self-hosted Telegram Bot API server (e.g. 'http://localhost:8082' from a local `telegram-bot-api --local` process) to raise file send/receive limits from cloud's 50MB-out / 20MB-in to 2GB both ways. Omit for cloud Telegram (default, unchanged). The server is a separate localhost-only companion daemon — see docs/0.12.0-file-send.md.",
|
|
7
8
|
"allowConfigCommands": true,
|
|
8
9
|
"_comment_adminChatId": "Required when allowConfigCommands is true for pairing commands (/pair-code, /pairings, /unpair) to work. These grant cross-chat trust and are gated to the admin chat only.",
|
|
9
10
|
"adminChatId": "123456789",
|
|
@@ -70,7 +71,10 @@
|
|
|
70
71
|
"model": "opus",
|
|
71
72
|
"effort": "medium",
|
|
72
73
|
"cwd": "/Users/you/admin-agent",
|
|
73
|
-
"timeout": 600
|
|
74
|
+
"timeout": 600,
|
|
75
|
+
"_comment_maxFileBytes": "OPTIONAL per-chat (or per-topic; topic wins) file-size cap in BYTES. There is NO fixed default — the default is backend-derived: cloud Telegram = 50MB send / 20MB receive; with a local Bot API server (bot.apiRoot set) = 2GB both ways. This key only LOWERS that ceiling for this chat (Telegram rejects anything above the backend limit regardless); omit it to use the full backend default. To set one, add e.g. \"maxFileBytes\": 104857600 (=100MB) — only meaningful when apiRoot is set, since cloud already clamps to 50/20MB.",
|
|
76
|
+
"_comment_compactionWarnings": "OPTIONAL per-chat (or per-topic; topic wins). CLI/channels backend (pm:'cli') only. Default OFF. When ON, polygram warns the chat as Claude's context fills so you can /compact on your terms BEFORE an auto-compaction interrupts a turn (auto-compaction detaches the channels MCP bridge mid-turn — see docs/0.12.0-compaction-warnings.md). Two forms: `true` (enable at the 75% default threshold) or `{ \"enabled\": true, \"thresholdPct\": 80 }` (custom 1-99 threshold). Proactive: at the threshold it posts 'context ~N% full, run /compact or /new at a break'. Reactive backstop: when claude auto-compacts anyway it posts 'compacting now, resend if quiet'. Manual /compact never warns. Requires the bot to allow /compact + /new commands.",
|
|
77
|
+
"compactionWarnings": true
|
|
74
78
|
},
|
|
75
79
|
|
|
76
80
|
"-1000000000001": {
|
package/lib/attachments.js
CHANGED
|
@@ -22,8 +22,48 @@
|
|
|
22
22
|
* extension — the fallback only kicks in when MIME is unhelpful.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// Inbound (user → bot) per-file cap. Telegram's cloud Bot API hard-caps
|
|
26
|
+
// bot file DOWNLOADS (getFile) at 20 MB, so 20 MB is the real ceiling on
|
|
27
|
+
// cloud — raised from 10 MB so users can send larger tracks/docs. With a
|
|
28
|
+
// self-hosted Bot API server (config.bot.apiRoot) the Telegram limit rises
|
|
29
|
+
// to 2 GB; resolveFileCaps() raises the default accordingly.
|
|
30
|
+
const MAX_FILE_BYTES = 20 * 1024 * 1024;
|
|
31
|
+
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
// ─── Backend-derived file-size caps (cloud vs local Bot API server) ──
|
|
34
|
+
//
|
|
35
|
+
// These are the HARD ceilings Telegram itself enforces — a per-chat
|
|
36
|
+
// override can lower them but never exceed them (Telegram rejects beyond
|
|
37
|
+
// regardless). NOT "adaptive": there is no intermediate tier. Cloud is a
|
|
38
|
+
// flat 20 in / 50 out; a local `telegram-bot-api --local` server is a flat
|
|
39
|
+
// 2 GB both ways.
|
|
40
|
+
const CLOUD_MAX_IN_BYTES = 20 * 1024 * 1024; // getFile download limit
|
|
41
|
+
const CLOUD_MAX_OUT_BYTES = 50 * 1024 * 1024; // sendDocument upload limit
|
|
42
|
+
const LOCAL_MAX_BYTES = 2000 * 1024 * 1024; // --local server, both ways
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the effective per-file caps for a chat/topic.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} opts
|
|
48
|
+
* @param {boolean} opts.localApi — true when config.bot.apiRoot is set
|
|
49
|
+
* (a local Bot API server is in use → 2 GB ceiling).
|
|
50
|
+
* @param {...number} opts.override — per-chat/topic maxFileBytes (bytes).
|
|
51
|
+
* Resolved by the caller from topic → chat → undefined; clamped to the
|
|
52
|
+
* backend ceiling.
|
|
53
|
+
* @returns {{ inBytes:number, outBytes:number, ceiling:number, localApi:boolean }}
|
|
54
|
+
*/
|
|
55
|
+
function resolveFileCaps({ localApi = false, override = null } = {}) {
|
|
56
|
+
const ceiling = localApi ? LOCAL_MAX_BYTES : null;
|
|
57
|
+
const defIn = localApi ? LOCAL_MAX_BYTES : CLOUD_MAX_IN_BYTES;
|
|
58
|
+
const defOut = localApi ? LOCAL_MAX_BYTES : CLOUD_MAX_OUT_BYTES;
|
|
59
|
+
// A numeric override sets BOTH directions to the same value, clamped to
|
|
60
|
+
// the backend hard ceiling (cloud uses the per-direction default as the
|
|
61
|
+
// clamp so an override can't push past Telegram's own limit).
|
|
62
|
+
const ovr = (typeof override === 'number' && override > 0) ? override : null;
|
|
63
|
+
const inBytes = ovr ? (localApi ? Math.min(ovr, ceiling) : Math.min(ovr, CLOUD_MAX_IN_BYTES)) : defIn;
|
|
64
|
+
const outBytes = ovr ? (localApi ? Math.min(ovr, ceiling) : Math.min(ovr, CLOUD_MAX_OUT_BYTES)) : defOut;
|
|
65
|
+
return { inBytes, outBytes, ceiling: ceiling ?? CLOUD_MAX_OUT_BYTES, localApi };
|
|
66
|
+
}
|
|
27
67
|
const MIME_ALLOW = [
|
|
28
68
|
/^image\//, /^audio\//, /^video\//,
|
|
29
69
|
/^application\/pdf$/, /^text\/plain$/,
|
|
@@ -109,8 +149,12 @@ function filterAttachments(attachments, opts = {}) {
|
|
|
109
149
|
|
|
110
150
|
module.exports = {
|
|
111
151
|
filterAttachments,
|
|
152
|
+
resolveFileCaps,
|
|
112
153
|
MAX_FILE_BYTES,
|
|
113
154
|
MAX_TOTAL_BYTES,
|
|
155
|
+
CLOUD_MAX_IN_BYTES,
|
|
156
|
+
CLOUD_MAX_OUT_BYTES,
|
|
157
|
+
LOCAL_MAX_BYTES,
|
|
114
158
|
MIME_ALLOW,
|
|
115
159
|
EXTENSION_ALLOW,
|
|
116
160
|
FALLBACK_MIMES,
|
package/lib/claude-bin.js
CHANGED
|
@@ -7,7 +7,14 @@ const fs = require('fs');
|
|
|
7
7
|
// 0.12 Phase 4: moved from lib/process/tmux-process.js into the helper module
|
|
8
8
|
// that consumes it, so the constant survives TmuxProcess deletion. CliProcess
|
|
9
9
|
// + spike scripts + polygram boot all import from here now.
|
|
10
|
-
|
|
10
|
+
// 0.12.0-rc.18: bumped 2.1.142 → 2.1.158 (latest installed). The dev-channels
|
|
11
|
+
// inbound-message delivery has an intermittent channel-bind race (the bridge
|
|
12
|
+
// pushes user_msg before claude's channel subscription is active → message
|
|
13
|
+
// silently dropped → stuck turn; see docs/0.12.0-known-issues.md). Trying a
|
|
14
|
+
// newer claude to see if the research-preview channels reliability improved,
|
|
15
|
+
// before building polygram-side recovery. Re-validate the channel flow on each
|
|
16
|
+
// bump via tests/e2e-channels-real-claude.test.js.
|
|
17
|
+
const CLAUDE_CLI_PINNED_VERSION = '2.1.158';
|
|
11
18
|
|
|
12
19
|
/**
|
|
13
20
|
* Resolve + verify the pinned claude CLI binary.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compaction-warn — per-chat config resolution + warn-once state for the
|
|
3
|
+
* compaction warning (0.12.0-rc.13).
|
|
4
|
+
*
|
|
5
|
+
* The warning is OFF by default. A chat (or topic) opts in via
|
|
6
|
+
* `compactionWarnings`:
|
|
7
|
+
* true → enabled, default threshold
|
|
8
|
+
* { enabled: true, thresholdPct: 80 } → enabled, custom threshold
|
|
9
|
+
* false / absent / object w/o enabled → off
|
|
10
|
+
*
|
|
11
|
+
* `thresholdPct` is the context-fill % at which the PROACTIVE warning fires
|
|
12
|
+
* (propose /compact before claude auto-compacts mid-turn). Default 75 — below
|
|
13
|
+
* claude's own auto-compact threshold so the user gets a window to act.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_THRESHOLD_PCT = 75;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object|undefined} cfg resolved topic/chat config (getTopicConfig result)
|
|
22
|
+
* @returns {{enabled: boolean, thresholdPct: number}}
|
|
23
|
+
*/
|
|
24
|
+
function resolveCompactionWarnConfig(cfg) {
|
|
25
|
+
const raw = cfg?.compactionWarnings;
|
|
26
|
+
const off = { enabled: false, thresholdPct: DEFAULT_THRESHOLD_PCT };
|
|
27
|
+
|
|
28
|
+
if (raw === true) return { enabled: true, thresholdPct: DEFAULT_THRESHOLD_PCT };
|
|
29
|
+
if (raw && typeof raw === 'object' && raw.enabled === true) {
|
|
30
|
+
const t = Number(raw.thresholdPct);
|
|
31
|
+
const thresholdPct = (Number.isFinite(t) && t > 0 && t < 100) ? t : DEFAULT_THRESHOLD_PCT;
|
|
32
|
+
return { enabled: true, thresholdPct };
|
|
33
|
+
}
|
|
34
|
+
return off;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Per-session "have we already warned on this climb?" state. Warn ONCE per
|
|
39
|
+
* session until reset — without this the proactive warning would re-fire on
|
|
40
|
+
* every turn-end while the context stays high. Reset on a successful
|
|
41
|
+
* compaction (PostCompact → context dropped) or a fresh session so the next
|
|
42
|
+
* climb can warn again. Mirrors the autoResumeTracker shape.
|
|
43
|
+
*/
|
|
44
|
+
function createCompactionWarnTracker() {
|
|
45
|
+
const warned = new Set();
|
|
46
|
+
return {
|
|
47
|
+
shouldWarn(sessionKey) { return !warned.has(sessionKey); },
|
|
48
|
+
markWarned(sessionKey) { warned.add(sessionKey); },
|
|
49
|
+
reset(sessionKey) { warned.delete(sessionKey); },
|
|
50
|
+
resetAll() { warned.clear(); },
|
|
51
|
+
_size() { return warned.size; },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
resolveCompactionWarnConfig,
|
|
57
|
+
createCompactionWarnTracker,
|
|
58
|
+
DEFAULT_THRESHOLD_PCT,
|
|
59
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context-usage — read live context occupancy from a Claude Code session
|
|
3
|
+
* transcript (JSONL).
|
|
4
|
+
*
|
|
5
|
+
* Used by the per-chat compaction warning (0.12.0-rc.13). polygram has no
|
|
6
|
+
* usage payload on the channels/CLI backend (hook events carry none — see
|
|
7
|
+
* the rc.13 spike), so the only source of "how full is the context" is the
|
|
8
|
+
* transcript itself. We read it ONCE per turn-end (Stop hook), not on a
|
|
9
|
+
* poll loop, so a single streamed pass is fine.
|
|
10
|
+
*
|
|
11
|
+
* What "occupancy" means: Claude's own context-% / auto-compact threshold is
|
|
12
|
+
* measured against what's fed INTO the model each turn —
|
|
13
|
+
* input_tokens + cache_read_input_tokens + cache_creation_input_tokens
|
|
14
|
+
* (cache_read dominates once the conversation is warm). output_tokens is the
|
|
15
|
+
* reply, not context, so it's excluded.
|
|
16
|
+
*
|
|
17
|
+
* We take the LAST main-thread (non-sidechain) assistant frame with a usage
|
|
18
|
+
* block. Subagents write to their own agent_transcript_path so sidechain
|
|
19
|
+
* frames don't normally appear here, but we skip them defensively: a format
|
|
20
|
+
* change that inlined a subagent's large usage would otherwise spike the
|
|
21
|
+
* parent's apparent context and trigger a false "you're full" warning.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const readline = require('node:readline');
|
|
28
|
+
|
|
29
|
+
// Standard Claude context window (sonnet/opus, non-beta). The warning is a
|
|
30
|
+
// heuristic ("you're getting full"), so an approximate denominator is fine;
|
|
31
|
+
// callers can pass a different window for 1M-beta sessions.
|
|
32
|
+
const DEFAULT_WINDOW_TOKENS = 200_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} transcriptPath
|
|
36
|
+
* @returns {Promise<{inputTokens:number, cacheReadTokens:number, cacheCreationTokens:number, total:number} | null>}
|
|
37
|
+
* null when the path is falsy/unreadable or no usable usage frame exists.
|
|
38
|
+
*/
|
|
39
|
+
async function readContextTokens(transcriptPath) {
|
|
40
|
+
if (!transcriptPath) return null;
|
|
41
|
+
|
|
42
|
+
let stream;
|
|
43
|
+
try {
|
|
44
|
+
stream = fs.createReadStream(transcriptPath, { encoding: 'utf8' });
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
let last = null;
|
|
51
|
+
// Resolve only once — error and close can both fire.
|
|
52
|
+
let done = false;
|
|
53
|
+
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
|
54
|
+
|
|
55
|
+
stream.on('error', () => finish(null));
|
|
56
|
+
|
|
57
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
58
|
+
// readline forwards the input stream's 'error' (e.g. ENOENT on open) to
|
|
59
|
+
// the interface; without this handler that re-emit is unhandled and
|
|
60
|
+
// crashes the process even though we resolved null on the stream error.
|
|
61
|
+
rl.on('error', () => finish(null));
|
|
62
|
+
rl.on('line', (line) => {
|
|
63
|
+
if (!line) return;
|
|
64
|
+
let o;
|
|
65
|
+
try { o = JSON.parse(line); } catch { return; } // skip partial/non-JSON lines
|
|
66
|
+
if (!o || o.type !== 'assistant' || o.isSidechain === true) return;
|
|
67
|
+
const u = o.message?.usage;
|
|
68
|
+
if (!u) return;
|
|
69
|
+
const inputTokens = Number(u.input_tokens) || 0;
|
|
70
|
+
const cacheReadTokens = Number(u.cache_read_input_tokens) || 0;
|
|
71
|
+
const cacheCreationTokens = Number(u.cache_creation_input_tokens) || 0;
|
|
72
|
+
const total = inputTokens + cacheReadTokens + cacheCreationTokens;
|
|
73
|
+
if (total > 0) last = { inputTokens, cacheReadTokens, cacheCreationTokens, total };
|
|
74
|
+
});
|
|
75
|
+
rl.on('close', () => finish(last));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Fraction (0..1) of the context window currently occupied. Clamps to 0 on
|
|
81
|
+
* non-positive / non-finite inputs so callers never see NaN/Infinity.
|
|
82
|
+
*
|
|
83
|
+
* @param {number} totalTokens
|
|
84
|
+
* @param {number} [windowTokens=DEFAULT_WINDOW_TOKENS]
|
|
85
|
+
* @returns {number}
|
|
86
|
+
*/
|
|
87
|
+
function contextPct(totalTokens, windowTokens = DEFAULT_WINDOW_TOKENS) {
|
|
88
|
+
if (!Number.isFinite(totalTokens) || totalTokens <= 0) return 0;
|
|
89
|
+
if (!Number.isFinite(windowTokens) || windowTokens <= 0) return 0;
|
|
90
|
+
return totalTokens / windowTokens;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { readContextTokens, contextPct, DEFAULT_WINDOW_TOKENS };
|
package/lib/error/classify.js
CHANGED
|
@@ -195,6 +195,18 @@ const CODES = {
|
|
|
195
195
|
isTransient: false,
|
|
196
196
|
autoRecover: null,
|
|
197
197
|
},
|
|
198
|
+
// TMUX_SESSION_GONE: claude exited during spawn so the tmux session vanished
|
|
199
|
+
// before the channel went live (the startup-gate's captureWide hit "can't
|
|
200
|
+
// find pane"). Usual cause: an unresumable aged session whose "Resume from
|
|
201
|
+
// summary?" /compact exits code 0. The dispatcher poison-clears the session
|
|
202
|
+
// on this code, so a resend genuinely starts fresh and works — hence the
|
|
203
|
+
// calm "send it again" copy instead of the old raw "[startup-gate]…" leak.
|
|
204
|
+
TMUX_SESSION_GONE: {
|
|
205
|
+
kind: 'tmuxSessionGone',
|
|
206
|
+
userMessage: '🔄 That chat got stuck starting up, so I reset it. Send your message again and I\'ll pick it up fresh.',
|
|
207
|
+
isTransient: false,
|
|
208
|
+
autoRecover: null,
|
|
209
|
+
},
|
|
198
210
|
// TURN_TIMEOUT: 10-min wall-clock cap on a single channels turn. Mirror
|
|
199
211
|
// of the tmux wall-clock ceiling — typically a runaway, not a wedge.
|
|
200
212
|
// Not transient (auto-retry would just runaway again).
|
package/lib/handlers/abort.js
CHANGED
|
@@ -42,13 +42,37 @@ function createHandleAbort({
|
|
|
42
42
|
const threadId = msg.message_thread_id?.toString();
|
|
43
43
|
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
44
44
|
const proc = pm.has(sessionKey) ? pm.get(sessionKey) : null;
|
|
45
|
-
|
|
45
|
+
let hadActive = !!proc?.inFlight;
|
|
46
46
|
|
|
47
47
|
// Mark BEFORE killing: the 'close' event fires almost immediately
|
|
48
48
|
// after interrupt, and the surrounding handleMessage's catch
|
|
49
49
|
// needs to see the flag to skip the generic error-reply.
|
|
50
50
|
if (hadActive) markSessionAborted(sessionKey);
|
|
51
51
|
|
|
52
|
+
// "Stop" incident (shumorobot Music, 2026-05-31 13:08): on the
|
|
53
|
+
// CliProcess/channels backend a turn resolves on the quiet-window
|
|
54
|
+
// after claude's last reply tool call (inFlight → false), but claude
|
|
55
|
+
// can still be working (subagent, long Bash). Keying the ack on
|
|
56
|
+
// inFlight alone made "Stop" say "Nothing to stop" while a subagent
|
|
57
|
+
// download churned. probeBusyState() reads the TUI "esc to interrupt"
|
|
58
|
+
// hint — the truthful signal — so detection, the abort mark, and the
|
|
59
|
+
// ack all agree. The probe result is logged below (forensics) so the
|
|
60
|
+
// heuristic can be refined against real states later. Channels analog
|
|
61
|
+
// of the (deleted) tmux hasBackgroundShell branch; typeof-guarded so
|
|
62
|
+
// it's a no-op on backends without it.
|
|
63
|
+
let busyProbe = null;
|
|
64
|
+
if (!hadActive && proc && typeof proc.probeBusyState === 'function') {
|
|
65
|
+
try {
|
|
66
|
+
busyProbe = await proc.probeBusyState();
|
|
67
|
+
if (busyProbe?.busy) {
|
|
68
|
+
hadActive = true;
|
|
69
|
+
markSessionAborted(sessionKey);
|
|
70
|
+
}
|
|
71
|
+
} catch (err) {
|
|
72
|
+
logger.error?.(`[${botName}] busy-probe failed: ${err.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
52
76
|
// Bug 1 (incident 2026-05-18): "Stop" was turn-scoped — it only
|
|
53
77
|
// looked at an in-flight TURN. But the agent can leave a DETACHED
|
|
54
78
|
// background shell running (a `run_in_background:true` Bash) that
|
|
@@ -87,6 +111,19 @@ function createHandleAbort({
|
|
|
87
111
|
chat_id: chatId, user_id: msg.from?.id || null,
|
|
88
112
|
had_active: hadActive,
|
|
89
113
|
killed_background_shell: killedBackgroundShell,
|
|
114
|
+
// "Stop" incident forensics: the raw busy-probe signals at decision
|
|
115
|
+
// time. Lets us query, across real aborts, where the esc-hint /
|
|
116
|
+
// inFlight / pending-turn signals agreed vs diverged and refine the
|
|
117
|
+
// heuristic later. null when no probe ran (turn was already inFlight,
|
|
118
|
+
// or the backend has no probeBusyState).
|
|
119
|
+
busy_probe: busyProbe ? {
|
|
120
|
+
busy: busyProbe.busy,
|
|
121
|
+
streaming: busyProbe.streaming,
|
|
122
|
+
in_flight: busyProbe.inFlight,
|
|
123
|
+
pending_turns: busyProbe.pendingTurns,
|
|
124
|
+
captured: busyProbe.captured,
|
|
125
|
+
pane_tail: busyProbe.paneTail,
|
|
126
|
+
} : null,
|
|
90
127
|
trigger: cleanText.slice(0, 40),
|
|
91
128
|
});
|
|
92
129
|
|
|
@@ -178,6 +178,26 @@ function createDispatcher({
|
|
|
178
178
|
aborted: wasAborted || undefined,
|
|
179
179
|
replay: isReplay || undefined,
|
|
180
180
|
});
|
|
181
|
+
// Startup-gate death (claude exited during spawn / the dialog gate timed
|
|
182
|
+
// out) of a likely-aged RESUMED session — the persisted claude_session_id
|
|
183
|
+
// can't be resumed cleanly (shumorobot general chat 2026-06-01→03: a
|
|
184
|
+
// week-old session renders claude's "Resume from summary?" dialog whose
|
|
185
|
+
// /compact resume exits code 0 → TMUX_SESSION_GONE → the chat re-resumes
|
|
186
|
+
// the same dead id on every message, stuck for days). Poison-clear so the
|
|
187
|
+
// NEXT message spawns a FRESH session — same recovery the auto-resume path
|
|
188
|
+
// does for BRIDGE_DISCONNECTED below. clearSessionId is a no-op DELETE when
|
|
189
|
+
// there's no row (a genuine fresh-spawn failure), so this is safe; and
|
|
190
|
+
// unlike an in-process recursive retry it never reuses a closed instance.
|
|
191
|
+
if ((err.code === 'TMUX_SESSION_GONE' || err.code === 'CHANNELS_DIALOG_TIMEOUT')
|
|
192
|
+
&& typeof db.clearSessionId === 'function') {
|
|
193
|
+
dbWrite(
|
|
194
|
+
() => db.clearSessionId(sessionKey),
|
|
195
|
+
`clearSessionId: poisoned by ${err.code} on startup`,
|
|
196
|
+
);
|
|
197
|
+
logEvent('session-reset-after-startup-gate', {
|
|
198
|
+
chat_id: chatId, session_key: sessionKey, msg_id: msg?.message_id, code: err.code,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
181
201
|
// rc.55: surface replay failures with a meaningful message.
|
|
182
202
|
// Pre-rc.55 any boot-replay turn that failed for ANY reason
|
|
183
203
|
// was silently dropped. The rc.51-onward boot-replay path is
|
|
@@ -224,6 +244,29 @@ function createDispatcher({
|
|
|
224
244
|
chat_id: chatId, session_key: sessionKey, msg_id: msg.message_id,
|
|
225
245
|
error: resumeErr?.message?.slice(0, 200),
|
|
226
246
|
});
|
|
247
|
+
// Music topic incident (2026-06-01): a channels session whose
|
|
248
|
+
// context grew large enough to auto-/compact on resume loses its
|
|
249
|
+
// MCP bridge binding on EVERY resume ("no MCP server configured"),
|
|
250
|
+
// so the resumed turn re-detaches (BRIDGE_DISCONNECTED) and lands
|
|
251
|
+
// here. The persisted claude_session_id is then poisoned — every
|
|
252
|
+
// future message (manual resend OR post-cooldown auto-resume)
|
|
253
|
+
// re-resumes it and re-detaches, an endless "🔌 please resend"
|
|
254
|
+
// loop. Break it: drop the session row so the NEXT message spawns
|
|
255
|
+
// a FRESH session (no --resume). Gated on the ORIGINAL error being
|
|
256
|
+
// a bridge-detach AND auto-resume having failed — a one-off bridge
|
|
257
|
+
// crash that resumes cleanly takes the .then() path above and
|
|
258
|
+
// keeps its context; only a session that re-detaches on resume is
|
|
259
|
+
// treated as poison. We lose the poisoned conversation's history,
|
|
260
|
+
// but that session can't complete a turn anyway.
|
|
261
|
+
if (err.code === 'BRIDGE_DISCONNECTED' && typeof db.clearSessionId === 'function') {
|
|
262
|
+
dbWrite(
|
|
263
|
+
() => db.clearSessionId(sessionKey),
|
|
264
|
+
'clearSessionId: poisoned by bridge-detach on resume',
|
|
265
|
+
);
|
|
266
|
+
logEvent('session-reset-after-bridge-detach', {
|
|
267
|
+
chat_id: chatId, session_key: sessionKey, msg_id: msg.message_id,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
227
270
|
const fallbackText = errorReplyText(err);
|
|
228
271
|
if (fallbackText) {
|
|
229
272
|
tg(bot, 'sendMessage', {
|
package/lib/handlers/download.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
const fs = require('fs');
|
|
25
25
|
const path = require('path');
|
|
26
26
|
const { redactBotToken } = require('../error/net');
|
|
27
|
-
const { MAX_FILE_BYTES } = require('../attachments');
|
|
27
|
+
const { MAX_FILE_BYTES, resolveFileCaps } = require('../attachments');
|
|
28
28
|
|
|
29
29
|
const ATTACHMENT_DOWNLOAD_CONCURRENCY_DEFAULT = 6;
|
|
30
30
|
|
|
@@ -60,76 +60,119 @@ function createDownloadAttachments({
|
|
|
60
60
|
} catch { /* fall through to refetch */ }
|
|
61
61
|
}
|
|
62
62
|
try {
|
|
63
|
+
// Inbound per-file cap is BACKEND-derived: 20 MB on cloud Telegram
|
|
64
|
+
// (Telegram's own getFile ceiling), 2 GB with the local Bot API server.
|
|
65
|
+
// rc.15: previously hardcoded to MAX_FILE_BYTES (20 MB), which rejected
|
|
66
|
+
// large lossless tracks even when the local server could handle them.
|
|
67
|
+
const cap = resolveFileCaps({ localApi: !!config.bot?.apiRoot }).inBytes;
|
|
68
|
+
|
|
63
69
|
const fileInfo = await bot.api.getFile(att.file_id);
|
|
64
70
|
if (!fileInfo?.file_path) throw new Error('no file_path from getFile');
|
|
65
|
-
|
|
66
|
-
const res = await fetchImpl(url);
|
|
67
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
68
|
-
// Three-layer size enforcement, in order of cheapness:
|
|
69
|
-
// 1. Content-Length header — fail-fast before reading body.
|
|
70
|
-
// 2. Streaming accumulator — abort the moment cumulative byte
|
|
71
|
-
// count crosses the cap. Defends against attackers omitting
|
|
72
|
-
// Content-Length: pre-cap the whole body could pin RSS.
|
|
73
|
-
// 3. Final post-buffer check — defense in depth.
|
|
74
|
-
const cl = parseInt(res.headers.get('content-length') || '0', 10);
|
|
75
|
-
if (cl > MAX_FILE_BYTES) {
|
|
76
|
-
throw new Error(`content-length ${cl} exceeds per-file cap ${MAX_FILE_BYTES}`);
|
|
77
|
-
}
|
|
78
|
-
let total = 0;
|
|
79
|
-
const chunks = [];
|
|
80
|
-
if (res.body && typeof res.body.getReader === 'function') {
|
|
81
|
-
const reader = res.body.getReader();
|
|
82
|
-
while (true) {
|
|
83
|
-
const { done, value } = await reader.read();
|
|
84
|
-
if (done) break;
|
|
85
|
-
total += value.byteLength;
|
|
86
|
-
if (total > MAX_FILE_BYTES) {
|
|
87
|
-
try { await reader.cancel(); } catch {}
|
|
88
|
-
throw new Error(`stream ${total}+ bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
|
|
89
|
-
}
|
|
90
|
-
chunks.push(value);
|
|
91
|
-
}
|
|
92
|
-
} else {
|
|
93
|
-
// Fallback for runtimes without WHATWG streams (shouldn't fire
|
|
94
|
-
// on Node 22+).
|
|
95
|
-
const ab = await res.arrayBuffer();
|
|
96
|
-
if (ab.byteLength > MAX_FILE_BYTES) {
|
|
97
|
-
throw new Error(`body ${ab.byteLength} bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
|
|
98
|
-
}
|
|
99
|
-
chunks.push(new Uint8Array(ab));
|
|
100
|
-
total = ab.byteLength;
|
|
101
|
-
}
|
|
102
|
-
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)));
|
|
103
|
-
if (buf.length > MAX_FILE_BYTES) {
|
|
104
|
-
throw new Error(`body ${buf.length} bytes exceeds per-file cap ${MAX_FILE_BYTES}`);
|
|
105
|
-
}
|
|
71
|
+
|
|
106
72
|
const safeName = sanitizeFilename(att.name);
|
|
107
73
|
// Embed file_unique_id so two attachments with the same msg_id+name
|
|
108
74
|
// (album, resend) can't silently overwrite each other.
|
|
109
75
|
const uniq = att.file_unique_id ? `-${att.file_unique_id}` : '';
|
|
110
76
|
const localName = `${msg.message_id}${uniq}-${safeName}`;
|
|
111
77
|
const localPath = path.join(chatDir, localName);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
|
|
78
|
+
|
|
79
|
+
let size;
|
|
80
|
+
|
|
81
|
+
if (path.isAbsolute(fileInfo.file_path)) {
|
|
82
|
+
// ── Local Bot API server ────────────────────────────────────────
|
|
83
|
+
// rc.15: in `--local` mode getFile returns a LOCAL ABSOLUTE PATH —
|
|
84
|
+
// the server has already downloaded the file to its own disk. The
|
|
85
|
+
// previous code built a cloud URL (https://api.telegram.org/file/...)
|
|
86
|
+
// and HTTP-fetched it, which is nonsensical for a local path and
|
|
87
|
+
// failed every inbound file once apiRoot was set. Instead, link the
|
|
88
|
+
// file into the inbox directly (no HTTP, no buffering a 2 GB file
|
|
89
|
+
// through RAM). A hardlink is instant and shares the inode, so it
|
|
90
|
+
// survives the server pruning its own copy; fall back to a byte copy
|
|
91
|
+
// across filesystems.
|
|
92
|
+
const srcStat = fs.statSync(fileInfo.file_path);
|
|
93
|
+
if (srcStat.size > cap) {
|
|
94
|
+
throw new Error(`file ${srcStat.size} exceeds per-file cap ${cap}`);
|
|
95
|
+
}
|
|
96
|
+
if (fs.existsSync(localPath)) {
|
|
97
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (already on disk, reusing)`);
|
|
98
|
+
} else {
|
|
99
|
+
try {
|
|
100
|
+
fs.linkSync(fileInfo.file_path, localPath);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (e.code === 'EEXIST') {
|
|
103
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (race: already on disk)`);
|
|
104
|
+
} else if (e.code === 'EXDEV') {
|
|
105
|
+
fs.copyFileSync(fileInfo.file_path, localPath); // cross-device fallback
|
|
106
|
+
} else {
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
size = srcStat.size;
|
|
112
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (${size} bytes, local-api) → ${localPath}`);
|
|
117
113
|
} else {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
114
|
+
// ── Cloud Telegram ──────────────────────────────────────────────
|
|
115
|
+
// getFile returns a RELATIVE path; download it over HTTPS with the
|
|
116
|
+
// three-layer size guard (header → streaming accumulator → final).
|
|
117
|
+
const url = `https://api.telegram.org/file/bot${token}/${fileInfo.file_path}`;
|
|
118
|
+
const res = await fetchImpl(url);
|
|
119
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
120
|
+
const cl = parseInt(res.headers.get('content-length') || '0', 10);
|
|
121
|
+
if (cl > cap) {
|
|
122
|
+
throw new Error(`content-length ${cl} exceeds per-file cap ${cap}`);
|
|
126
123
|
}
|
|
124
|
+
let total = 0;
|
|
125
|
+
const chunks = [];
|
|
126
|
+
if (res.body && typeof res.body.getReader === 'function') {
|
|
127
|
+
const reader = res.body.getReader();
|
|
128
|
+
while (true) {
|
|
129
|
+
const { done, value } = await reader.read();
|
|
130
|
+
if (done) break;
|
|
131
|
+
total += value.byteLength;
|
|
132
|
+
if (total > cap) {
|
|
133
|
+
try { await reader.cancel(); } catch {}
|
|
134
|
+
throw new Error(`stream ${total}+ bytes exceeds per-file cap ${cap}`);
|
|
135
|
+
}
|
|
136
|
+
chunks.push(value);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// Fallback for runtimes without WHATWG streams (shouldn't fire
|
|
140
|
+
// on Node 22+).
|
|
141
|
+
const ab = await res.arrayBuffer();
|
|
142
|
+
if (ab.byteLength > cap) {
|
|
143
|
+
throw new Error(`body ${ab.byteLength} bytes exceeds per-file cap ${cap}`);
|
|
144
|
+
}
|
|
145
|
+
chunks.push(new Uint8Array(ab));
|
|
146
|
+
total = ab.byteLength;
|
|
147
|
+
}
|
|
148
|
+
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)));
|
|
149
|
+
if (buf.length > cap) {
|
|
150
|
+
throw new Error(`body ${buf.length} bytes exceeds per-file cap ${cap}`);
|
|
151
|
+
}
|
|
152
|
+
// Atomic write: temp file + rename. A crash mid-write leaves a
|
|
153
|
+
// .tmp.* file (swept later) rather than a truncated canonical
|
|
154
|
+
// file the EEXIST dedup branch would happily serve next time.
|
|
155
|
+
if (fs.existsSync(localPath)) {
|
|
156
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (already on disk, reusing)`);
|
|
157
|
+
} else {
|
|
158
|
+
const tmpPath = `${localPath}.tmp.${process.pid}.${Date.now()}`;
|
|
159
|
+
try {
|
|
160
|
+
fs.writeFileSync(tmpPath, buf, { flag: 'wx' });
|
|
161
|
+
fs.renameSync(tmpPath, localPath);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
164
|
+
if (e.code !== 'EEXIST') throw e;
|
|
165
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (race: already on disk)`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
size = buf.length;
|
|
169
|
+
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (${size} bytes) → ${localPath}`);
|
|
127
170
|
}
|
|
128
|
-
|
|
171
|
+
|
|
129
172
|
dbWrite(() => db.markAttachmentDownloaded(att.id, {
|
|
130
|
-
local_path: localPath, size_bytes: att.size_bytes ||
|
|
173
|
+
local_path: localPath, size_bytes: att.size_bytes || size,
|
|
131
174
|
}), `markAttachmentDownloaded ${att.id}`);
|
|
132
|
-
return { ...att, path: localPath, size: att.size_bytes ||
|
|
175
|
+
return { ...att, path: localPath, size: att.size_bytes || size, error: null };
|
|
133
176
|
} catch (err) {
|
|
134
177
|
// Don't drop the attachment silently — push it through with the
|
|
135
178
|
// failure noted. buildAttachmentTags renders this as
|
|
@@ -50,7 +50,14 @@ function validateIpcFileParam(method, params = {}) {
|
|
|
50
50
|
const fileParam = FILE_PARAM_BY_METHOD[method];
|
|
51
51
|
if (!fileParam) return null;
|
|
52
52
|
const val = params[fileParam];
|
|
53
|
-
|
|
53
|
+
// { source: '/abs/path' } envelope — now coerced to a grammy InputFile in
|
|
54
|
+
// tg() (coerceFileParams). Validate it has a usable absolute source, else
|
|
55
|
+
// pass through (Buffer / stream / InputFile shapes).
|
|
56
|
+
if (val && typeof val === 'object' && typeof val.source === 'string') {
|
|
57
|
+
if (val.source.length === 0) return `polygram IPC: ${fileParam}.source is empty`;
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (typeof val !== 'string') return null; // Buffer/InputFile/etc — pass through
|
|
54
61
|
if (val.length === 0) return `polygram IPC: ${fileParam} is empty`;
|
|
55
62
|
|
|
56
63
|
const looksUrl = /^(https?|ftp):\/\//i.test(val);
|
|
@@ -125,7 +125,7 @@ function createChannelsToolDispatcher({
|
|
|
125
125
|
|| require('../telegram/process-agent-reply').processAndDeliverAgentText;
|
|
126
126
|
|
|
127
127
|
return async function channelsToolDispatcher(call) {
|
|
128
|
-
const { sessionKey, chatId, threadId, toolName, text, files, sourceMsgId } = call;
|
|
128
|
+
const { sessionKey, chatId, threadId, toolName, text, files, sourceMsgId, maxOutboundFileBytes } = call;
|
|
129
129
|
|
|
130
130
|
if (toolName !== 'reply') {
|
|
131
131
|
// 0.11.0 Phase 1 ships `reply` only — react and edit_message are
|
|
@@ -196,6 +196,21 @@ function createChannelsToolDispatcher({
|
|
|
196
196
|
failedAttachments.push({ path: filePath, error: check.error });
|
|
197
197
|
continue;
|
|
198
198
|
}
|
|
199
|
+
// Backend/chat-derived upload cap. Reject oversize BEFORE upload with
|
|
200
|
+
// a clear error (vs Telegram's cryptic 413/"file is too big") so
|
|
201
|
+
// claude can convert/compress and retry. maxOutboundFileBytes is
|
|
202
|
+
// undefined for non-channels callers → no cap (Telegram still gates).
|
|
203
|
+
if (typeof maxOutboundFileBytes === 'number' && maxOutboundFileBytes > 0) {
|
|
204
|
+
let size = 0;
|
|
205
|
+
try { size = fs.statSync(check.resolved).size; } catch {}
|
|
206
|
+
if (size > maxOutboundFileBytes) {
|
|
207
|
+
const mb = (n) => (n / (1024 * 1024)).toFixed(1);
|
|
208
|
+
const err = `file too large to send: ${mb(size)}MB > ${mb(maxOutboundFileBytes)}MB limit`;
|
|
209
|
+
logger.warn?.(`[channels-tool-dispatcher] ${err} (${check.resolved})`);
|
|
210
|
+
failedAttachments.push({ path: filePath, error: err });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
199
214
|
try {
|
|
200
215
|
const ext = path.extname(check.resolved).toLowerCase();
|
|
201
216
|
const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
|
|
@@ -203,7 +218,10 @@ function createChannelsToolDispatcher({
|
|
|
203
218
|
const fieldName = isImage ? 'photo' : 'document';
|
|
204
219
|
const params = {
|
|
205
220
|
chat_id: chatId,
|
|
206
|
-
|
|
221
|
+
// { source } envelope → grammy InputFile in tg()'s coerceFileParams.
|
|
222
|
+
// Pre-fix this bare object reached grammy unrecognized and every
|
|
223
|
+
// upload 400'd with "Wrong port number" (file-send never worked).
|
|
224
|
+
[fieldName]: { source: check.resolved, filename: path.basename(check.resolved) },
|
|
207
225
|
};
|
|
208
226
|
if (threadId) params.message_thread_id = threadId;
|
|
209
227
|
await send(bot, method, params, { source: 'channels-tool-dispatcher', sessionKey });
|