polygram 0.12.0-rc.22 → 0.12.0-rc.24

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.
@@ -96,15 +96,28 @@ function createHandleAbort({
96
96
  }
97
97
  }
98
98
 
99
- // SDK abort: interrupt() + drainQueue(). interrupt() cancels
100
- // the in-flight turn at SDK level WITHOUT tearing down the
101
- // Query (cheap to reuse for the user's next message);
102
- // drainQueue() rejects every queued pending with
103
- // err.code='INTERRUPTED' so the abort-grace classifier
104
- // suppresses error replies.
105
- await pm.interrupt(sessionKey).catch((err) =>
106
- logger.error?.(`[${botName}] interrupt failed: ${err.message}`));
99
+ // Reject queued pendings first (err.code='INTERRUPTED' the abort-grace
100
+ // classifier suppresses their error replies AND each turn's finally clears
101
+ // its reactor + typing), THEN stop the live work.
107
102
  pm.drainQueue(sessionKey, 'INTERRUPTED');
103
+ if (hadActive && proc && proc.backend === 'cli') {
104
+ // Channels HARD stop (user decision 2026-06-04: "/stop should stop
105
+ // everything including background, like the SDK backend"). A soft C-c
106
+ // interrupt leaves detached background shells + subagents running and
107
+ // can't clear a ghost (no-pending-turn) busy state — the symptom was
108
+ // "Stopped." with the reaction + typing still going. Kill the session: the
109
+ // whole process tree (claude + every subagent + all background shells)
110
+ // dies at once, the close drains the in-flight turn (clearing its
111
+ // reactor/typing), and the next message respawns fresh (--resume restores
112
+ // the conversation). This is what makes channels /stop "stop everything".
113
+ await pm.kill(sessionKey, 'abort').catch((err) =>
114
+ logger.error?.(`[${botName}] abort kill failed: ${err.message}`));
115
+ } else {
116
+ // SDK (or nothing active): non-destructive interrupt cancels the in-flight
117
+ // Query turn WITHOUT tearing down the Query (cheap to reuse next message).
118
+ await pm.interrupt(sessionKey).catch((err) =>
119
+ logger.error?.(`[${botName}] interrupt failed: ${err.message}`));
120
+ }
108
121
 
109
122
  clearAutosteeredReactions(sessionKey).catch(() => {});
110
123
  logEvent('abort-requested', {
@@ -248,6 +248,9 @@ class CliProcess extends Process {
248
248
  // at one read-only self-check per continuous background-work window.
249
249
  this._bgWorkSince = null;
250
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;
251
254
  // Review P2 ADV-6: token-bucket rate limit on Claude's reply tool calls.
252
255
  // Without this, a prompt-injected or runaway Claude can fire reply() 1000×
253
256
  // in a tight loop, flooding TG + saturating the daemon event loop.
@@ -1643,16 +1646,24 @@ class CliProcess extends Process {
1643
1646
  if (!live) {
1644
1647
  if (this._bgWorkSince !== null) {
1645
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
+ }
1646
1654
  }
1647
1655
  this._bgWorkSince = null;
1648
1656
  this._bgWorkEscalations = 0;
1649
1657
  return;
1650
1658
  }
1651
1659
  if (this._bgWorkSince === null) {
1652
- // First idle observation of a live background shell — start the clock.
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.
1653
1662
  this._bgWorkSince = Date.now();
1654
1663
  this._bgWorkEscalations = 0;
1655
1664
  this._logEvent('cli-bg-work-detected', { shell_count: count });
1665
+ this.emit('bg-work-status', { state: 'running', count });
1666
+ this._bgWorkStatusShown = true;
1656
1667
  return;
1657
1668
  }
1658
1669
  const idleMs = Date.now() - this._bgWorkSince;
@@ -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
@@ -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.22",
3
+ "version": "0.12.0-rc.24",
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": {