polygram 0.10.0-rc.25 → 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.25",
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",
@@ -98,7 +98,8 @@
98
98
  "cwd": "/Users/you/admin-agent",
99
99
  "requireMention": true,
100
100
  "isolateTopics": true,
101
- "_comment_topics": "rc.48: each topic entry is EITHER a string (legacy: just a label) OR an object with optional fields {name, agent, cwd, model, effort, permissionMode}. Object form lets a topic override chat-level config. Per-topic permissionMode overrides chat-level — typical use: scope one topic to permissionMode:'default' (so settings.json gates apply) while the rest of the chat stays on bypassPermissions. Object form requires isolateTopics: true (each topic gets its own SDK Query); polygram emits a startup warning otherwise.",
101
+ "_comment_topics": "rc.48: each topic entry is EITHER a string (legacy: just a label) OR an object with optional fields {name, agent, cwd, model, effort, permissionMode, isolateUserConfig}. Object form lets a topic override chat-level config. Per-topic permissionMode overrides chat-level — typical use: scope one topic to permissionMode:'default' (so settings.json gates apply) while the rest of the chat stays on bypassPermissions. Object form requires isolateTopics: true (each topic gets its own SDK Query); polygram emits a startup warning otherwise.",
102
+ "_comment_isolateUserConfig": "0.10.0, tmux backend only: isolateUserConfig:true spawns the topic's claude TUI cut off from the user-level ~/.claude config — passes --strict-mcp-config (zero MCP servers load) and --setting-sources project,local (drops ~/.claude/settings.json; the spawn cwd's own .claude/settings.json still loads). Use it when a topic's agent would otherwise inherit slow user-global MCP servers whose cold-start (tens of seconds) wedges the TUI before it can accept a prompt. Settable at chat OR topic level (topic wins). Default false.",
102
103
  "topics": {
103
104
  "100": "Customer A",
104
105
  "200": {
@@ -107,7 +108,8 @@
107
108
  "cwd": "/Users/you/customer-b-projects",
108
109
  "model": "opus",
109
110
  "effort": "high",
110
- "permissionMode": "default"
111
+ "permissionMode": "default",
112
+ "isolateUserConfig": true
111
113
  }
112
114
  }
113
115
  },
@@ -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
@@ -347,7 +374,7 @@ class TmuxProcess extends Process {
347
374
  *
348
375
  * @param {object} ctx
349
376
  * @param {string|null} [ctx.existingSessionId] — for --resume
350
- * @param {object} [ctx.chatConfig={}] — supplies model, effort, cwd, agent, permissionMode
377
+ * @param {object} [ctx.chatConfig={}] — supplies model, effort, cwd, agent, permissionMode, isolateUserConfig
351
378
  * @param {string} [ctx.model] — override (rare; e.g. tests)
352
379
  * @param {string} [ctx.effort] — override
353
380
  * @param {string} [ctx.cwd] — override
@@ -378,6 +405,21 @@ class TmuxProcess extends Process {
378
405
  const cwd = ctx.cwd || topicConfig.cwd || chatConfig.cwd;
379
406
  const agent = topicConfig.agent || chatConfig.agent;
380
407
  const permissionMode = topicConfig.permissionMode || chatConfig.permissionMode || 'acceptEdits';
408
+ // `isolateUserConfig` (topic- or chat-level, topic wins — same
409
+ // merge path as agent/cwd/permissionMode). When true, the spawned
410
+ // claude TUI is cut off from the user-level `~/.claude` config:
411
+ // no user-level MCP servers, plugins, or settings load. Decided
412
+ // fix for the Music topic incident — the music-curation agent was
413
+ // pulling in user-global MCP servers (serena ~27.5 s, peekaboo
414
+ // ~9 s, context7) and the ~45 s MCP cold-start left the TUI
415
+ // accepting a pasted prompt but dropping the submitted Enter, so
416
+ // polygram's paste never submitted and the turn failed (broke the
417
+ // Music topic 5+ times). Default OFF — every other topic is
418
+ // unaffected unless it explicitly opts in.
419
+ const isolateUserConfig =
420
+ topicConfig.isolateUserConfig != null
421
+ ? topicConfig.isolateUserConfig === true
422
+ : chatConfig.isolateUserConfig === true;
381
423
 
382
424
  // Pre-allocate the sessionId via --session-id flag (v9 finding).
383
425
  // claude accepts a valid UUID and uses it as THE session ID for the
@@ -400,6 +442,25 @@ class TmuxProcess extends Process {
400
442
  }
401
443
  args.push('--debug-file', this.debugLogPath);
402
444
  if (agent) args.push('--agent', agent);
445
+ // isolateUserConfig: cut the spawned TUI off from `~/.claude`.
446
+ // --strict-mcp-config — claude CLI v2.1.142: "Only use MCP
447
+ // servers from --mcp-config, ignoring all other MCP
448
+ // configurations." Passed ALONE (no --mcp-config) → zero MCP
449
+ // servers load. Hard guarantee that no plugin-provided OR
450
+ // directly-registered MCP server (serena/peekaboo/context7)
451
+ // starts, so there is no ~45 s cold-start window.
452
+ // --setting-sources project,local — load only project + local
453
+ // settings, NOT `user`. Drops `~/.claude/settings.json`
454
+ // (user-level plugins/skills/settings) while the spawn cwd's
455
+ // own `.claude/settings.json` still loads — so the rekordbox
456
+ // project's WebFetch allowlist + dontAsk mode still apply.
457
+ // No --mcp-config needed: the music-curation plugin ships NO MCP
458
+ // server (its .claude-plugin/plugin.json declares no `mcpServers`;
459
+ // it is all-Bash), so --strict-mcp-config alone is clean.
460
+ if (isolateUserConfig) {
461
+ args.push('--strict-mcp-config');
462
+ args.push('--setting-sources', 'project,local');
463
+ }
403
464
  // Cross-backend parity: SDK appends polygram's Telegram display
404
465
  // hint to every agent's systemPrompt (lib/sdk/build-options.js).
405
466
  // Without this, the spawned claude session has no idea it's
@@ -1539,43 +1600,95 @@ class TmuxProcess extends Process {
1539
1600
  return this._sleep(this.pollMs);
1540
1601
  }
1541
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
+
1542
1633
  async _waitForReady() {
1543
1634
  const deadline = this._now() + this.readyTimeoutMs;
1544
1635
  let lastBuf = '';
1545
1636
  // B6 (shumorobot 2026-05-18, Music topic, twice): a slow
1546
1637
  // custom-agent spawn (`music-curation:music-curator` loading
1547
1638
  // several MCP servers) leaves the claude TUI mid-startup for
1548
- // SECONDS the production debug log shows MCP connections
1549
- // spanning 14:45:31→14:45:40 (playwright 2.9s, context7 3.1s,
1550
- // serena 3.8s, …) with the screen repainting hard the whole time
1551
- // ("High write ratio: 100.0% writes"). Throughout that window the
1552
- // TUI ALREADY renders its ready hint (`? for shortcuts` /
1553
- // `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.
1645
+ //
1646
+ // B6's fix gated on pane QUIESCENCE: ready ⇔ the hint is present
1647
+ // AND the `capture-pane` is byte-stable across consecutive polls.
1554
1648
  //
1555
- // The old `_waitForReady` returned the INSTANT `READY_HINTS_RE`
1556
- // matched i.e. on the first poll, while MCP servers were still
1557
- // loading. `start()` resolved early; the first `send()` pasted
1558
- // the prompt into a TUI still ingesting startup, and the
1559
- // submitted Enter was dropped the prompt sat unsubmitted the
1560
- // turn never began (a fake THINKING→STALL followed). On a
1561
- // no-agent TUI the startup window is sub-poll so it never bit; a
1562
- // slow custom-agent spawn opens a multi-second window every time.
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.
1563
1661
  //
1564
- // The banner is NOT a usable "not ready" signal it stays on the
1565
- // pane indefinitely (the agent emits nothing pre-turn, so it
1566
- // never scrolls into scrollback). The real discriminator is
1567
- // QUIESCENCE: a genuinely-ready idle TUI produces a BYTE-STABLE
1568
- // `capture-pane` between polls (static banner + empty input box +
1569
- // ready hint), whereas a mid-startup TUI repaints every tick.
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.
1570
1674
  //
1571
- // Fix: require the ready hint to be present AND the captured pane
1572
- // to be UNCHANGED across consecutive polls for `quiesceMs`
1573
- // continuously before declaring ready the same "must hold this
1574
- // long" idea `_awaitTurnComplete` already uses for turn
1575
- // completion. Bounded by `readyTimeoutMs`, so a genuinely wedged
1576
- // spawn still throws TMUX_READY_TIMEOUT.
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`.
1577
1681
  let readySinceAt = null; // when the (hint + stable-pane) state began
1578
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;
1579
1692
  if (this.pollScheduler) this.pollScheduler.acquire();
1580
1693
  try {
1581
1694
  while (this._now() < deadline) {
@@ -1590,11 +1703,28 @@ class TmuxProcess extends Process {
1590
1703
  // least two matching captures, which is the point.
1591
1704
  const hintPresent = READY_HINTS_RE.test(lastBuf);
1592
1705
  const paneStable = prevBuf !== null && lastBuf === prevBuf;
1593
- 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) {
1594
1721
  if (readySinceAt == null) readySinceAt = this._now();
1595
1722
  if (this._now() - readySinceAt >= this.quiesceMs) return;
1596
1723
  } else {
1597
- 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;
1598
1728
  }
1599
1729
  prevBuf = lastBuf;
1600
1730
  await this._waitForNextTick();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.10.0-rc.25",
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": {