polygram 0.12.0-rc.21 → 0.12.0-rc.23

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.
@@ -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,15 @@ 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;
251
+ // Visibility (Use 3): whether a "⏳ working in background" status message is
252
+ // currently shown, so we emit exactly one running→cleared pair per window.
253
+ this._bgWorkStatusShown = false;
229
254
  // Review P2 ADV-6: token-bucket rate limit on Claude's reply tool calls.
230
255
  // Without this, a prompt-injected or runaway Claude can fire reply() 1000×
231
256
  // in a tight loop, flooding TG + saturating the daemon event loop.
@@ -1531,7 +1556,7 @@ class CliProcess extends Process {
1531
1556
  */
1532
1557
  async probeBusyState() {
1533
1558
  const base = {
1534
- busy: false, streaming: false,
1559
+ busy: false, streaming: false, backgroundShell: false, shellCount: 0,
1535
1560
  inFlight: this.inFlight, pendingTurns: this.pendingTurns.size,
1536
1561
  captured: false, paneTail: null,
1537
1562
  };
@@ -1547,10 +1572,23 @@ class CliProcess extends Process {
1547
1572
  }
1548
1573
  if (!pane) return base;
1549
1574
  const streaming = STREAMING_HINT_RE.test(pane);
1575
+ // Background-shell count from the TUI mode line. Match only the captured
1576
+ // TAIL (the mode line lives at the bottom of the viewport) so a `· N shell ·`
1577
+ // string scrolled off into history can't trip a stale false-positive — see
1578
+ // BACKGROUND_SHELL_RE. A detached `run_in_background` Bash that outlived its
1579
+ // turn shows here even while claude is idle and not streaming.
1580
+ const m = pane.slice(-400).match(BACKGROUND_SHELL_RE);
1581
+ const shellCount = m ? parseInt(m[1], 10) : 0;
1582
+ const backgroundShell = shellCount > 0;
1550
1583
  return {
1551
1584
  ...base,
1585
+ // `busy` stays streaming-only — it is the abort path's "is claude working a
1586
+ // turn" signal and must not change behaviour. Background-shell liveness is a
1587
+ // separate axis the stall-watchdog reads via `backgroundShell`/`shellCount`.
1552
1588
  busy: streaming,
1553
1589
  streaming,
1590
+ backgroundShell,
1591
+ shellCount,
1554
1592
  captured: true,
1555
1593
  paneTail: pane.slice(-200),
1556
1594
  };
@@ -1562,6 +1600,84 @@ class CliProcess extends Process {
1562
1600
  return busy;
1563
1601
  }
1564
1602
 
1603
+ /**
1604
+ * Does this session have a detached background shell running RIGHT NOW — a
1605
+ * `run_in_background` Bash that may have outlived its turn? Thin probe over
1606
+ * probeBusyState's background-shell signal; the stall-watchdog's input.
1607
+ * @returns {Promise<{live:boolean, count:number}>}
1608
+ */
1609
+ async hasLiveBackgroundWork() {
1610
+ const { backgroundShell, shellCount } = await this.probeBusyState();
1611
+ return { live: backgroundShell, count: shellCount };
1612
+ }
1613
+
1614
+ /**
1615
+ * Stall-watchdog for detached background work (0.12.0 background-work
1616
+ * lifecycle, shumorobot Music 7h frozen-Chrome download). Runs on the
1617
+ * pongWatchdog 5s tick but ONLY while the session is IDLE (pendingTurns===0) —
1618
+ * the mirror of _pollMidTurnDialogs, which only runs DURING turns. When a
1619
+ * `run_in_background` Bash outlives its turn and keeps running while claude is
1620
+ * idle for > bgWorkStallMs, nothing tells the agent or user whether it's
1621
+ * progressing or stuck. One read-only self-check re-invokes the agent to
1622
+ * diagnose — via `fireUserMessage`, NOT `injectUserMessage` (which no-ops when
1623
+ * !inFlight, the exact idle state here). Read-only framing matters: the agent
1624
+ * runs bypassPermissions, so an open-ended "fix it" could background another
1625
+ * hung shell unattended.
1626
+ *
1627
+ * Exactly one self-check per continuous background-work window (capped by
1628
+ * `_bgWorkEscalations`); the window resets only when the shell count returns to
1629
+ * 0. Never throws — swallows its own errors so the pong watchdog stays clean.
1630
+ */
1631
+ async _pollBackgroundWork() {
1632
+ if (this.closed || !this.bridgeReady) return;
1633
+ // Only watch while idle. An active turn means the agent is engaged
1634
+ // (_pollMidTurnDialogs owns that path). Crucially we do NOT reset the clock
1635
+ // here — the same shell is still running, so the window persists across a
1636
+ // brief self-check turn rather than restarting and re-pinging every window.
1637
+ if (this.pendingTurns.size > 0) return;
1638
+ let live = false;
1639
+ let count = 0;
1640
+ try {
1641
+ ({ live, count } = await this.hasLiveBackgroundWork());
1642
+ } catch (err) {
1643
+ this.logger.warn?.(`[${this.label}] channels: bg-work probe failed: ${err.message}`);
1644
+ return;
1645
+ }
1646
+ if (!live) {
1647
+ if (this._bgWorkSince !== null) {
1648
+ this._logEvent('cli-bg-work-cleared', { idle_ms: Date.now() - this._bgWorkSince });
1649
+ // Visibility: tear down the status indicator once work clears.
1650
+ if (this._bgWorkStatusShown) {
1651
+ this.emit('bg-work-status', { state: 'cleared' });
1652
+ this._bgWorkStatusShown = false;
1653
+ }
1654
+ }
1655
+ this._bgWorkSince = null;
1656
+ this._bgWorkEscalations = 0;
1657
+ return;
1658
+ }
1659
+ if (this._bgWorkSince === null) {
1660
+ // First idle observation of a live background shell — start the clock AND
1661
+ // raise the visibility indicator so a long job reads as working, not stuck.
1662
+ this._bgWorkSince = Date.now();
1663
+ this._bgWorkEscalations = 0;
1664
+ this._logEvent('cli-bg-work-detected', { shell_count: count });
1665
+ this.emit('bg-work-status', { state: 'running', count });
1666
+ this._bgWorkStatusShown = true;
1667
+ return;
1668
+ }
1669
+ const idleMs = Date.now() - this._bgWorkSince;
1670
+ if (idleMs < this.bgWorkStallMs || this._bgWorkEscalations >= 1) return;
1671
+ const mins = Math.max(1, Math.round(idleMs / 60000));
1672
+ const prompt =
1673
+ `⏳ A background job has been running ~${mins} min with no update. `
1674
+ + `Check its status and report whether it's progressing or stuck. `
1675
+ + `Do NOT start new work, re-run it, or kill anything — report only.`;
1676
+ const fired = this.fireUserMessage(prompt);
1677
+ this._bgWorkEscalations = 1;
1678
+ this._logEvent('cli-bg-work-stall-selfcheck', { idle_ms: idleMs, shell_count: count, fired });
1679
+ }
1680
+
1565
1681
  async kill(reason = 'kill') {
1566
1682
  if (this.closed) return;
1567
1683
  // Parity P19: re-entry guard for concurrent kill() calls. Mirrors
@@ -2397,6 +2513,11 @@ class CliProcess extends Process {
2397
2513
  this._pollMidTurnDialogs().catch((err) => {
2398
2514
  this.logger.warn?.(`[${this.label}] channels: mid-turn poll failed: ${err.message}`);
2399
2515
  });
2516
+ // 0.12.0 background-work lifecycle: idle-side stall-watchdog, the mirror of
2517
+ // _pollMidTurnDialogs (which only runs during turns). Fire-and-forget.
2518
+ this._pollBackgroundWork().catch((err) => {
2519
+ this.logger.warn?.(`[${this.label}] channels: bg-work poll failed: ${err.message}`);
2520
+ });
2400
2521
  }, PONG_CHECK_INTERVAL_MS);
2401
2522
  this.pongWatchdog.unref?.();
2402
2523
  }
@@ -47,6 +47,12 @@ const CALLBACK_TO_EVENT = {
47
47
  // auto-compacting now. The callback posts a chat message proposing /compact
48
48
  // — opt-in per chat. See docs/0.12.0-file-send.md / lib/compaction-warn.js.
49
49
  onCompactionWarn: 'compaction-warn',
50
+ // 0.12.0 background-work visibility (Use 3). CliProcess emits 'bg-work-status'
51
+ // {state:'running'|'cleared', count?} when a detached background shell is first
52
+ // observed running idle past its turn, and again when it clears. The callback
53
+ // posts/edits a "⏳ working in background" status message so a long job reads as
54
+ // working, not stuck. See docs/0.12.0-background-work-lifecycle-plan.md.
55
+ onBgWorkStatus: 'bg-work-status',
50
56
  onQueueDrop: 'queue-drop',
51
57
  onThinking: 'thinking',
52
58
  // Tmux backend: TUI shows in-pane approval prompt. SDK backend
@@ -60,6 +60,10 @@ function createSdkCallbacks({
60
60
  // because the TUI's queue is FIFO and we only watch one extra turn
61
61
  // at a time per session.
62
62
  const extraTurnTracker = new Map(); // sessionKey → { msgId, intervalHandle, chatId }
63
+ // 0.12.0 background-work visibility (Use 3): sessionKey → message_id of the live
64
+ // "⏳ working in background" status message, so the cleared/close paths can edit
65
+ // it to a final state instead of leaving it dangling as "working".
66
+ const bgStatusMsgIds = new Map();
63
67
 
64
68
  function startExtraTurnVisuals(sessionKey, msgId) {
65
69
  if (!bot) return;
@@ -152,6 +156,17 @@ function createSdkCallbacks({
152
156
  // visuals so we don't leak the interval and aren't stuck
153
157
  // showing "writing…" on a dead session.
154
158
  stopExtraTurnVisuals(sessionKey, null);
159
+ // 0.12.0 bg-work visibility: if a "⏳ working in background" status is still
160
+ // up when the session closes, its shell died with the session — edit to a
161
+ // final state so it doesn't dangle as "working" forever.
162
+ const bgMid = bgStatusMsgIds.get(sessionKey);
163
+ if (bgMid != null && bot) {
164
+ bgStatusMsgIds.delete(sessionKey);
165
+ tg(bot, 'editMessageText', {
166
+ chat_id: entry.chatId, message_id: bgMid,
167
+ text: '⏹ Background work ended (session restarted).',
168
+ }, { source: 'bg-work-status', botName }).catch(() => {});
169
+ }
155
170
  },
156
171
 
157
172
  onStreamChunk: (sessionKey, partial, entry) => {
@@ -324,6 +339,49 @@ function createSdkCallbacks({
324
339
  }
325
340
  },
326
341
 
342
+ // 0.12.0 background-work visibility (Use 3). CliProcess emits this when a
343
+ // detached `run_in_background` shell is first observed running idle past its
344
+ // turn ('running') and again when it clears ('cleared'). We post ONE bot
345
+ // status message and edit it to done — so a long job reads as working, not
346
+ // stuck. Direct tg send (NOT via claude — this is a bot status indicator),
347
+ // keyed by sessionKey so the cleared/close paths can find it to edit.
348
+ onBgWorkStatus: async (sessionKey, payload) => {
349
+ try {
350
+ if (!bot) return;
351
+ const chatId = getChatIdFromKey(sessionKey);
352
+ const threadIdRaw = getThreadIdFromKey(sessionKey);
353
+ const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
354
+ const state = payload?.state;
355
+ if (state === 'running') {
356
+ if (bgStatusMsgIds.has(sessionKey)) return; // already showing one
357
+ const res = await tg(bot, 'sendMessage', {
358
+ chat_id: chatId,
359
+ text: '⏳ Working in the background — I\'ll keep an eye on it and report when it\'s done.',
360
+ ...(Number.isInteger(threadId) && { message_thread_id: threadId }),
361
+ }, { source: 'bg-work-status', botName });
362
+ const mid = res?.message_id ?? res?.result?.message_id ?? null;
363
+ if (mid != null) bgStatusMsgIds.set(sessionKey, mid);
364
+ logEvent('bg-work-status', {
365
+ chat_id: chatId, session_key: sessionKey, thread_id: threadIdRaw,
366
+ state: 'running', message_id: mid,
367
+ });
368
+ } else if (state === 'cleared') {
369
+ const mid = bgStatusMsgIds.get(sessionKey);
370
+ bgStatusMsgIds.delete(sessionKey);
371
+ if (mid == null) return;
372
+ await tg(bot, 'editMessageText', {
373
+ chat_id: chatId, message_id: mid, text: '✅ Background work finished.',
374
+ }, { source: 'bg-work-status', botName }).catch(() => {});
375
+ logEvent('bg-work-status', {
376
+ chat_id: chatId, session_key: sessionKey, thread_id: threadIdRaw,
377
+ state: 'cleared', message_id: mid,
378
+ });
379
+ }
380
+ } catch (err) {
381
+ logger.error?.(`[${botName}] bg-work-status handler: ${err.message}`);
382
+ }
383
+ },
384
+
327
385
  // rc.9: pair-of with onExtraTurnReply. Fires the moment
328
386
  // TmuxProcess sees the dequeued user-message in JSONL → turn 2
329
387
  // is starting. Re-engages typing indicator + ✍ on the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.12.0-rc.21",
3
+ "version": "0.12.0-rc.23",
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": {