polygram 0.12.0-rc.21 → 0.12.0-rc.23
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/lib/process/cli-process.js +122 -1
- package/lib/process-manager.js +6 -0
- package/lib/sdk/callbacks.js +58 -0
- package/package.json +1 -1
|
@@ -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,15 @@ 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;
|
|
251
|
+
// Visibility (Use 3): whether a "⏳ working in background" status message is
|
|
252
|
+
// currently shown, so we emit exactly one running→cleared pair per window.
|
|
253
|
+
this._bgWorkStatusShown = false;
|
|
229
254
|
// Review P2 ADV-6: token-bucket rate limit on Claude's reply tool calls.
|
|
230
255
|
// Without this, a prompt-injected or runaway Claude can fire reply() 1000×
|
|
231
256
|
// in a tight loop, flooding TG + saturating the daemon event loop.
|
|
@@ -1531,7 +1556,7 @@ class CliProcess extends Process {
|
|
|
1531
1556
|
*/
|
|
1532
1557
|
async probeBusyState() {
|
|
1533
1558
|
const base = {
|
|
1534
|
-
busy: false, streaming: false,
|
|
1559
|
+
busy: false, streaming: false, backgroundShell: false, shellCount: 0,
|
|
1535
1560
|
inFlight: this.inFlight, pendingTurns: this.pendingTurns.size,
|
|
1536
1561
|
captured: false, paneTail: null,
|
|
1537
1562
|
};
|
|
@@ -1547,10 +1572,23 @@ class CliProcess extends Process {
|
|
|
1547
1572
|
}
|
|
1548
1573
|
if (!pane) return base;
|
|
1549
1574
|
const streaming = STREAMING_HINT_RE.test(pane);
|
|
1575
|
+
// Background-shell count from the TUI mode line. Match only the captured
|
|
1576
|
+
// TAIL (the mode line lives at the bottom of the viewport) so a `· N shell ·`
|
|
1577
|
+
// string scrolled off into history can't trip a stale false-positive — see
|
|
1578
|
+
// BACKGROUND_SHELL_RE. A detached `run_in_background` Bash that outlived its
|
|
1579
|
+
// turn shows here even while claude is idle and not streaming.
|
|
1580
|
+
const m = pane.slice(-400).match(BACKGROUND_SHELL_RE);
|
|
1581
|
+
const shellCount = m ? parseInt(m[1], 10) : 0;
|
|
1582
|
+
const backgroundShell = shellCount > 0;
|
|
1550
1583
|
return {
|
|
1551
1584
|
...base,
|
|
1585
|
+
// `busy` stays streaming-only — it is the abort path's "is claude working a
|
|
1586
|
+
// turn" signal and must not change behaviour. Background-shell liveness is a
|
|
1587
|
+
// separate axis the stall-watchdog reads via `backgroundShell`/`shellCount`.
|
|
1552
1588
|
busy: streaming,
|
|
1553
1589
|
streaming,
|
|
1590
|
+
backgroundShell,
|
|
1591
|
+
shellCount,
|
|
1554
1592
|
captured: true,
|
|
1555
1593
|
paneTail: pane.slice(-200),
|
|
1556
1594
|
};
|
|
@@ -1562,6 +1600,84 @@ class CliProcess extends Process {
|
|
|
1562
1600
|
return busy;
|
|
1563
1601
|
}
|
|
1564
1602
|
|
|
1603
|
+
/**
|
|
1604
|
+
* Does this session have a detached background shell running RIGHT NOW — a
|
|
1605
|
+
* `run_in_background` Bash that may have outlived its turn? Thin probe over
|
|
1606
|
+
* probeBusyState's background-shell signal; the stall-watchdog's input.
|
|
1607
|
+
* @returns {Promise<{live:boolean, count:number}>}
|
|
1608
|
+
*/
|
|
1609
|
+
async hasLiveBackgroundWork() {
|
|
1610
|
+
const { backgroundShell, shellCount } = await this.probeBusyState();
|
|
1611
|
+
return { live: backgroundShell, count: shellCount };
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Stall-watchdog for detached background work (0.12.0 background-work
|
|
1616
|
+
* lifecycle, shumorobot Music 7h frozen-Chrome download). Runs on the
|
|
1617
|
+
* pongWatchdog 5s tick but ONLY while the session is IDLE (pendingTurns===0) —
|
|
1618
|
+
* the mirror of _pollMidTurnDialogs, which only runs DURING turns. When a
|
|
1619
|
+
* `run_in_background` Bash outlives its turn and keeps running while claude is
|
|
1620
|
+
* idle for > bgWorkStallMs, nothing tells the agent or user whether it's
|
|
1621
|
+
* progressing or stuck. One read-only self-check re-invokes the agent to
|
|
1622
|
+
* diagnose — via `fireUserMessage`, NOT `injectUserMessage` (which no-ops when
|
|
1623
|
+
* !inFlight, the exact idle state here). Read-only framing matters: the agent
|
|
1624
|
+
* runs bypassPermissions, so an open-ended "fix it" could background another
|
|
1625
|
+
* hung shell unattended.
|
|
1626
|
+
*
|
|
1627
|
+
* Exactly one self-check per continuous background-work window (capped by
|
|
1628
|
+
* `_bgWorkEscalations`); the window resets only when the shell count returns to
|
|
1629
|
+
* 0. Never throws — swallows its own errors so the pong watchdog stays clean.
|
|
1630
|
+
*/
|
|
1631
|
+
async _pollBackgroundWork() {
|
|
1632
|
+
if (this.closed || !this.bridgeReady) return;
|
|
1633
|
+
// Only watch while idle. An active turn means the agent is engaged
|
|
1634
|
+
// (_pollMidTurnDialogs owns that path). Crucially we do NOT reset the clock
|
|
1635
|
+
// here — the same shell is still running, so the window persists across a
|
|
1636
|
+
// brief self-check turn rather than restarting and re-pinging every window.
|
|
1637
|
+
if (this.pendingTurns.size > 0) return;
|
|
1638
|
+
let live = false;
|
|
1639
|
+
let count = 0;
|
|
1640
|
+
try {
|
|
1641
|
+
({ live, count } = await this.hasLiveBackgroundWork());
|
|
1642
|
+
} catch (err) {
|
|
1643
|
+
this.logger.warn?.(`[${this.label}] channels: bg-work probe failed: ${err.message}`);
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
if (!live) {
|
|
1647
|
+
if (this._bgWorkSince !== null) {
|
|
1648
|
+
this._logEvent('cli-bg-work-cleared', { idle_ms: Date.now() - this._bgWorkSince });
|
|
1649
|
+
// Visibility: tear down the status indicator once work clears.
|
|
1650
|
+
if (this._bgWorkStatusShown) {
|
|
1651
|
+
this.emit('bg-work-status', { state: 'cleared' });
|
|
1652
|
+
this._bgWorkStatusShown = false;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
this._bgWorkSince = null;
|
|
1656
|
+
this._bgWorkEscalations = 0;
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
if (this._bgWorkSince === null) {
|
|
1660
|
+
// First idle observation of a live background shell — start the clock AND
|
|
1661
|
+
// raise the visibility indicator so a long job reads as working, not stuck.
|
|
1662
|
+
this._bgWorkSince = Date.now();
|
|
1663
|
+
this._bgWorkEscalations = 0;
|
|
1664
|
+
this._logEvent('cli-bg-work-detected', { shell_count: count });
|
|
1665
|
+
this.emit('bg-work-status', { state: 'running', count });
|
|
1666
|
+
this._bgWorkStatusShown = true;
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
const idleMs = Date.now() - this._bgWorkSince;
|
|
1670
|
+
if (idleMs < this.bgWorkStallMs || this._bgWorkEscalations >= 1) return;
|
|
1671
|
+
const mins = Math.max(1, Math.round(idleMs / 60000));
|
|
1672
|
+
const prompt =
|
|
1673
|
+
`⏳ A background job has been running ~${mins} min with no update. `
|
|
1674
|
+
+ `Check its status and report whether it's progressing or stuck. `
|
|
1675
|
+
+ `Do NOT start new work, re-run it, or kill anything — report only.`;
|
|
1676
|
+
const fired = this.fireUserMessage(prompt);
|
|
1677
|
+
this._bgWorkEscalations = 1;
|
|
1678
|
+
this._logEvent('cli-bg-work-stall-selfcheck', { idle_ms: idleMs, shell_count: count, fired });
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1565
1681
|
async kill(reason = 'kill') {
|
|
1566
1682
|
if (this.closed) return;
|
|
1567
1683
|
// Parity P19: re-entry guard for concurrent kill() calls. Mirrors
|
|
@@ -2397,6 +2513,11 @@ class CliProcess extends Process {
|
|
|
2397
2513
|
this._pollMidTurnDialogs().catch((err) => {
|
|
2398
2514
|
this.logger.warn?.(`[${this.label}] channels: mid-turn poll failed: ${err.message}`);
|
|
2399
2515
|
});
|
|
2516
|
+
// 0.12.0 background-work lifecycle: idle-side stall-watchdog, the mirror of
|
|
2517
|
+
// _pollMidTurnDialogs (which only runs during turns). Fire-and-forget.
|
|
2518
|
+
this._pollBackgroundWork().catch((err) => {
|
|
2519
|
+
this.logger.warn?.(`[${this.label}] channels: bg-work poll failed: ${err.message}`);
|
|
2520
|
+
});
|
|
2400
2521
|
}, PONG_CHECK_INTERVAL_MS);
|
|
2401
2522
|
this.pongWatchdog.unref?.();
|
|
2402
2523
|
}
|
package/lib/process-manager.js
CHANGED
|
@@ -47,6 +47,12 @@ const CALLBACK_TO_EVENT = {
|
|
|
47
47
|
// auto-compacting now. The callback posts a chat message proposing /compact
|
|
48
48
|
// — opt-in per chat. See docs/0.12.0-file-send.md / lib/compaction-warn.js.
|
|
49
49
|
onCompactionWarn: 'compaction-warn',
|
|
50
|
+
// 0.12.0 background-work visibility (Use 3). CliProcess emits 'bg-work-status'
|
|
51
|
+
// {state:'running'|'cleared', count?} when a detached background shell is first
|
|
52
|
+
// observed running idle past its turn, and again when it clears. The callback
|
|
53
|
+
// posts/edits a "⏳ working in background" status message so a long job reads as
|
|
54
|
+
// working, not stuck. See docs/0.12.0-background-work-lifecycle-plan.md.
|
|
55
|
+
onBgWorkStatus: 'bg-work-status',
|
|
50
56
|
onQueueDrop: 'queue-drop',
|
|
51
57
|
onThinking: 'thinking',
|
|
52
58
|
// Tmux backend: TUI shows in-pane approval prompt. SDK backend
|
package/lib/sdk/callbacks.js
CHANGED
|
@@ -60,6 +60,10 @@ function createSdkCallbacks({
|
|
|
60
60
|
// because the TUI's queue is FIFO and we only watch one extra turn
|
|
61
61
|
// at a time per session.
|
|
62
62
|
const extraTurnTracker = new Map(); // sessionKey → { msgId, intervalHandle, chatId }
|
|
63
|
+
// 0.12.0 background-work visibility (Use 3): sessionKey → message_id of the live
|
|
64
|
+
// "⏳ working in background" status message, so the cleared/close paths can edit
|
|
65
|
+
// it to a final state instead of leaving it dangling as "working".
|
|
66
|
+
const bgStatusMsgIds = new Map();
|
|
63
67
|
|
|
64
68
|
function startExtraTurnVisuals(sessionKey, msgId) {
|
|
65
69
|
if (!bot) return;
|
|
@@ -152,6 +156,17 @@ function createSdkCallbacks({
|
|
|
152
156
|
// visuals so we don't leak the interval and aren't stuck
|
|
153
157
|
// showing "writing…" on a dead session.
|
|
154
158
|
stopExtraTurnVisuals(sessionKey, null);
|
|
159
|
+
// 0.12.0 bg-work visibility: if a "⏳ working in background" status is still
|
|
160
|
+
// up when the session closes, its shell died with the session — edit to a
|
|
161
|
+
// final state so it doesn't dangle as "working" forever.
|
|
162
|
+
const bgMid = bgStatusMsgIds.get(sessionKey);
|
|
163
|
+
if (bgMid != null && bot) {
|
|
164
|
+
bgStatusMsgIds.delete(sessionKey);
|
|
165
|
+
tg(bot, 'editMessageText', {
|
|
166
|
+
chat_id: entry.chatId, message_id: bgMid,
|
|
167
|
+
text: '⏹ Background work ended (session restarted).',
|
|
168
|
+
}, { source: 'bg-work-status', botName }).catch(() => {});
|
|
169
|
+
}
|
|
155
170
|
},
|
|
156
171
|
|
|
157
172
|
onStreamChunk: (sessionKey, partial, entry) => {
|
|
@@ -324,6 +339,49 @@ function createSdkCallbacks({
|
|
|
324
339
|
}
|
|
325
340
|
},
|
|
326
341
|
|
|
342
|
+
// 0.12.0 background-work visibility (Use 3). CliProcess emits this when a
|
|
343
|
+
// detached `run_in_background` shell is first observed running idle past its
|
|
344
|
+
// turn ('running') and again when it clears ('cleared'). We post ONE bot
|
|
345
|
+
// status message and edit it to done — so a long job reads as working, not
|
|
346
|
+
// stuck. Direct tg send (NOT via claude — this is a bot status indicator),
|
|
347
|
+
// keyed by sessionKey so the cleared/close paths can find it to edit.
|
|
348
|
+
onBgWorkStatus: async (sessionKey, payload) => {
|
|
349
|
+
try {
|
|
350
|
+
if (!bot) return;
|
|
351
|
+
const chatId = getChatIdFromKey(sessionKey);
|
|
352
|
+
const threadIdRaw = getThreadIdFromKey(sessionKey);
|
|
353
|
+
const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
|
|
354
|
+
const state = payload?.state;
|
|
355
|
+
if (state === 'running') {
|
|
356
|
+
if (bgStatusMsgIds.has(sessionKey)) return; // already showing one
|
|
357
|
+
const res = await tg(bot, 'sendMessage', {
|
|
358
|
+
chat_id: chatId,
|
|
359
|
+
text: '⏳ Working in the background — I\'ll keep an eye on it and report when it\'s done.',
|
|
360
|
+
...(Number.isInteger(threadId) && { message_thread_id: threadId }),
|
|
361
|
+
}, { source: 'bg-work-status', botName });
|
|
362
|
+
const mid = res?.message_id ?? res?.result?.message_id ?? null;
|
|
363
|
+
if (mid != null) bgStatusMsgIds.set(sessionKey, mid);
|
|
364
|
+
logEvent('bg-work-status', {
|
|
365
|
+
chat_id: chatId, session_key: sessionKey, thread_id: threadIdRaw,
|
|
366
|
+
state: 'running', message_id: mid,
|
|
367
|
+
});
|
|
368
|
+
} else if (state === 'cleared') {
|
|
369
|
+
const mid = bgStatusMsgIds.get(sessionKey);
|
|
370
|
+
bgStatusMsgIds.delete(sessionKey);
|
|
371
|
+
if (mid == null) return;
|
|
372
|
+
await tg(bot, 'editMessageText', {
|
|
373
|
+
chat_id: chatId, message_id: mid, text: '✅ Background work finished.',
|
|
374
|
+
}, { source: 'bg-work-status', botName }).catch(() => {});
|
|
375
|
+
logEvent('bg-work-status', {
|
|
376
|
+
chat_id: chatId, session_key: sessionKey, thread_id: threadIdRaw,
|
|
377
|
+
state: 'cleared', message_id: mid,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
} catch (err) {
|
|
381
|
+
logger.error?.(`[${botName}] bg-work-status handler: ${err.message}`);
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
|
|
327
385
|
// rc.9: pair-of with onExtraTurnReply. Fires the moment
|
|
328
386
|
// TmuxProcess sees the dequeued user-message in JSONL → turn 2
|
|
329
387
|
// is starting. Re-engages typing indicator + ✍ on the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.12.0-rc.
|
|
3
|
+
"version": "0.12.0-rc.23",
|
|
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": {
|