polygram 0.12.0-rc.20 → 0.12.0-rc.22

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.
@@ -17,6 +17,7 @@
17
17
  'use strict';
18
18
 
19
19
  const { toTelegramHtml } = require('../telegram/format');
20
+ const { getTopicConfig } = require('../session-key');
20
21
 
21
22
  const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
22
23
  const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
@@ -92,8 +93,13 @@ function createHandleConfigCallback({
92
93
  // tapped into.
93
94
  const existingRows = ctx.callbackQuery.message?.reply_markup?.inline_keyboard?.length || 0;
94
95
  const showRow = existingRows >= 2 ? 'all' : setting;
95
- const newInfo = formatConfigInfoText(chatConfig, showRow, chatId);
96
- const newKeyboard = buildConfigKeyboard(chatConfig, showRow);
96
+ // Re-render with per-topic overrides resolved (topic > chat), so the agent
97
+ // line doesn't flip back to the chat-level default after a button tap —
98
+ // mirrors the /model command card (polygram.js). getTopicConfig returns {}
99
+ // for the chat-level card.
100
+ const _cbTopicCfg = getTopicConfig(chatConfig, callbackThreadId);
101
+ const newInfo = formatConfigInfoText(chatConfig, showRow, chatId, _cbTopicCfg);
102
+ const newKeyboard = buildConfigKeyboard(chatConfig, showRow, _cbTopicCfg);
97
103
  try {
98
104
  const { text: html, parseMode } = toTelegramHtml(newInfo);
99
105
  await ctx.editMessageText(html, {
@@ -31,19 +31,23 @@ const MODEL_VERSIONS_DESC = {
31
31
  /**
32
32
  * Build the inline keyboard for /model + /effort.
33
33
  * show = 'model' | 'effort' | 'all'
34
- * The current value gets a ✓ prefix.
34
+ * The current value gets a ✓ prefix. `topicConfig` (per-topic overrides, or
35
+ * null for the chat-level card) wins over chatConfig so the ✓ matches what a
36
+ * topic actually runs — mirrors the spawn-path precedence (topic > chat).
35
37
  */
36
- function buildConfigKeyboard(chatConfig, show = 'all') {
38
+ function buildConfigKeyboard(chatConfig, show = 'all', topicConfig = null) {
39
+ const model = (topicConfig && topicConfig.model) || chatConfig.model;
40
+ const effort = (topicConfig && topicConfig.effort) || chatConfig.effort;
37
41
  const rows = [];
38
42
  if (show === 'model' || show === 'all') {
39
43
  rows.push(MODEL_OPTIONS.map((m) => ({
40
- text: m === chatConfig.model ? `✓ ${m}` : m,
44
+ text: m === model ? `✓ ${m}` : m,
41
45
  callback_data: `cfg:model:${m}`,
42
46
  })));
43
47
  }
44
48
  if (show === 'effort' || show === 'all') {
45
49
  rows.push(EFFORT_OPTIONS.map((e) => ({
46
- text: e === chatConfig.effort ? `✓ ${e}` : e,
50
+ text: e === effort ? `✓ ${e}` : e,
47
51
  callback_data: `cfg:effort:${e}`,
48
52
  })));
49
53
  }
@@ -60,14 +64,24 @@ function buildConfigKeyboard(chatConfig, show = 'all') {
60
64
  * @param {(db, sessionKey) => string|null} deps.getClaudeSessionId
61
65
  */
62
66
  function createFormatConfigInfoText({ pm, db, getClaudeSessionId } = {}) {
63
- return function formatConfigInfoText(chatConfig, show, sessionKey) {
67
+ return function formatConfigInfoText(chatConfig, show, sessionKey, topicConfig = null) {
64
68
  const alive = pm.has(sessionKey) && !pm.get(sessionKey).closed;
65
- const ver = MODEL_VERSIONS_DESC[chatConfig.model] || chatConfig.model;
69
+ // Per-topic overrides win over chat-level for the displayed values,
70
+ // mirroring the spawn path (polygram.js: topicConfig.agent ||
71
+ // chatConfig.agent). Pre-fix the card always read chat-level, so a topic's
72
+ // /model showed the WRONG agent — shumorobot Music topic (thread 3) showed
73
+ // "Agent: shumabit" instead of its music-curation:music-curator override
74
+ // (2026-06-03). topicConfig defaults to null (chat-level) for callers with
75
+ // no active topic.
76
+ const model = (topicConfig && topicConfig.model) || chatConfig.model;
77
+ const effort = (topicConfig && topicConfig.effort) || chatConfig.effort;
78
+ const agent = (topicConfig && topicConfig.agent) || chatConfig.agent;
79
+ const ver = MODEL_VERSIONS_DESC[model] || model;
66
80
  const sess = getClaudeSessionId(db, sessionKey)?.slice(0, 8) || 'new';
67
81
  const head =
68
- `Model: ${chatConfig.model} (${ver})\n` +
69
- `Effort: ${chatConfig.effort}\n` +
70
- `Agent: ${chatConfig.agent}\n` +
82
+ `Model: ${model} (${ver})\n` +
83
+ `Effort: ${effort}\n` +
84
+ `Agent: ${agent}\n` +
71
85
  `Process: ${alive ? 'warm' : 'cold'}\n` +
72
86
  `Session: ${sess}`;
73
87
 
@@ -113,6 +113,20 @@ const MID_TURN_PROMPTS = [
113
113
  // hook process.
114
114
  const STREAMING_HINT_RE = /esc to interrupt/i;
115
115
 
116
+ // 0.12.0 background-work lifecycle: claude's TUI mode line shows a live
117
+ // background-shell COUNT while a `run_in_background:true` Bash outlives its turn,
118
+ // e.g. `⏵⏵ auto mode on · 1 shell · ← for agents · ↓ to manage`. Confirmed on
119
+ // claude 2.1.158 (P0 spike — docs/0.12.0-background-work-lifecycle-plan.md): the
120
+ // count is always-present in the viewport mode line while shells run and clears
121
+ // IN-PLACE within ~3s when they exit (no stale scrollback). Anchored to the
122
+ // `auto mode on` line and matched only against the captured TAIL so a scrolled-off
123
+ // history line never trips it. R1: re-validate on each pinned-claude bump.
124
+ const BACKGROUND_SHELL_RE = /auto mode on[^\n]*·\s*(\d+)\s+shells?\b/i;
125
+ // How long a detached background shell may run AFTER its turn resolved (claude
126
+ // idle) before the stall-watchdog fires one read-only self-check. Override via
127
+ // the constructor (tests use a small value).
128
+ const DEFAULT_BG_WORK_STALL_MS = 600_000; // 10 min
129
+
116
130
  // 0.12 Phase 3.3 (Q1 resolution): heuristic for "looks like an unknown
117
131
  // interactive prompt." Match common prompt shapes that don't appear in
118
132
  // MID_TURN_PROMPTS — operator gets a telemetry event so they can decide
@@ -172,6 +186,7 @@ class CliProcess extends Process {
172
186
  turnQuietMs = DEFAULT_TURN_QUIET_MS,
173
187
  turnTimeoutMs = DEFAULT_TURN_TIMEOUT_MS,
174
188
  turnAbsoluteMs = DEFAULT_TURN_ABSOLUTE_MS,
189
+ bgWorkStallMs = DEFAULT_BG_WORK_STALL_MS,
175
190
  interruptGraceMs = DEFAULT_INTERRUPT_GRACE_MS,
176
191
  maxRepliesPerTurn = DEFAULT_MAX_REPLIES_PER_TURN,
177
192
  queueCap = DEFAULT_QUEUE_CAP, // Parity P2
@@ -203,6 +218,7 @@ class CliProcess extends Process {
203
218
  this.turnQuietMs = turnQuietMs;
204
219
  this.turnTimeoutMs = turnTimeoutMs;
205
220
  this.turnAbsoluteMs = turnAbsoluteMs;
221
+ this.bgWorkStallMs = bgWorkStallMs;
206
222
  this.interruptGraceMs = interruptGraceMs;
207
223
  this.maxRepliesPerTurn = maxRepliesPerTurn;
208
224
  this.queueCap = queueCap;
@@ -226,6 +242,12 @@ class CliProcess extends Process {
226
242
  // interval fires bridge-disconnected if too much time elapses.
227
243
  this.lastPongAt = 0;
228
244
  this.pongWatchdog = null;
245
+ // 0.12.0 background-work stall-watchdog state. `_bgWorkSince` = when a live
246
+ // background shell was first observed while idle (null = none); reset only
247
+ // when the shell count returns to 0. `_bgWorkEscalations` caps the watchdog
248
+ // at one read-only self-check per continuous background-work window.
249
+ this._bgWorkSince = null;
250
+ this._bgWorkEscalations = 0;
229
251
  // Review P2 ADV-6: token-bucket rate limit on Claude's reply tool calls.
230
252
  // Without this, a prompt-injected or runaway Claude can fire reply() 1000×
231
253
  // in a tight loop, flooding TG + saturating the daemon event loop.
@@ -1531,7 +1553,7 @@ class CliProcess extends Process {
1531
1553
  */
1532
1554
  async probeBusyState() {
1533
1555
  const base = {
1534
- busy: false, streaming: false,
1556
+ busy: false, streaming: false, backgroundShell: false, shellCount: 0,
1535
1557
  inFlight: this.inFlight, pendingTurns: this.pendingTurns.size,
1536
1558
  captured: false, paneTail: null,
1537
1559
  };
@@ -1547,10 +1569,23 @@ class CliProcess extends Process {
1547
1569
  }
1548
1570
  if (!pane) return base;
1549
1571
  const streaming = STREAMING_HINT_RE.test(pane);
1572
+ // Background-shell count from the TUI mode line. Match only the captured
1573
+ // TAIL (the mode line lives at the bottom of the viewport) so a `· N shell ·`
1574
+ // string scrolled off into history can't trip a stale false-positive — see
1575
+ // BACKGROUND_SHELL_RE. A detached `run_in_background` Bash that outlived its
1576
+ // turn shows here even while claude is idle and not streaming.
1577
+ const m = pane.slice(-400).match(BACKGROUND_SHELL_RE);
1578
+ const shellCount = m ? parseInt(m[1], 10) : 0;
1579
+ const backgroundShell = shellCount > 0;
1550
1580
  return {
1551
1581
  ...base,
1582
+ // `busy` stays streaming-only — it is the abort path's "is claude working a
1583
+ // turn" signal and must not change behaviour. Background-shell liveness is a
1584
+ // separate axis the stall-watchdog reads via `backgroundShell`/`shellCount`.
1552
1585
  busy: streaming,
1553
1586
  streaming,
1587
+ backgroundShell,
1588
+ shellCount,
1554
1589
  captured: true,
1555
1590
  paneTail: pane.slice(-200),
1556
1591
  };
@@ -1562,6 +1597,76 @@ class CliProcess extends Process {
1562
1597
  return busy;
1563
1598
  }
1564
1599
 
1600
+ /**
1601
+ * Does this session have a detached background shell running RIGHT NOW — a
1602
+ * `run_in_background` Bash that may have outlived its turn? Thin probe over
1603
+ * probeBusyState's background-shell signal; the stall-watchdog's input.
1604
+ * @returns {Promise<{live:boolean, count:number}>}
1605
+ */
1606
+ async hasLiveBackgroundWork() {
1607
+ const { backgroundShell, shellCount } = await this.probeBusyState();
1608
+ return { live: backgroundShell, count: shellCount };
1609
+ }
1610
+
1611
+ /**
1612
+ * Stall-watchdog for detached background work (0.12.0 background-work
1613
+ * lifecycle, shumorobot Music 7h frozen-Chrome download). Runs on the
1614
+ * pongWatchdog 5s tick but ONLY while the session is IDLE (pendingTurns===0) —
1615
+ * the mirror of _pollMidTurnDialogs, which only runs DURING turns. When a
1616
+ * `run_in_background` Bash outlives its turn and keeps running while claude is
1617
+ * idle for > bgWorkStallMs, nothing tells the agent or user whether it's
1618
+ * progressing or stuck. One read-only self-check re-invokes the agent to
1619
+ * diagnose — via `fireUserMessage`, NOT `injectUserMessage` (which no-ops when
1620
+ * !inFlight, the exact idle state here). Read-only framing matters: the agent
1621
+ * runs bypassPermissions, so an open-ended "fix it" could background another
1622
+ * hung shell unattended.
1623
+ *
1624
+ * Exactly one self-check per continuous background-work window (capped by
1625
+ * `_bgWorkEscalations`); the window resets only when the shell count returns to
1626
+ * 0. Never throws — swallows its own errors so the pong watchdog stays clean.
1627
+ */
1628
+ async _pollBackgroundWork() {
1629
+ if (this.closed || !this.bridgeReady) return;
1630
+ // Only watch while idle. An active turn means the agent is engaged
1631
+ // (_pollMidTurnDialogs owns that path). Crucially we do NOT reset the clock
1632
+ // here — the same shell is still running, so the window persists across a
1633
+ // brief self-check turn rather than restarting and re-pinging every window.
1634
+ if (this.pendingTurns.size > 0) return;
1635
+ let live = false;
1636
+ let count = 0;
1637
+ try {
1638
+ ({ live, count } = await this.hasLiveBackgroundWork());
1639
+ } catch (err) {
1640
+ this.logger.warn?.(`[${this.label}] channels: bg-work probe failed: ${err.message}`);
1641
+ return;
1642
+ }
1643
+ if (!live) {
1644
+ if (this._bgWorkSince !== null) {
1645
+ this._logEvent('cli-bg-work-cleared', { idle_ms: Date.now() - this._bgWorkSince });
1646
+ }
1647
+ this._bgWorkSince = null;
1648
+ this._bgWorkEscalations = 0;
1649
+ return;
1650
+ }
1651
+ if (this._bgWorkSince === null) {
1652
+ // First idle observation of a live background shell — start the clock.
1653
+ this._bgWorkSince = Date.now();
1654
+ this._bgWorkEscalations = 0;
1655
+ this._logEvent('cli-bg-work-detected', { shell_count: count });
1656
+ return;
1657
+ }
1658
+ const idleMs = Date.now() - this._bgWorkSince;
1659
+ if (idleMs < this.bgWorkStallMs || this._bgWorkEscalations >= 1) return;
1660
+ const mins = Math.max(1, Math.round(idleMs / 60000));
1661
+ const prompt =
1662
+ `⏳ A background job has been running ~${mins} min with no update. `
1663
+ + `Check its status and report whether it's progressing or stuck. `
1664
+ + `Do NOT start new work, re-run it, or kill anything — report only.`;
1665
+ const fired = this.fireUserMessage(prompt);
1666
+ this._bgWorkEscalations = 1;
1667
+ this._logEvent('cli-bg-work-stall-selfcheck', { idle_ms: idleMs, shell_count: count, fired });
1668
+ }
1669
+
1565
1670
  async kill(reason = 'kill') {
1566
1671
  if (this.closed) return;
1567
1672
  // Parity P19: re-entry guard for concurrent kill() calls. Mirrors
@@ -2397,6 +2502,11 @@ class CliProcess extends Process {
2397
2502
  this._pollMidTurnDialogs().catch((err) => {
2398
2503
  this.logger.warn?.(`[${this.label}] channels: mid-turn poll failed: ${err.message}`);
2399
2504
  });
2505
+ // 0.12.0 background-work lifecycle: idle-side stall-watchdog, the mirror of
2506
+ // _pollMidTurnDialogs (which only runs during turns). Fire-and-forget.
2507
+ this._pollBackgroundWork().catch((err) => {
2508
+ this.logger.warn?.(`[${this.label}] channels: bg-work poll failed: ${err.message}`);
2509
+ });
2400
2510
  }, PONG_CHECK_INTERVAL_MS);
2401
2511
  this.pongWatchdog.unref?.();
2402
2512
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.12.0-rc.20",
3
+ "version": "0.12.0-rc.22",
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
@@ -740,8 +740,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
740
740
 
741
741
  if (botAllowsCommands && (text === '/model' || text === '/config' || text === '/effort')) {
742
742
  const show = text === '/effort' ? 'effort' : text === '/model' ? 'model' : 'all';
743
- const info = formatConfigInfoText(chatConfig, show, sessionKey);
744
- const reply_markup = buildConfigKeyboard(chatConfig, show);
743
+ // Resolve per-topic overrides so a topic's card shows its REAL
744
+ // agent/model/effort, not the chat-level default — Music topic (thread 3)
745
+ // showed "Agent: shumabit" instead of music-curation:music-curator
746
+ // (2026-06-03). getTopicConfig returns {} when there's no active topic.
747
+ const _cardTopicCfg = getTopicConfig(chatConfig, threadIdStr || null);
748
+ const info = formatConfigInfoText(chatConfig, show, sessionKey, _cardTopicCfg);
749
+ const reply_markup = buildConfigKeyboard(chatConfig, show, _cardTopicCfg);
745
750
  await sendReply(info, { params: { reply_markup } });
746
751
  return;
747
752
  }