polygram 0.10.0-rc.26 → 0.10.0-rc.27

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.10.0-rc.26",
4
+ "version": "0.10.0-rc.27",
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 plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -174,6 +174,20 @@ const DEFAULT_TURN_TIMEOUT_MS = 5 * 60_000;
174
174
  const DEFAULT_POLL_MS = 250;
175
175
  const DEFAULT_QUIESCE_MS = 500; // require READY for this long before declaring done
176
176
 
177
+ // B8 (slow-MCP readiness): how long the claude `--debug-file` log must
178
+ // have had NO new bytes appended before the startup is considered
179
+ // quiescent. During MCP cold-start the debug log is DENSELY written —
180
+ // the production log shows ~33 s of `MCP server "X": connecting/…
181
+ // connected` lines, then total silence once the TUI is idle. A genuine
182
+ // idle TUI's debug log is quiet for minutes. 1 s is comfortably longer
183
+ // than the gap between two consecutive MCP-startup log writes (verified
184
+ // against the production debug log) yet short enough to add only ~1 s
185
+ // to a clean startup. Used ONLY by `_waitForReady`, scoped to the
186
+ // startup wait — never reused mid-turn (the debug log keeps being
187
+ // written during a turn; quiescence-of-the-whole-log would wrongly
188
+ // block, but `_waitForReady` runs only at startup before any turn).
189
+ const DEFAULT_READY_DEBUG_QUIET_MS = 1000;
190
+
177
191
  // R7: sentinel returned by _awaitTurnComplete when its poll loop is
178
192
  // stopped by the caller's absolute-deadline abort (rather than by a
179
193
  // real READY quiescence or its own internal timeout). _runTurn maps
@@ -197,6 +211,9 @@ class TmuxProcess extends Process {
197
211
  * @param {number} [opts.turnTimeoutMs]
198
212
  * @param {number} [opts.pollMs]
199
213
  * @param {number} [opts.quiesceMs]
214
+ * @param {number} [opts.readyDebugQuietMs] — B8: `_waitForReady`
215
+ * requires the claude `--debug-file` log to have had no new bytes
216
+ * for this long (in addition to pane stability + ready hint).
200
217
  */
201
218
  constructor({
202
219
  sessionKey, chatId, threadId, label,
@@ -216,6 +233,14 @@ class TmuxProcess extends Process {
216
233
  // presses before giving up loud.
217
234
  submitConfirmMs = 1500,
218
235
  submitConfirmRetries = 4,
236
+ // B8: `_waitForReady` gates startup on the claude `--debug-file`
237
+ // log going quiet (no new bytes for this long) — the signal that
238
+ // is NOT fooled by a byte-stable-but-still-loading TUI pane.
239
+ readyDebugQuietMs = DEFAULT_READY_DEBUG_QUIET_MS,
240
+ // Test seam: a fake `fs` forwarded to the readiness debug-log tail
241
+ // so a unit test can drive debug-log writes deterministically
242
+ // without touching the real filesystem.
243
+ fs: fsOverride = null,
219
244
  } = {}) {
220
245
  super({ sessionKey, chatId, threadId, label });
221
246
  if (!runner) throw new TypeError('TmuxProcess: runner required');
@@ -241,6 +266,8 @@ class TmuxProcess extends Process {
241
266
  this.turnTimeoutMs = turnTimeoutMs;
242
267
  this.pollMs = pollMs;
243
268
  this.quiesceMs = quiesceMs;
269
+ this.readyDebugQuietMs = readyDebugQuietMs;
270
+ this._fsOverride = fsOverride;
244
271
  this.lateGraceMs = lateGraceMs;
245
272
  this.queueCap = queueCap;
246
273
  // Optional shared poll scheduler. When provided, the polling
@@ -1573,43 +1600,95 @@ class TmuxProcess extends Process {
1573
1600
  return this._sleep(this.pollMs);
1574
1601
  }
1575
1602
 
1603
+ /**
1604
+ * Synchronously probe the byte-size of the claude `--debug-file` log.
1605
+ * Returns the size in bytes, or `null` if the log does not exist yet
1606
+ * (claude takes ~100 ms to create it after spawn) or cannot be
1607
+ * stat'd. Never throws — a readiness gate must not hard-fail on a
1608
+ * missing debug-log signal.
1609
+ *
1610
+ * B8: this is the size-delta channel `_waitForReady` uses to detect
1611
+ * debug-log quiescence — the signal a byte-stable-but-still-loading
1612
+ * TUI pane cannot fool. During MCP cold-start claude DENSELY appends
1613
+ * to this log (`MCP server "X": connecting…` / `…connected in
1614
+ * NNNNms`); once the TUI is genuinely idle the log goes silent. This
1615
+ * mirrors `LogTail`'s approach (stat-based size-delta detection,
1616
+ * ENOENT-tolerant) without arming a second async tailer: the
1617
+ * readiness loop already polls on a timer, so a synchronous
1618
+ * `statSync` per poll is the simplest, fully-deterministic fit and
1619
+ * needs no fs.watch.
1620
+ *
1621
+ * `this._fsOverride` is a test seam — a fake `fs` lets a unit test
1622
+ * drive debug-log growth by hand.
1623
+ */
1624
+ _probeDebugLogSize() {
1625
+ const fsImpl = this._fsOverride || require('fs');
1626
+ try {
1627
+ return fsImpl.statSync(this.debugLogPath).size;
1628
+ } catch {
1629
+ return null; // ENOENT (not created yet) / any stat error
1630
+ }
1631
+ }
1632
+
1576
1633
  async _waitForReady() {
1577
1634
  const deadline = this._now() + this.readyTimeoutMs;
1578
1635
  let lastBuf = '';
1579
1636
  // B6 (shumorobot 2026-05-18, Music topic, twice): a slow
1580
1637
  // custom-agent spawn (`music-curation:music-curator` loading
1581
1638
  // several MCP servers) leaves the claude TUI mid-startup for
1582
- // SECONDS the production debug log shows MCP connections
1583
- // spanning 14:45:31→14:45:40 (playwright 2.9s, context7 3.1s,
1584
- // serena 3.8s, …) with the screen repainting hard the whole time
1585
- // ("High write ratio: 100.0% writes"). Throughout that window the
1586
- // TUI ALREADY renders its ready hint (`? for shortcuts` /
1587
- // `bypass permissions on`) at the bottom of its startup banner.
1639
+ // SECONDS. Throughout that window the TUI ALREADY renders its
1640
+ // ready hint (`? for shortcuts` / `bypass permissions on`) at the
1641
+ // bottom of its startup banner. The old `_waitForReady` returned
1642
+ // the INSTANT `READY_HINTS_RE` matched on the first poll, while
1643
+ // MCP servers were still loading so the first `send()` pasted
1644
+ // into a not-yet-ready TUI and the submitted Enter was dropped.
1588
1645
  //
1589
- // The old `_waitForReady` returned the INSTANT `READY_HINTS_RE`
1590
- // matched — i.e. on the first poll, while MCP servers were still
1591
- // loading. `start()` resolved early; the first `send()` pasted
1592
- // the prompt into a TUI still ingesting startup, and the
1593
- // submitted Enter was dropped → the prompt sat unsubmitted → the
1594
- // turn never began (a fake THINKING→STALL followed). On a
1595
- // no-agent TUI the startup window is sub-poll so it never bit; a
1596
- // slow custom-agent spawn opens a multi-second window every time.
1646
+ // B6's fix gated on pane QUIESCENCE: ready ⇔ the hint is present
1647
+ // AND the `capture-pane` is byte-stable across consecutive polls.
1597
1648
  //
1598
- // The banner is NOT a usable "not ready" signal — it stays on the
1599
- // pane indefinitely (the agent emits nothing pre-turn, so it
1600
- // never scrolls into scrollback). The real discriminator is
1601
- // QUIESCENCE: a genuinely-ready idle TUI produces a BYTE-STABLE
1602
- // `capture-pane` between polls (static banner + empty input box +
1603
- // ready hint), whereas a mid-startup TUI repaints every tick.
1649
+ // B8 (slow-MCP-startup, 2026-05-19): pane quiescence is NOT
1650
+ // enough. The production debug log for the Music topic shows MCP
1651
+ // cold-start spanning ~33 s (`plugin:serena:serena` 27.5 s,
1652
+ // peekaboo 9.3 s, …) yet across that whole window the claude
1653
+ // pane is BYTE-STABLE: the REPL mounts and paints its ready hint
1654
+ // immediately, then MCP servers load entirely off-screen with the
1655
+ // pane unchanged. B6 reads "stable = ready", `start()` resolves
1656
+ // mid-MCP-load, the paste lands in a TUI that is not yet
1657
+ // interactive, and the Enter is dropped (the Music-topic break,
1658
+ // 5+ times). `isolateUserConfig` (rc.26) removes MCP servers for
1659
+ // the Music topic specifically, but the gate is still wrong for
1660
+ // any non-isolated topic that legitimately loads MCP servers.
1604
1661
  //
1605
- // Fix: require the ready hint to be present AND the captured pane
1606
- // to be UNCHANGED across consecutive polls for `quiesceMs`
1607
- // continuously before declaring ready the same "must hold this
1608
- // long" idea `_awaitTurnComplete` already uses for turn
1609
- // completion. Bounded by `readyTimeoutMs`, so a genuinely wedged
1610
- // spawn still throws TMUX_READY_TIMEOUT.
1662
+ // The pane is fooled; the claude `--debug-file` log is NOT. During
1663
+ // MCP startup that log is ACTIVELY written; a genuinely-ready idle
1664
+ // TUI's debug log is quiet (verified against the production log
1665
+ // dense writes for ~33 s, then silence for over an hour). So
1666
+ // `_waitForReady` now ALSO gates on debug-log quiescence:
1667
+ // ready (ready hint present)
1668
+ // AND (pane byte-stable across consecutive polls)
1669
+ // AND (the --debug-file log has had no new bytes for
1670
+ // `readyDebugQuietMs`).
1671
+ // During MCP load the debug log is active → not ready. After load
1672
+ // → quiet → ready. Still bounded by `readyTimeoutMs`, so a
1673
+ // genuinely wedged spawn still throws TMUX_READY_TIMEOUT.
1674
+ //
1675
+ // Scope: the debug log keeps being written DURING normal turns, so
1676
+ // whole-log quiescence would wrongly block mid-turn — but
1677
+ // `_waitForReady` runs ONLY at startup, before any turn, so
1678
+ // startup-phase quiescence is exactly the right window. The
1679
+ // `readyDebugQuietMs` clock lives entirely inside this method and
1680
+ // is never consulted by `_awaitTurnComplete`.
1611
1681
  let readySinceAt = null; // when the (hint + stable-pane) state began
1612
1682
  let prevBuf = null; // last poll's capture, for the stability compare
1683
+ // B8 debug-log quiescence tracking. `prevDebugSize` is the
1684
+ // `--debug-file` byte-size seen on the PREVIOUS poll; `lastGrowthAt`
1685
+ // is when the size last increased. The debug log is "quiet" once it
1686
+ // has not grown for `readyDebugQuietMs`. The FIRST non-null size is
1687
+ // a baseline (no growth recorded for it) — claude actively writing
1688
+ // its MCP-startup burst makes the size keep climbing across the
1689
+ // next polls, which is what resets the clock.
1690
+ let prevDebugSize = null;
1691
+ let lastGrowthAt = null;
1613
1692
  if (this.pollScheduler) this.pollScheduler.acquire();
1614
1693
  try {
1615
1694
  while (this._now() < deadline) {
@@ -1624,11 +1703,28 @@ class TmuxProcess extends Process {
1624
1703
  // least two matching captures, which is the point.
1625
1704
  const hintPresent = READY_HINTS_RE.test(lastBuf);
1626
1705
  const paneStable = prevBuf !== null && lastBuf === prevBuf;
1627
- if (hintPresent && paneStable) {
1706
+ // B8: the --debug-file log must have stopped growing for
1707
+ // `readyDebugQuietMs`. A debug-log size increase since the last
1708
+ // poll = claude is still writing (MCP servers connecting) → not
1709
+ // quiet. The log never appearing at all (null size for the whole
1710
+ // wait — no MCP startup observed) reads as quiet, so the B6 pane
1711
+ // check still gates a no-agent / fast spawn.
1712
+ const debugSize = this._probeDebugLogSize();
1713
+ if (debugSize !== null && prevDebugSize !== null
1714
+ && debugSize > prevDebugSize) {
1715
+ lastGrowthAt = this._now(); // log grew → claude still writing
1716
+ }
1717
+ if (debugSize !== null) prevDebugSize = debugSize;
1718
+ const debugQuiet = lastGrowthAt === null
1719
+ || (this._now() - lastGrowthAt) >= this.readyDebugQuietMs;
1720
+ if (hintPresent && paneStable && debugQuiet) {
1628
1721
  if (readySinceAt == null) readySinceAt = this._now();
1629
1722
  if (this._now() - readySinceAt >= this.quiesceMs) return;
1630
1723
  } else {
1631
- readySinceAt = null; // pane moved / hint gone reset the clock
1724
+ // pane moved / hint gone / debug log still being written →
1725
+ // reset the clock. A debug-log write during the quiesce
1726
+ // window means MCP startup is not finished.
1727
+ readySinceAt = null;
1632
1728
  }
1633
1729
  prevBuf = lastBuf;
1634
1730
  await this._waitForNextTick();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.10.0-rc.26",
3
+ "version": "0.10.0-rc.27",
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": {