polygram 0.10.0-rc.26 → 0.10.0-rc.28

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.28",
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
@@ -743,6 +770,12 @@ class TmuxProcess extends Process {
743
770
  // The interrupt signal still wins here too — Bug 3: an
744
771
  // interrupted tool turn writes no terminal JSONL `result`, so
745
772
  // without this racer it would hang to `turnTimeoutMs`.
773
+ //
774
+ // B10: an outstanding `Agent` subagent counts as "tool in
775
+ // flight" exactly like a foreground `Bash` — its `tool-use`
776
+ // already set `toolUsedThisTurn`, so this branch catches the
777
+ // common case. The race where capture wins BEFORE the `Agent`
778
+ // tool_use line is tailed is handled by the §6 re-check below.
746
779
  if (winner.kind === 'capture' && turn.toolUsedThisTurn) {
747
780
  winner = await Promise.race([
748
781
  turn.resultPromise.then((ev) => ({ kind: 'jsonl', ev })),
@@ -808,11 +841,41 @@ class TmuxProcess extends Process {
808
841
  text = turn.text;
809
842
  } else {
810
843
  const lateGraceMs = this.lateGraceMs ?? 1500;
811
- const late = await Promise.race([
844
+ let late = await Promise.race([
812
845
  turn.resultPromise.then((ev) => ({ kind: 'jsonl-late', ev })),
813
846
  new Promise((r) => setTimeout(() => r({ kind: 'no-jsonl' }), lateGraceMs)),
814
847
  ]);
815
- if (late.kind === 'jsonl-late') {
848
+ // B10 (shumorobot Music topic, 2026-05-20): the main agent
849
+ // delegated to an `Agent` subagent within ~7 s, then the main
850
+ // pane went quiescent for MINUTES while the subagent ran in
851
+ // its own sidechain. capture-pane read that quiescence as
852
+ // "done"; the main agent had emitted only the `Agent` call so
853
+ // no JSONL reply text existed yet, and the §6 fail-loud below
854
+ // fired ~grace-window in — closing a turn that was genuinely
855
+ // in flight. A subagent is still running iff its `Agent`
856
+ // tool_use has no matching `tool-result` yet. While one is
857
+ // outstanding, capture-pane quiescence of the MAIN pane is
858
+ // meaningless — the turn completes only when the subagent
859
+ // returns and the main agent emits its real terminal reply.
860
+ // Wait for that JSONL `result`, bounded by the absolute turn
861
+ // deadline so a genuinely wedged turn still fails loud.
862
+ if (late.kind === 'no-jsonl' && turn.outstandingSubagents.size > 0) {
863
+ this.emit('subagent-wait', {
864
+ outstanding: turn.outstandingSubagents.size,
865
+ turnId: turn.turnId,
866
+ });
867
+ late = await Promise.race([
868
+ turn.resultPromise.then((ev) => ({ kind: 'jsonl-late', ev })),
869
+ turnDeadlineP,
870
+ turn.interruptP.then(() => ({ kind: 'interrupt' })),
871
+ ]);
872
+ }
873
+ if (late.kind === 'interrupt') {
874
+ turn.interrupted = true;
875
+ text = turn.text || '';
876
+ resultSubtype = 'interrupted';
877
+ stopReason = 'interrupted';
878
+ } else if (late.kind === 'jsonl-late') {
816
879
  resolvedVia = 'jsonl-late';
817
880
  text = turn.text || late.ev.text || '';
818
881
  resultSubtype = late.ev.subtype || 'success';
@@ -941,6 +1004,13 @@ class TmuxProcess extends Process {
941
1004
  text: '',
942
1005
  toolUses: 0,
943
1006
  toolUsedThisTurn: false,
1007
+ // B10: outstanding `Agent` (subagent/Task) tool_use ids — a
1008
+ // tool_use with no matching tool_result yet. A non-empty set
1009
+ // means a subagent is running in its own sidechain context: the
1010
+ // main pane goes quiescent for MINUTES while it works, and that
1011
+ // quiescence must NOT be read as turn completion. Cleared when
1012
+ // the matching `tool-result` arrives.
1013
+ outstandingSubagents: new Set(),
944
1014
  stopReason: null,
945
1015
  resultEvent: null,
946
1016
  via: null, // autosteer: 'fold' | 'new-turn'
@@ -1118,8 +1188,25 @@ class TmuxProcess extends Process {
1118
1188
  // the flag here so a transient capture-pane "ready" between
1119
1189
  // tool calls cannot resolve a still-working turn.
1120
1190
  t.toolUsedThisTurn = true;
1191
+ // B10: an `Agent` (subagent/Task) tool_use spawns a subagent
1192
+ // that runs for MINUTES in its own sidechain context while the
1193
+ // main pane sits quiescent. Track its id as outstanding until
1194
+ // the matching `tool-result` returns — `_runTurn` treats an
1195
+ // outstanding subagent as "turn still in flight" so the main
1196
+ // pane's quiescence cannot trip the §6 fail-loud.
1197
+ if (ev.name === 'Agent' && typeof ev.id === 'string') {
1198
+ t.outstandingSubagents.add(ev.id);
1199
+ }
1121
1200
  }
1122
1201
  this.emit('tool-use', ev.name);
1202
+ } else if (ev.type === 'tool-result') {
1203
+ // B10: a subagent returned. Clear the outstanding `Agent` call
1204
+ // it answers across every turn in the active group. A
1205
+ // tool-result for a non-Agent tool (or an id we never tracked)
1206
+ // is a harmless no-op — the set only ever held `Agent` ids.
1207
+ for (const t of this._activeGroup.turns) {
1208
+ t.outstandingSubagents.delete(ev.toolUseId);
1209
+ }
1123
1210
  } else if (ev.type === 'usage') {
1124
1211
  // Token-usage snapshot from JSONL. Cache for getContextUsage().
1125
1212
  // Each assistant message carries the cumulative usage; latest
@@ -1573,43 +1660,95 @@ class TmuxProcess extends Process {
1573
1660
  return this._sleep(this.pollMs);
1574
1661
  }
1575
1662
 
1663
+ /**
1664
+ * Synchronously probe the byte-size of the claude `--debug-file` log.
1665
+ * Returns the size in bytes, or `null` if the log does not exist yet
1666
+ * (claude takes ~100 ms to create it after spawn) or cannot be
1667
+ * stat'd. Never throws — a readiness gate must not hard-fail on a
1668
+ * missing debug-log signal.
1669
+ *
1670
+ * B8: this is the size-delta channel `_waitForReady` uses to detect
1671
+ * debug-log quiescence — the signal a byte-stable-but-still-loading
1672
+ * TUI pane cannot fool. During MCP cold-start claude DENSELY appends
1673
+ * to this log (`MCP server "X": connecting…` / `…connected in
1674
+ * NNNNms`); once the TUI is genuinely idle the log goes silent. This
1675
+ * mirrors `LogTail`'s approach (stat-based size-delta detection,
1676
+ * ENOENT-tolerant) without arming a second async tailer: the
1677
+ * readiness loop already polls on a timer, so a synchronous
1678
+ * `statSync` per poll is the simplest, fully-deterministic fit and
1679
+ * needs no fs.watch.
1680
+ *
1681
+ * `this._fsOverride` is a test seam — a fake `fs` lets a unit test
1682
+ * drive debug-log growth by hand.
1683
+ */
1684
+ _probeDebugLogSize() {
1685
+ const fsImpl = this._fsOverride || require('fs');
1686
+ try {
1687
+ return fsImpl.statSync(this.debugLogPath).size;
1688
+ } catch {
1689
+ return null; // ENOENT (not created yet) / any stat error
1690
+ }
1691
+ }
1692
+
1576
1693
  async _waitForReady() {
1577
1694
  const deadline = this._now() + this.readyTimeoutMs;
1578
1695
  let lastBuf = '';
1579
1696
  // B6 (shumorobot 2026-05-18, Music topic, twice): a slow
1580
1697
  // custom-agent spawn (`music-curation:music-curator` loading
1581
1698
  // 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.
1699
+ // SECONDS. Throughout that window the TUI ALREADY renders its
1700
+ // ready hint (`? for shortcuts` / `bypass permissions on`) at the
1701
+ // bottom of its startup banner. The old `_waitForReady` returned
1702
+ // the INSTANT `READY_HINTS_RE` matched on the first poll, while
1703
+ // MCP servers were still loading so the first `send()` pasted
1704
+ // into a not-yet-ready TUI and the submitted Enter was dropped.
1705
+ //
1706
+ // B6's fix gated on pane QUIESCENCE: ready ⇔ the hint is present
1707
+ // AND the `capture-pane` is byte-stable across consecutive polls.
1588
1708
  //
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.
1709
+ // B8 (slow-MCP-startup, 2026-05-19): pane quiescence is NOT
1710
+ // enough. The production debug log for the Music topic shows MCP
1711
+ // cold-start spanning ~33 s (`plugin:serena:serena` 27.5 s,
1712
+ // peekaboo 9.3 s, …) yet across that whole window the claude
1713
+ // pane is BYTE-STABLE: the REPL mounts and paints its ready hint
1714
+ // immediately, then MCP servers load entirely off-screen with the
1715
+ // pane unchanged. B6 reads "stable = ready", `start()` resolves
1716
+ // mid-MCP-load, the paste lands in a TUI that is not yet
1717
+ // interactive, and the Enter is dropped (the Music-topic break,
1718
+ // 5+ times). `isolateUserConfig` (rc.26) removes MCP servers for
1719
+ // the Music topic specifically, but the gate is still wrong for
1720
+ // any non-isolated topic that legitimately loads MCP servers.
1597
1721
  //
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.
1722
+ // The pane is fooled; the claude `--debug-file` log is NOT. During
1723
+ // MCP startup that log is ACTIVELY written; a genuinely-ready idle
1724
+ // TUI's debug log is quiet (verified against the production log —
1725
+ // dense writes for ~33 s, then silence for over an hour). So
1726
+ // `_waitForReady` now ALSO gates on debug-log quiescence:
1727
+ // ready ⇔ (ready hint present)
1728
+ // AND (pane byte-stable across consecutive polls)
1729
+ // AND (the --debug-file log has had no new bytes for
1730
+ // `readyDebugQuietMs`).
1731
+ // During MCP load the debug log is active → not ready. After load
1732
+ // → quiet → ready. Still bounded by `readyTimeoutMs`, so a
1733
+ // genuinely wedged spawn still throws TMUX_READY_TIMEOUT.
1604
1734
  //
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.
1735
+ // Scope: the debug log keeps being written DURING normal turns, so
1736
+ // whole-log quiescence would wrongly block mid-turn but
1737
+ // `_waitForReady` runs ONLY at startup, before any turn, so
1738
+ // startup-phase quiescence is exactly the right window. The
1739
+ // `readyDebugQuietMs` clock lives entirely inside this method and
1740
+ // is never consulted by `_awaitTurnComplete`.
1611
1741
  let readySinceAt = null; // when the (hint + stable-pane) state began
1612
1742
  let prevBuf = null; // last poll's capture, for the stability compare
1743
+ // B8 debug-log quiescence tracking. `prevDebugSize` is the
1744
+ // `--debug-file` byte-size seen on the PREVIOUS poll; `lastGrowthAt`
1745
+ // is when the size last increased. The debug log is "quiet" once it
1746
+ // has not grown for `readyDebugQuietMs`. The FIRST non-null size is
1747
+ // a baseline (no growth recorded for it) — claude actively writing
1748
+ // its MCP-startup burst makes the size keep climbing across the
1749
+ // next polls, which is what resets the clock.
1750
+ let prevDebugSize = null;
1751
+ let lastGrowthAt = null;
1613
1752
  if (this.pollScheduler) this.pollScheduler.acquire();
1614
1753
  try {
1615
1754
  while (this._now() < deadline) {
@@ -1624,11 +1763,28 @@ class TmuxProcess extends Process {
1624
1763
  // least two matching captures, which is the point.
1625
1764
  const hintPresent = READY_HINTS_RE.test(lastBuf);
1626
1765
  const paneStable = prevBuf !== null && lastBuf === prevBuf;
1627
- if (hintPresent && paneStable) {
1766
+ // B8: the --debug-file log must have stopped growing for
1767
+ // `readyDebugQuietMs`. A debug-log size increase since the last
1768
+ // poll = claude is still writing (MCP servers connecting) → not
1769
+ // quiet. The log never appearing at all (null size for the whole
1770
+ // wait — no MCP startup observed) reads as quiet, so the B6 pane
1771
+ // check still gates a no-agent / fast spawn.
1772
+ const debugSize = this._probeDebugLogSize();
1773
+ if (debugSize !== null && prevDebugSize !== null
1774
+ && debugSize > prevDebugSize) {
1775
+ lastGrowthAt = this._now(); // log grew → claude still writing
1776
+ }
1777
+ if (debugSize !== null) prevDebugSize = debugSize;
1778
+ const debugQuiet = lastGrowthAt === null
1779
+ || (this._now() - lastGrowthAt) >= this.readyDebugQuietMs;
1780
+ if (hintPresent && paneStable && debugQuiet) {
1628
1781
  if (readySinceAt == null) readySinceAt = this._now();
1629
1782
  if (this._now() - readySinceAt >= this.quiesceMs) return;
1630
1783
  } else {
1631
- readySinceAt = null; // pane moved / hint gone reset the clock
1784
+ // pane moved / hint gone / debug log still being written →
1785
+ // reset the clock. A debug-log write during the quiesce
1786
+ // window means MCP startup is not finished.
1787
+ readySinceAt = null;
1632
1788
  }
1633
1789
  prevBuf = lastBuf;
1634
1790
  await this._waitForNextTick();
@@ -50,6 +50,7 @@
50
50
  * (ONCE per message.id, on finalize)
51
51
  * - last-prompt → 'last-prompt' (fallback complete signal)
52
52
  * - user (top-level string) → 'user-message' { text, parentUuid, promptId }
53
+ * - user tool_result block → 'tool-result' { toolUseId, isError }
53
54
  * - queue-operation → 'queue-operation' { operation, content }
54
55
  *
55
56
  * Robust against malformed lines: skips them.
@@ -128,6 +129,31 @@ function extractContentBlocks(content) {
128
129
  return { textParts, toolUses };
129
130
  }
130
131
 
132
+ /**
133
+ * Pull `tool_result` blocks out of a user message's `content` array.
134
+ * A user message with array content carries API-shaped tool feedback
135
+ * (NOT a user prompt). Each `tool_result` block names the `tool_use`
136
+ * it answers via `tool_use_id` — the matcher polygram's turn ledger
137
+ * uses to clear an outstanding `Agent`/subagent call.
138
+ *
139
+ * @returns {object[]} `tool-result` events, possibly empty.
140
+ */
141
+ function extractToolResults(content) {
142
+ const out = [];
143
+ if (!Array.isArray(content)) return out;
144
+ for (const block of content) {
145
+ if (!block || typeof block !== 'object') continue;
146
+ if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
147
+ out.push({
148
+ type: 'tool-result',
149
+ toolUseId: block.tool_use_id,
150
+ isError: block.is_error === true,
151
+ });
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
131
157
  /**
132
158
  * Join assistant text blocks the way the SDK backend's
133
159
  * `extractAssistantText` does (rc.8 cross-backend parity): blocks
@@ -204,12 +230,15 @@ function parseLine(line) {
204
230
  } else if (obj.type === 'last-prompt') {
205
231
  out.push({ type: 'last-prompt', text: obj.lastPrompt ?? '' });
206
232
  } else if (obj.type === 'user' && obj.message) {
207
- // Top-level user message only emit when content is a non-empty
208
- // string. Array content carries tool_result blocks (API-shaped
209
- // tool feedback), NOT a user prompt — skip those.
233
+ // Top-level user message. String content is a user prompt. Array
234
+ // content carries API-shaped `tool_result` blocks (tool feedback,
235
+ // NOT a prompt)those surface as `tool-result` events so the
236
+ // turn ledger can clear an outstanding `Agent`/subagent call.
210
237
  const content = obj.message.content;
211
238
  if (typeof content === 'string' && content.length > 0) {
212
239
  out.push({ type: 'user-message', text: content });
240
+ } else {
241
+ out.push(...extractToolResults(content));
213
242
  }
214
243
  } else if (obj.type === 'attachment' && obj.attachment) {
215
244
  const a = obj.attachment;
@@ -321,6 +350,13 @@ class SessionEventAggregator {
321
350
  parentUuid: obj.parentUuid ?? null,
322
351
  promptId: obj.promptId ?? null,
323
352
  });
353
+ } else {
354
+ // Array content — API-shaped `tool_result` blocks. A subagent
355
+ // (`Agent` tool) returning to the main agent surfaces here;
356
+ // the turn ledger keys on `toolUseId` to clear the outstanding
357
+ // subagent call so capture-pane quiescence of the main pane is
358
+ // not mistaken for turn completion while the subagent runs.
359
+ out.push(...extractToolResults(content));
324
360
  }
325
361
  } else if (obj.type === 'last-prompt') {
326
362
  out.push({ type: 'last-prompt', text: obj.lastPrompt ?? '' });
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.28",
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": {