polygram 0.12.0-rc.21 → 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.
@@ -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.21",
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": {