polygram 0.8.0-rc.24 → 0.8.0-rc.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.8.0-rc.24",
4
+ "version": "0.8.0-rc.26",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Abort-grace tracker — per-session timestamps marking "user just
3
+ * /stop'd this session, suppress the next batch of generic error
4
+ * replies".
5
+ *
6
+ * Why this exists: when the user types /stop (or natural-language
7
+ * "стоп"), polygram calls pm.kill(sessionKey). The kill SIGTERM's
8
+ * the in-flight process — every pending in the queue rejects with
9
+ * "Process killed" or INTERRUPTED. WITHOUT abort-grace, polygram
10
+ * would post "💥 Hit a snag" for each rejected pending, even though
11
+ * the user already saw the /stop ack and these errors are caused
12
+ * by their own action.
13
+ *
14
+ * Timestamp model (vs the earlier "delete after first read" Set):
15
+ * a single /stop can drain many pendings, so we mark a TS and let
16
+ * every error within ABORT_GRACE_MS see "yes, aborted, stay quiet".
17
+ *
18
+ * Closes v6 plan §7.1 G11 unit gate.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const DEFAULT_ABORT_GRACE_MS = 15_000;
24
+
25
+ /**
26
+ * @param {object} [opts]
27
+ * @param {number} [opts.windowMs] — grace window (default 15s)
28
+ * @param {() => number} [opts.now] — clock injection for tests
29
+ */
30
+ function createAbortGrace({ windowMs = DEFAULT_ABORT_GRACE_MS, now = () => Date.now() } = {}) {
31
+ const aborted = new Map(); // sessionKey → ts of abort
32
+
33
+ function mark(sessionKey) {
34
+ if (!sessionKey) return;
35
+ const ts = now();
36
+ aborted.set(sessionKey, ts);
37
+ // Sweep old entries opportunistically. Use 2× window so a
38
+ // session that's marked-and-checked at the boundary doesn't
39
+ // disappear before the check completes.
40
+ for (const [k, t] of aborted) {
41
+ if (ts - t > windowMs * 2) aborted.delete(k);
42
+ }
43
+ }
44
+
45
+ function isRecent(sessionKey) {
46
+ const ts = aborted.get(sessionKey);
47
+ return ts != null && (now() - ts) < windowMs;
48
+ }
49
+
50
+ function clear(sessionKey) {
51
+ aborted.delete(sessionKey);
52
+ }
53
+
54
+ return {
55
+ mark,
56
+ isRecent,
57
+ clear,
58
+ get size() { return aborted.size; },
59
+ };
60
+ }
61
+
62
+ module.exports = { createAbortGrace, DEFAULT_ABORT_GRACE_MS };
@@ -43,7 +43,23 @@ const cache = new Map(); // cacheKey → AgentBundle
43
43
 
44
44
  // Resolve agent file by checking each search path in order.
45
45
  // Returns { kind: 'file'|'dir', path, dir | null } or null.
46
+ // Restrict agent names to a conservative charset so they can't
47
+ // path-traverse out of the `.claude/agents/` directory. Pre-fix, an
48
+ // agent name like `../../etc/passwd` silently resolved to whatever
49
+ // existed at that path, loading arbitrary file content as the
50
+ // system prompt. Chat configs are operator-controlled (not user
51
+ // input), so the practical threat is operator typos — but pinning
52
+ // the contract removes the foot-gun.
53
+ //
54
+ // Allowed: alphanumerics, hyphen, underscore, single dots inside
55
+ // (e.g. "shumabit-finance.v2"). Forbidden: leading/trailing dot,
56
+ // consecutive dots, slashes, NUL.
57
+ const AGENT_NAME_RE = /^[A-Za-z0-9_]+(?:[.-][A-Za-z0-9_]+)*$/;
58
+
46
59
  function resolveAgentLocation(agentName, homeDir, cwd) {
60
+ if (typeof agentName !== 'string' || !AGENT_NAME_RE.test(agentName)) {
61
+ return null;
62
+ }
47
63
  const fileCandidates = [];
48
64
  if (cwd) fileCandidates.push(path.join(cwd, '.claude', 'agents', agentName + '.md'));
49
65
  fileCandidates.push(path.join(homeDir, '.claude', 'agents', agentName + '.md'));
@@ -110,6 +110,13 @@ function createApprovalWaiters({
110
110
  parkedAt: Date.now(),
111
111
  sessionKey,
112
112
  });
113
+
114
+ // If the signal was ALREADY aborted before we attached the
115
+ // listener, addEventListener never fires — the waiter would
116
+ // sit in the map until timeout-sweep / shutdown picked it up.
117
+ // Trigger the cleanup manually so the parked promise rejects
118
+ // immediately (matches "abort fired during park" semantics).
119
+ if (signal && signal.aborted) sigCleanup();
113
120
  });
114
121
  }
115
122
 
package/lib/approvals.js CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  const crypto = require('crypto');
14
+ const { canonicalizeToolInput } = require('./canonical-json');
14
15
 
15
16
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
16
17
  // 16 random bytes → 22 base64url chars ≈ 128 bits of entropy. Prevents
@@ -19,7 +20,14 @@ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
19
20
  const TOKEN_BYTES = 16;
20
21
 
21
22
  function digestInput(input) {
22
- const json = typeof input === 'string' ? input : JSON.stringify(input);
23
+ // Canonicalise object inputs so key-order doesn't change the digest.
24
+ // Pre-fix `JSON.stringify({a:1,b:2})` and `JSON.stringify({b:2,a:1})`
25
+ // produced different hashes — the dedup contract assumed logical
26
+ // equivalence but the impl was order-sensitive, so an SDK that
27
+ // re-serialised the input between turns would dedup-miss.
28
+ const json = typeof input === 'string'
29
+ ? input
30
+ : JSON.stringify(canonicalizeToolInput(input));
23
31
  return crypto.createHash('sha256').update(json).digest('hex').slice(0, 16);
24
32
  }
25
33
 
package/lib/async-lock.js CHANGED
@@ -22,13 +22,21 @@ function createAsyncLock() {
22
22
  const prev = chains.get(key) || Promise.resolve();
23
23
  let release;
24
24
  const next = new Promise((resolve) => { release = resolve; });
25
- chains.set(key, prev.then(() => next));
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);
26
32
  await prev;
27
33
  // Return a wrapper that also clears the chain entry when this is
28
34
  // the last holder — avoids the Map growing unbounded across the
29
- // lifetime of the process.
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).
30
38
  return () => {
31
- if (chains.get(key) === prev.then(() => next)) {
39
+ if (chains.get(key) === myEntry) {
32
40
  chains.delete(key);
33
41
  }
34
42
  release();
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Per-session tracker for messages that received the ✍ AUTOSTEERED
3
+ * reaction, so they can be cleared at turn-end.
4
+ *
5
+ * Why this exists (rc.14): each autosteer invocation runs inside its
6
+ * own `handleMessage` scope with its own `reactor`. When the original
7
+ * (trigger) message's reactor calls `.clear()` at turn-end, it can
8
+ * only clear *its own* message — not the follow-ups whose reactors
9
+ * already called `.stop()` after acking ✍. So we track the
10
+ * (chat_id, message_id) pairs centrally per session and the success-
11
+ * path handler in polygram.js calls `clear(sessionKey)` to drop the
12
+ * reactions in one go.
13
+ *
14
+ * Concurrency: this is a plain Map indexed by sessionKey. Single-
15
+ * thread Node, so add/get/clear race-free.
16
+ *
17
+ * The `applyClear` callback abstracts Telegram's setMessageReaction
18
+ * so tests can inject a fake without spinning up grammy/bot.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ /**
24
+ * @typedef {object} MsgRef
25
+ * @property {number|string} chatId
26
+ * @property {number} msgId
27
+ */
28
+
29
+ /**
30
+ * @typedef {object} AutosteeredRefs
31
+ * @property {(sessionKey: string, ref: MsgRef) => void} add
32
+ * @property {(sessionKey: string) => MsgRef[]} get
33
+ * @property {(sessionKey: string) => Promise<number>} clear
34
+ * resolves with the count of refs that were cleared.
35
+ * @property {(sessionKey: string) => number} size
36
+ * @property {(sessionKey: string) => void} dropSession
37
+ * discard all refs for a session WITHOUT calling applyClear (used
38
+ * when the chat is being torn down — Telegram side will be cleared
39
+ * by the parent reactor).
40
+ */
41
+
42
+ /**
43
+ * @param {object} opts
44
+ * @param {(ref: MsgRef) => Promise<void>} opts.applyClear
45
+ * invoked once per ref during clear(). Errors are caught and
46
+ * logged to opts.logger?.error — they never block clearing of
47
+ * subsequent refs.
48
+ * @param {{ error?: (msg: string) => void }} [opts.logger]
49
+ * @returns {AutosteeredRefs}
50
+ */
51
+ function createAutosteeredRefs({ applyClear, logger = console } = {}) {
52
+ if (typeof applyClear !== 'function') {
53
+ throw new TypeError('applyClear function required');
54
+ }
55
+ /** @type {Map<string, MsgRef[]>} */
56
+ const refs = new Map();
57
+
58
+ function add(sessionKey, ref) {
59
+ if (!sessionKey || !ref || ref.msgId == null || ref.chatId == null) return;
60
+ let list = refs.get(sessionKey);
61
+ if (!list) { list = []; refs.set(sessionKey, list); }
62
+ list.push({ chatId: ref.chatId, msgId: ref.msgId });
63
+ }
64
+
65
+ function get(sessionKey) {
66
+ return refs.get(sessionKey)?.slice() || [];
67
+ }
68
+
69
+ function size(sessionKey) {
70
+ return refs.get(sessionKey)?.length || 0;
71
+ }
72
+
73
+ function dropSession(sessionKey) {
74
+ refs.delete(sessionKey);
75
+ }
76
+
77
+ async function clear(sessionKey) {
78
+ const list = refs.get(sessionKey);
79
+ if (!list || list.length === 0) return 0;
80
+ refs.delete(sessionKey);
81
+ let cleared = 0;
82
+ for (const ref of list) {
83
+ try {
84
+ await applyClear(ref);
85
+ cleared += 1;
86
+ } catch (err) {
87
+ // Ack-clear failures are silent — the ✍ stays on screen but
88
+ // doesn't block the in-flight turn's reply UX.
89
+ logger?.error?.(
90
+ `autosteer-clear failed (chat=${ref.chatId} msg=${ref.msgId}): ${err?.message || err}`,
91
+ );
92
+ }
93
+ }
94
+ return cleared;
95
+ }
96
+
97
+ return { add, get, clear, size, dropSession };
98
+ }
99
+
100
+ module.exports = { createAutosteeredRefs };
@@ -31,11 +31,29 @@ function canonicalizeToolInput(input) {
31
31
  if (input == null || typeof input !== 'object') {
32
32
  return JSON.stringify(input);
33
33
  }
34
+ // Track in-flight (currently-on-stack) nodes to detect circular
35
+ // references. WeakSet membership marks "we are still inside this
36
+ // node"; we drop the entry after finishing recursion so DAG
37
+ // shapes (shared subtrees that aren't cycles) round-trip fine.
38
+ // Pre-fix sortRec recursed forever on `{a: 1, self: <self>}`
39
+ // and crashed the daemon — DoS path if any tool ever produces
40
+ // self-referencing input. Now throws a clean TypeError matching
41
+ // JSON.stringify's own "Converting circular structure to JSON".
42
+ const onStack = new WeakSet();
34
43
  const sortRec = (v) => {
35
- if (Array.isArray(v)) return v.map(sortRec);
44
+ if (Array.isArray(v)) {
45
+ if (onStack.has(v)) throw new TypeError('Converting circular structure to JSON');
46
+ onStack.add(v);
47
+ const result = v.map(sortRec);
48
+ onStack.delete(v);
49
+ return result;
50
+ }
36
51
  if (v == null || typeof v !== 'object') return v;
52
+ if (onStack.has(v)) throw new TypeError('Converting circular structure to JSON');
53
+ onStack.add(v);
37
54
  const out = {};
38
55
  for (const k of Object.keys(v).sort()) out[k] = sortRec(v[k]);
56
+ onStack.delete(v);
39
57
  return out;
40
58
  };
41
59
  return JSON.stringify(sortRec(input));
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Pure formatters for /context command output and the 85%-full hint.
3
+ *
4
+ * Lifted from polygram.js so the formatting can be unit-tested without
5
+ * spinning up the full handleMessage stack. Both functions are pure —
6
+ * no I/O, no Date.now, no module-level state.
7
+ *
8
+ * Background — rc.4 percentage scale:
9
+ * The SDK's `getContextUsage()` returns `percentage` already on a
10
+ * 0-100 scale (verified in rc.3 production: a 77%-used context
11
+ * reported `percentage: 77`). Pre-rc.4 polygram treated it as a
12
+ * 0-1 ratio and multiplied by 100, which displayed "7700% full" and
13
+ * skipped the 85% hint threshold. The formatters below assume the
14
+ * 0-100 scale; do not multiply or divide.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const HINT_THRESHOLD_PCT = 85;
20
+
21
+ /**
22
+ * Format a getContextUsage() result into a multi-line chat reply.
23
+ *
24
+ * @param {object} usage — return value from `Query.getContextUsage()`.
25
+ * Expected fields (all optional, all from SDK):
26
+ * percentage: number (0-100)
27
+ * totalTokens: number
28
+ * maxTokens: number
29
+ * model: string
30
+ * isAutoCompactEnabled: boolean
31
+ * autoCompactThreshold: number (0-100)
32
+ * categories: Array<{ label?: string, name?: string, tokens: number }>
33
+ * @returns {string} pre-formatted text suitable for sendMessage
34
+ */
35
+ function formatContextReply(usage) {
36
+ const u = usage || {};
37
+ const pct = (u.percentage ?? 0).toFixed(0);
38
+ const total = (u.totalTokens ?? 0).toLocaleString();
39
+ const max = (u.maxTokens ?? 0).toLocaleString();
40
+ const lines = [`📚 Context: ${total} / ${max} tokens (${pct}%)`];
41
+ if (u.model) lines.push(`Model: ${u.model}`);
42
+ if (u.isAutoCompactEnabled && u.autoCompactThreshold) {
43
+ const thrPct = u.autoCompactThreshold.toFixed(0);
44
+ lines.push(`Auto-compact at ${thrPct}%.`);
45
+ }
46
+ if (Array.isArray(u.categories) && u.categories.length) {
47
+ const top = [...u.categories]
48
+ .filter((c) => Number.isFinite(c?.tokens) && c.tokens > 0)
49
+ .sort((a, b) => b.tokens - a.tokens)
50
+ .slice(0, 3)
51
+ .map((c) => ` • ${c.label || c.name || '?'}: ${c.tokens.toLocaleString()}`);
52
+ if (top.length) lines.push('Top categories:', ...top);
53
+ }
54
+ return lines.join('\n');
55
+ }
56
+
57
+ /**
58
+ * Decide whether to send the 85% hint and return the hint text if so.
59
+ *
60
+ * @param {object} usage — same shape as formatContextReply input.
61
+ * @returns {string|null} the hint text to send, or null when below threshold.
62
+ */
63
+ function maybeContextFullHint(usage) {
64
+ const pct = usage?.percentage ?? 0;
65
+ if (pct < HINT_THRESHOLD_PCT) return null;
66
+ return [
67
+ `📚 Context window ${pct.toFixed(0)}% full. Three options:`,
68
+ '',
69
+ '• `/new` — start fresh; this conversation ends.',
70
+ '• `/compact` — summarise older messages. Add a hint after the command (e.g. `/compact keep the Q3 commission decisions`) and that becomes the compactor\'s guidance.',
71
+ '• Keep chatting — I\'ll auto-compact when needed; key context is preserved automatically.',
72
+ ].join('\n');
73
+ }
74
+
75
+ module.exports = {
76
+ formatContextReply,
77
+ maybeContextFullHint,
78
+ HINT_THRESHOLD_PCT,
79
+ };
package/lib/pm-router.js CHANGED
@@ -73,6 +73,33 @@ function makeRouterPolicy({ useSdkAll = false, sdkChats = [], getChatIdFromKey }
73
73
  * @param {object|null} opts.sdkPm
74
74
  * @param {(sessionKey: string) => 'sdk'|'cli'} opts.pickPmKindFor
75
75
  */
76
+ /**
77
+ * Broadcast helper for killChat / shutdown. Awaits every task to
78
+ * settlement (success OR rejection), then throws an aggregate error
79
+ * if any task rejected. Single rejections re-throw the original
80
+ * error untouched (no AggregateError noise); multiple rejections
81
+ * surface as `AggregateError` with all causes preserved.
82
+ *
83
+ * Each task entry is `[label, () => Promise]`; the label appears in
84
+ * AggregateError messages so a debugger can tell which pm failed.
85
+ */
86
+ async function broadcastSettle(method, tasks) {
87
+ const results = await Promise.allSettled(tasks.map(([, fn]) => fn()));
88
+ const errors = [];
89
+ results.forEach((r, i) => {
90
+ if (r.status === 'rejected') {
91
+ const tag = tasks[i][0];
92
+ const err = r.reason instanceof Error ? r.reason : new Error(String(r.reason));
93
+ err.pmTag = tag;
94
+ errors.push(err);
95
+ }
96
+ });
97
+ if (errors.length === 1) throw errors[0];
98
+ if (errors.length > 1) {
99
+ throw new AggregateError(errors, `${method} failed in ${errors.length} pms`);
100
+ }
101
+ }
102
+
76
103
  function createPmRouter({ cliPm, sdkPm = null, pickPmKindFor } = {}) {
77
104
  if (!cliPm) throw new TypeError('cliPm required');
78
105
  if (typeof pickPmKindFor !== 'function') {
@@ -98,15 +125,20 @@ function createPmRouter({ cliPm, sdkPm = null, pickPmKindFor } = {}) {
98
125
 
99
126
  // Lifecycle methods broadcast to both pms because a chat may
100
127
  // have spawned sessions on either side at different times.
101
- async killChat(chatId) {
102
- const tasks = [cliPm.killChat(chatId)];
103
- if (sdkPm) tasks.push(sdkPm.killChat(chatId));
104
- await Promise.all(tasks);
128
+ // Promise.allSettled (NOT Promise.all) so a rejection from one
129
+ // pm doesn't abandon the other mid-tear-down. Both must always
130
+ // complete; we then surface aggregated errors. Pre-fix, a cliPm
131
+ // rejection let sdkPm's Query.close() get GC'd with handles
132
+ // still open.
133
+ killChat(chatId) {
134
+ const tasks = [['cli', () => cliPm.killChat(chatId)]];
135
+ if (sdkPm) tasks.push(['sdk', () => sdkPm.killChat(chatId)]);
136
+ return broadcastSettle('killChat', tasks);
105
137
  },
106
- async shutdown() {
107
- const tasks = [cliPm.shutdown()];
108
- if (sdkPm) tasks.push(sdkPm.shutdown());
109
- await Promise.all(tasks);
138
+ shutdown() {
139
+ const tasks = [['cli', () => cliPm.shutdown()]];
140
+ if (sdkPm) tasks.push(['sdk', () => sdkPm.shutdown()]);
141
+ return broadcastSettle('shutdown', tasks);
110
142
  },
111
143
 
112
144
  // Optional methods — forward when the routed pm implements
@@ -224,6 +224,9 @@ class ProcessManagerSdk {
224
224
  // ─── Spawn / pool ────────────────────────────────────────────────
225
225
 
226
226
  async getOrSpawn(sessionKey, spawnContext) {
227
+ if (this._shuttingDown) {
228
+ throw new Error('shutdown');
229
+ }
227
230
  const existing = this.procs.get(sessionKey);
228
231
  if (existing && !existing.closed) return existing;
229
232
 
@@ -232,6 +235,7 @@ class ProcessManagerSdk {
232
235
  if (!evicted) {
233
236
  // All entries in-flight — park.
234
237
  await this._awaitLruSlot();
238
+ if (this._shuttingDown) throw new Error('shutdown');
235
239
  return this.getOrSpawn(sessionKey, spawnContext);
236
240
  }
237
241
  }
@@ -899,18 +903,23 @@ class ProcessManagerSdk {
899
903
  }
900
904
 
901
905
  async shutdown() {
906
+ // Set flag FIRST so any LRU-waiter unparked by _closeEntry's
907
+ // iteration-finally doesn't recurse into a fresh spawn (which
908
+ // would leave an orphaned entry after `procs.clear()` below).
909
+ // Reject parked waiters immediately so their getOrSpawn callers
910
+ // unwind cleanly rather than racing the shutdown.
911
+ this._shuttingDown = true;
912
+ while (this._lruWaiters.length) {
913
+ const w = this._lruWaiters.shift();
914
+ clearTimeout(w.timer);
915
+ w.reject(new Error('shutdown'));
916
+ }
902
917
  const entries = [...this.procs.values()];
903
918
  await Promise.allSettled(entries.map((e) => {
904
919
  this.drainQueue(e.sessionKey, 'SHUTDOWN');
905
920
  return this._closeEntry(e, 'shutdown');
906
921
  }));
907
922
  this.procs.clear();
908
- // Reject any remaining LRU waiters.
909
- while (this._lruWaiters.length) {
910
- const w = this._lruWaiters.shift();
911
- clearTimeout(w.timer);
912
- w.reject(new Error('shutdown'));
913
- }
914
923
  }
915
924
 
916
925
  // ─── Helpers ────────────────────────────────────────────────────
@@ -57,12 +57,19 @@ const DEFAULT_THROTTLE_MS = 800;
57
57
  // 0.7.4 (item A): after this long with no setState() call (Claude is
58
58
  // silently chugging on a long tool / model latency), auto-flip to STALL
59
59
  // (🥱) so the user has a visible cue that the bot is alive but slow.
60
- // 10s matches OpenClaw's "yawn after 10s of nothing".
61
- const DEFAULT_STALL_MS = 10_000;
62
- // 30s without a heartbeat is "we're worried" territory promote to
63
- // TIMEOUT (😨) so the user knows it might be stuck. Distinct from the
64
- // pm's 5-minute hard idle timeout, which actually rejects the turn.
65
- const DEFAULT_FREEZE_MS = 30_000;
60
+ // rc.25: bumped from 10s 45s. The original 10s matched OpenClaw, but
61
+ // SDK pm with effort=high reasoning routinely thinks for 15-30s before
62
+ // firing any tool or text chunk under the old threshold the 🥱 was
63
+ // firing on EVERY substantive turn, training users to ignore it.
64
+ const DEFAULT_STALL_MS = 45_000;
65
+ // rc.25: bumped from 30s → 180s (3 min). The 😨 TIMEOUT was firing
66
+ // during ordinary multi-step agent runs (Ivan DM at 11:32 — bot was
67
+ // actively replying within 20s, but the trigger message stayed at
68
+ // 😨 because the OUTER turn ran for 100+ s across multiple replies
69
+ // and tool calls). Real "stuck" state would be 3+ min of nothing,
70
+ // which 180s captures while letting routine work breathe. Pm has its
71
+ // own 5-minute hard idle timeout that actually rejects stuck turns.
72
+ const DEFAULT_FREEZE_MS = 180_000;
66
73
 
67
74
  // Tool name → state classifier. Case-insensitive substring match so we
68
75
  // don't have to enumerate every existing or future tool. Order matters:
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Polygram-side display constraints injected into every chat's system
3
+ * prompt. This is INFRASTRUCTURE knowledge — the agent's business
4
+ * logic shouldn't have to know that Telegram's `<pre>` block on a
5
+ * portrait iPhone wraps at ~36 monospace chars. The agent decides
6
+ * *what* to render; polygram tells it *how* the surface displays.
7
+ *
8
+ * Why a polygram concern, not an agent concern:
9
+ * - Same agent runs across surfaces (Telegram bot, CLI, future
10
+ * surfaces). Each has its own width / markdown / image support.
11
+ * - Mixing display rules into agent prompts means every agent doc
12
+ * has to be updated when Telegram's rendering changes (or when
13
+ * we onboard a new chat surface). Centralising here keeps
14
+ * `_shumabit-base.md` and friends focused on business logic.
15
+ * - Tested in isolation; no risk of agent drift breaking tables.
16
+ *
17
+ * Width budget — measured 2026-04-30 from production screenshots:
18
+ * - iPhone portrait, default Telegram font: ~36 monospace chars
19
+ * per line in a `<pre>` block before wrap.
20
+ * - iPhone landscape: ~70.
21
+ * - Desktop client (macOS, default): ~85+.
22
+ * Agents see the conservative number (40) so output stays clean on
23
+ * the smallest reasonable surface.
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const TELEGRAM_TABLE_WIDTH_BUDGET = 40;
29
+
30
+ const POLYGRAM_DISPLAY_HINT = [
31
+ '## Telegram display constraints',
32
+ '',
33
+ 'Your replies are sent to Telegram. The user reads them on phone or desktop.',
34
+ '',
35
+ '**Tables:** Telegram renders markdown tables as monospace `<pre>` blocks.',
36
+ `On mobile portrait, lines wrap after ~${TELEGRAM_TABLE_WIDTH_BUDGET} chars and look broken.`,
37
+ '',
38
+ '- Use a markdown table when **every** rendered row (including separators',
39
+ ` and padding) fits in ${TELEGRAM_TABLE_WIDTH_BUDGET} chars or fewer.`,
40
+ '- If any row would exceed that budget, **drop the table** and switch to',
41
+ ' vertical "row blocks": one entity per paragraph, **bold** headline,',
42
+ ' then `Field: value` per data point. Example:',
43
+ '',
44
+ ' ```',
45
+ ' **Mini dress Keen → Black dress mini**',
46
+ ' COGS: ฿546 → ฿1144 (2.1×)',
47
+ ' Margin: 84.8% → 77% ↓',
48
+ '',
49
+ ' **Tank top Sway → Top voluminous cotton**',
50
+ ' COGS: ฿360 → ฿947 (2.6×)',
51
+ ' Margin: 78.7% → 73% ↓',
52
+ ' ```',
53
+ '',
54
+ '- Decide row-by-row before emitting; do not start a wide table assuming',
55
+ ' the user can scroll.',
56
+ '',
57
+ 'Other Telegram quirks:',
58
+ '- Headers `#`, `##`, `###` render as plain text — use **bold** for emphasis.',
59
+ '- Horizontal rules render as a thin divider line.',
60
+ '- Long replies stream in chunks, so prefer concise structure over walls of text.',
61
+ ].join('\n');
62
+
63
+ /**
64
+ * Append the polygram display hint to an existing systemPrompt option,
65
+ * preserving the original shape (string / preset object / undefined).
66
+ * Pure function — does not mutate input.
67
+ *
68
+ * Shapes handled (matches @anthropic-ai/claude-agent-sdk's Options.systemPrompt):
69
+ * - undefined / null → returns `{ type: 'preset', preset: 'claude_code', append: hint }`
70
+ * - string → returns `string + '\n\n' + hint`
71
+ * - { type: 'preset', append?: string }
72
+ * → merges hint into `append`
73
+ * - other (string[], etc.) → returns input unchanged (caller's responsibility)
74
+ *
75
+ * @param {*} systemPromptOpt — current SdkOptions.systemPrompt value
76
+ * @param {string} [hint] — override the default hint (used by tests)
77
+ * @returns {*} new systemPrompt option with the hint appended
78
+ */
79
+ function appendDisplayHint(systemPromptOpt, hint = POLYGRAM_DISPLAY_HINT) {
80
+ if (!hint) return systemPromptOpt;
81
+
82
+ if (systemPromptOpt == null) {
83
+ return { type: 'preset', preset: 'claude_code', append: hint };
84
+ }
85
+
86
+ if (typeof systemPromptOpt === 'string') {
87
+ return `${systemPromptOpt}\n\n${hint}`;
88
+ }
89
+
90
+ if (typeof systemPromptOpt === 'object' && systemPromptOpt.type === 'preset') {
91
+ const existingAppend = typeof systemPromptOpt.append === 'string' ? systemPromptOpt.append : '';
92
+ const newAppend = existingAppend ? `${existingAppend}\n\n${hint}` : hint;
93
+ return { ...systemPromptOpt, append: newAppend };
94
+ }
95
+
96
+ // Unknown shape (e.g. string[]) — return as-is. Caller can opt in
97
+ // by passing a supported shape.
98
+ return systemPromptOpt;
99
+ }
100
+
101
+ /**
102
+ * For the CLI pm (`claude -p ...`), the equivalent of an appended
103
+ * system prompt is the `--append-system-prompt <text>` flag. This
104
+ * helper returns the args the CLI pm should add to its argv.
105
+ *
106
+ * @param {string} [hint] — override (tests)
107
+ * @returns {string[]} — argv tail, e.g. ['--append-system-prompt', '...']
108
+ */
109
+ function appendDisplayHintCliArgs(hint = POLYGRAM_DISPLAY_HINT) {
110
+ if (!hint) return [];
111
+ return ['--append-system-prompt', hint];
112
+ }
113
+
114
+ module.exports = {
115
+ POLYGRAM_DISPLAY_HINT,
116
+ TELEGRAM_TABLE_WIDTH_BUDGET,
117
+ appendDisplayHint,
118
+ appendDisplayHintCliArgs,
119
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.24",
3
+ "version": "0.8.0-rc.26",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc-client.js",
6
6
  "bin": {
package/polygram.js CHANGED
@@ -32,6 +32,7 @@ const { ProcessManager } = require('./lib/process-manager');
32
32
  // soak proves SDK stable. See docs/0.8.0-architecture-decisions.md.
33
33
  const { ProcessManagerSdk } = require('./lib/process-manager-sdk');
34
34
  const { createAutosteerBuffer, makePostToolBatchHook } = require('./lib/autosteer-buffer');
35
+ const { createAutosteeredRefs } = require('./lib/autosteered-refs');
35
36
  const { makeRouterPolicy, createPmRouter } = require('./lib/pm-router');
36
37
  const { canonicalizeToolInput } = require('./lib/canonical-json');
37
38
  const {
@@ -41,6 +42,9 @@ const {
41
42
  approvalCardText,
42
43
  } = require('./lib/approval-ui');
43
44
  const { makeSessionStartHook } = require('./lib/history-preload');
45
+ const { formatContextReply, maybeContextFullHint } = require('./lib/context-format');
46
+ const { appendDisplayHint, appendDisplayHintCliArgs } = require('./lib/telegram-prompt');
47
+ const { createAbortGrace } = require('./lib/abort-grace');
44
48
  const agentLoader = require('./lib/agent-loader');
45
49
  const USE_SDK = process.env.POLYGRAM_USE_SDK === '1';
46
50
  const { createSender } = require('./lib/telegram');
@@ -723,23 +727,18 @@ const autosteerBuffer = createAutosteerBuffer();
723
727
  // the TRIGGER message's reactor.clear() at turn-end couldn't reach
724
728
  // across to other messages. Without this map, users see ✍ stuck on
725
729
  // every follow-up and don't know whether the bot incorporated them.
726
- const autosteeredMsgRefs = new Map(); // sessionKey → [{chatId, msgId}]
730
+ const autosteeredRefs = createAutosteeredRefs({
731
+ applyClear: async ({ chatId, msgId }) => {
732
+ if (!bot) return;
733
+ await tg(bot, 'setMessageReaction', {
734
+ chat_id: chatId, message_id: msgId, reaction: [],
735
+ }, { source: 'autosteer-clear', botName: BOT_NAME });
736
+ },
737
+ logger: { error: (m) => console.error(`[${BOT_NAME}] ${m}`) },
738
+ });
727
739
 
728
740
  async function clearAutosteeredReactions(sessionKey) {
729
- const list = autosteeredMsgRefs.get(sessionKey);
730
- if (!list || list.length === 0) return;
731
- autosteeredMsgRefs.delete(sessionKey);
732
- if (!bot) return;
733
- for (const { chatId: cid, msgId } of list) {
734
- try {
735
- await tg(bot, 'setMessageReaction', {
736
- chat_id: cid, message_id: msgId, reaction: [],
737
- }, { source: 'autosteer-clear', botName: BOT_NAME });
738
- } catch (err) {
739
- // Ack-clear failures are silent — the ✍ stays on screen
740
- // but doesn't block the in-flight turn's reply UX.
741
- }
742
- }
741
+ return autosteeredRefs.clear(sessionKey);
743
742
  }
744
743
 
745
744
  // 0.8.0-rc.14: tool-less-turn drain. PostToolBatch hook only fires
@@ -820,6 +819,10 @@ function spawnClaude(sessionKey, ctx) {
820
819
  ];
821
820
  if (chatConfig.agent) args.push('--agent', chatConfig.agent);
822
821
  if (existingSessionId) args.push('--resume', existingSessionId);
822
+ // Polygram-side display constraints — same hint the SDK pm appends
823
+ // via Options.systemPrompt. Keeps the table-width rule in
824
+ // infrastructure, not in agent docs.
825
+ args.push(...appendDisplayHintCliArgs());
823
826
 
824
827
  console.log(`[${label}] Spawning process (${chatConfig.model}/${chatConfig.effort})`);
825
828
 
@@ -973,7 +976,7 @@ function buildSdkOptions(sessionKey, ctx) {
973
976
  // precedence: chatConfig > agent > defaults. The chatConfig keys
974
977
  // we care about for SDK options are model/effort/cwd/thinking;
975
978
  // others (agent, chrome, isolateTopics) are polygram-only.
976
- return agentLoader.composeSdkOptions(
979
+ const composed = agentLoader.composeSdkOptions(
977
980
  {
978
981
  // chat-level overrides — only the keys SDK understands.
979
982
  model: chatConfig.model,
@@ -984,6 +987,13 @@ function buildSdkOptions(sessionKey, ctx) {
984
987
  agentBundle,
985
988
  baseOpts,
986
989
  );
990
+
991
+ // Append polygram's display constraints to the systemPrompt.
992
+ // Infrastructure-layer hint — the agent's own prompt covers
993
+ // business logic; polygram adds "your output renders in Telegram,
994
+ // here's the width budget for tables".
995
+ composed.systemPrompt = appendDisplayHint(composed.systemPrompt);
996
+ return composed;
987
997
  }
988
998
 
989
999
  function buildSpawnContext(sessionKey) {
@@ -1092,30 +1102,16 @@ function errorReplyText(err) {
1092
1102
  return userMessage; // may be null — caller must handle
1093
1103
  }
1094
1104
 
1095
- // Sessions the operator just /stop'd (or natural-language "стоп"). Keyed
1096
- // by sessionKey timestamp of abort. ANY pending that rejects within
1097
- // ABORT_GRACE_MS of the mark is considered abort-caused its generic
1098
- // error reply is suppressed and the streamer warning is skipped.
1099
- //
1100
- // Timestamp model (vs the earlier "delete after first read" Set) fixes
1101
- // the case where multiple pendings were in-flight at abort time: all of
1102
- // them reject with "Process killed", all of them should be silent, not
1103
- // just the first one.
1104
- const ABORT_GRACE_MS = 15_000;
1105
- const abortedSessions = new Map();
1106
-
1107
- function markSessionAborted(sessionKey) {
1108
- abortedSessions.set(sessionKey, Date.now());
1109
- // Sweep old entries opportunistically.
1110
- for (const [k, ts] of abortedSessions) {
1111
- if (Date.now() - ts > ABORT_GRACE_MS * 2) abortedSessions.delete(k);
1112
- }
1113
- }
1105
+ // Sessions the operator just /stop'd (or natural-language "стоп").
1106
+ // rc.25: extracted to lib/abort-grace.js so the timestamp/window
1107
+ // logic has its own unit tests. Behaviour identical: any pending
1108
+ // rejected within the grace window is considered abort-caused
1109
+ // its generic error reply is suppressed and the streamer warning
1110
+ // is skipped.
1111
+ const abortGrace = createAbortGrace();
1114
1112
 
1115
- function isSessionRecentlyAborted(sessionKey) {
1116
- const ts = abortedSessions.get(sessionKey);
1117
- return ts != null && (Date.now() - ts) < ABORT_GRACE_MS;
1118
- }
1113
+ function markSessionAborted(sessionKey) { abortGrace.mark(sessionKey); }
1114
+ function isSessionRecentlyAborted(sessionKey) { return abortGrace.isRecent(sessionKey); }
1119
1115
 
1120
1116
  // Called by bot.on('message') for every regular (non-admin, non-pair)
1121
1117
  // message. Runs handleMessage in a fire-and-forget manner with centralised
@@ -1955,32 +1951,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1955
1951
  }
1956
1952
  try {
1957
1953
  const u = await q.getContextUsage();
1958
- // SDK returns percentage in 0-100 scale (verified rc.3 prod
1959
- // — saw "77" for a 77%-used context). Display directly.
1960
- const pct = (u?.percentage ?? 0).toFixed(0);
1961
- const total = (u?.totalTokens ?? 0).toLocaleString();
1962
- const max = (u?.maxTokens ?? 0).toLocaleString();
1963
- const lines = [`📚 Context: ${total} / ${max} tokens (${pct}%)`];
1964
- if (u?.model) lines.push(`Model: ${u.model}`);
1965
- if (u?.isAutoCompactEnabled && u?.autoCompactThreshold) {
1966
- // autoCompactThreshold scale is currently unverified; assume
1967
- // matches percentage (0-100). If it turns out to be 0-1 we'll
1968
- // see something like "Auto-compact at 0%" and can flip back.
1969
- const thrPct = u.autoCompactThreshold.toFixed(0);
1970
- lines.push(`Auto-compact at ${thrPct}%.`);
1971
- }
1972
- // Top-3 categories by token cost so the user knows where the
1973
- // budget is going. SDK exposes a rich breakdown in
1974
- // u.categories — we just summarise.
1975
- if (Array.isArray(u?.categories) && u.categories.length) {
1976
- const top = [...u.categories]
1977
- .filter((c) => Number.isFinite(c?.tokens) && c.tokens > 0)
1978
- .sort((a, b) => b.tokens - a.tokens)
1979
- .slice(0, 3)
1980
- .map((c) => ` • ${c.label || c.name || '?'}: ${c.tokens.toLocaleString()}`);
1981
- if (top.length) lines.push('Top categories:', ...top);
1982
- }
1983
- await sendReply(lines.join('\n'));
1954
+ await sendReply(formatContextReply(u));
1984
1955
  } catch (err) {
1985
1956
  console.error(`[${label}] /context failed: ${err.message}`);
1986
1957
  await sendReply(`📚 Couldn't fetch context info: ${err.message}`);
@@ -2509,9 +2480,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2509
2480
  if (ok) {
2510
2481
  // Track this msg_id so the in-flight turn's success / abort
2511
2482
  // / error path can clear the ✍ reaction at turn-end.
2512
- const refs = autosteeredMsgRefs.get(sessionKey) || [];
2513
- refs.push({ chatId, msgId: msg.message_id });
2514
- autosteeredMsgRefs.set(sessionKey, refs);
2483
+ autosteeredRefs.add(sessionKey, { chatId, msgId: msg.message_id });
2515
2484
  logEvent('autosteer', {
2516
2485
  chat_id: chatId, msg_id: msg.message_id,
2517
2486
  text_len: prompt?.length ?? 0,
@@ -2642,25 +2611,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2642
2611
  const q = entry?.query;
2643
2612
  if (q && typeof q.getContextUsage === 'function') {
2644
2613
  q.getContextUsage().then((usage) => {
2645
- // SDK returns percentage in 0-100 scale, not 0-1.
2646
- // Pre-rc.4 we treated it as a 0-1 ratio and multiplied
2647
- // by 100, which displayed "7700% full" for a 77%-used
2648
- // context (and fired below the intended 85% threshold).
2649
- const pct = usage?.percentage ?? 0;
2650
- if (pct < 85) return;
2651
- // rc.22: three-choice hint. The original "send /new"
2652
- // message implied the only path forward was a hard
2653
- // reset. Now offer all three options the user actually
2654
- // has — start fresh, compact with their preserve
2655
- // instructions, or keep going (auto-compact eventually
2656
- // fires).
2657
- const text = [
2658
- `📚 Context window ${pct.toFixed(0)}% full. Three options:`,
2659
- '',
2660
- '• `/new` — start fresh; this conversation ends.',
2661
- '• `/compact` — summarise older messages. Add a hint after the command (e.g. `/compact keep the Q3 commission decisions`) and that becomes the compactor\'s guidance.',
2662
- '• Keep chatting — I\'ll auto-compact when needed; key context is preserved automatically.',
2663
- ].join('\n');
2614
+ const text = maybeContextFullHint(usage);
2615
+ if (!text) return;
2664
2616
  return tg(bot, 'sendMessage', {
2665
2617
  chat_id: chatId,
2666
2618
  text,
@@ -3624,6 +3576,13 @@ async function main() {
3624
3576
  const head = entry.pendingQueue?.[0];
3625
3577
  const s = head?.context?.streamer;
3626
3578
  if (s) s.forceNewMessage();
3579
+ // rc.25: heartbeat at every assistant-message boundary too. A
3580
+ // long thinking phase (effort=high, 30+ s before first chunk)
3581
+ // doesn't fire onStreamChunk. Without this, the freeze timer
3582
+ // could expire while the model is "still thinking but about
3583
+ // to speak".
3584
+ const r = head?.context?.reactor;
3585
+ if (r && typeof r.heartbeat === 'function') r.heartbeat();
3627
3586
  },
3628
3587
  // 0.8.0 Phase 2 step 5: SDK auto-compaction observability. Fires
3629
3588
  // when SDK emits SDKCompactBoundaryMessage (between turns or