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.
- package/.claude-plugin/plugin.json +1 -1
- package/config.example.json +4 -2
- package/lib/process/tmux-process.js +159 -29
- package/package.json +1 -1
|
@@ -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.
|
|
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",
|
package/config.example.json
CHANGED
|
@@ -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
|
|
1549
|
-
//
|
|
1550
|
-
//
|
|
1551
|
-
//
|
|
1552
|
-
//
|
|
1553
|
-
//
|
|
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
|
-
//
|
|
1556
|
-
//
|
|
1557
|
-
//
|
|
1558
|
-
//
|
|
1559
|
-
//
|
|
1560
|
-
//
|
|
1561
|
-
//
|
|
1562
|
-
//
|
|
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
|
|
1565
|
-
//
|
|
1566
|
-
//
|
|
1567
|
-
//
|
|
1568
|
-
// `
|
|
1569
|
-
// ready hint)
|
|
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
|
-
//
|
|
1572
|
-
//
|
|
1573
|
-
//
|
|
1574
|
-
//
|
|
1575
|
-
//
|
|
1576
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|