polygram 0.17.4 → 0.17.7

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.
@@ -69,6 +69,22 @@ function createSessionFeedback({
69
69
  });
70
70
  }
71
71
 
72
+ // Stop the cycle's TYPING the moment it delivers its answer, decoupled from
73
+ // endCycle. endCycle fires on the Process 'idle' edge (= session idle), which a
74
+ // later turn delays — so without this the "typing…" indicator spun minutes past the
75
+ // delivered answer (field: Ivan DM 2026-06-26). The entry is LEFT in place so
76
+ // endCycle still clears the anchor 🤔 and tears the entry down. docs/typing-tracks-activity-spec.md
77
+ function stopCycleTyping(sessionKey) {
78
+ const entry = active.get(sessionKey);
79
+ if (!entry || entry.typingStopped) return;
80
+ entry.typingStopped = true;
81
+ try { entry.stop(); } catch { /* best-effort */ }
82
+ logEvent('autonomous-cycle-visuals', {
83
+ chat_id: entry.anchor?.chatId ?? getChatIdFromKey(sessionKey),
84
+ session_key: sessionKey, state: 'typing-stopped',
85
+ });
86
+ }
87
+
72
88
  function endCycle(sessionKey) {
73
89
  const entry = active.get(sessionKey);
74
90
  if (!entry) return;
@@ -85,7 +101,7 @@ function createSessionFeedback({
85
101
  });
86
102
  }
87
103
 
88
- return { startAutonomousCycle, endCycle };
104
+ return { startAutonomousCycle, stopCycleTyping, endCycle };
89
105
  }
90
106
 
91
107
  module.exports = { createSessionFeedback };
@@ -133,8 +133,24 @@ function createDispatcher({
133
133
  throw new Error('auto-resume turn produced no text');
134
134
  }
135
135
 
136
- // 4. Send the continuation reply as regular Telegram messages,
137
- // threaded under the original user message.
136
+ // 4. Deliver the continuation reply UNLESS the resumed turn already
137
+ // delivered it itself. On the channels/cli backend Claude responds via the
138
+ // reply tool DURING the turn, so result.alreadyDelivered is set and the main
139
+ // dispatch path short-circuits its own deliver (cli-process.js ~2116). The
140
+ // resume path must honor it too, or the reply-tool send + this re-send
141
+ // double-post the SAME answer (field: shumabit@umi WhatsApp topic 2026-06-27,
142
+ // a bridge-disconnect resume sent "Fixed. ✅…" twice). SDK / genuine no-reply
143
+ // turns leave it falsy → deliver as before.
144
+ if (result.alreadyDelivered) {
145
+ logEvent('auto-resume-already-delivered', {
146
+ chat_id: chatId, session_key: sessionKey, msg_id: originalMsg.message_id,
147
+ text_len: result.text.length,
148
+ });
149
+ return result.text;
150
+ }
151
+
152
+ // Send the continuation reply as regular Telegram messages, threaded under
153
+ // the original user message.
138
154
  const chunks = chunkMarkdownText(result.text, chunkBudget);
139
155
  await deliverReplies({
140
156
  bot,
@@ -119,11 +119,19 @@ function resolveToolAck(toolCallId, ok, error, messageId) {
119
119
  }
120
120
 
121
121
  // ─── 0.12 interactive questions: `ask` blocks for the user's answer ──
122
- // Separate from tool_ack: a question can take MINUTES (the daemon-side 30-min
123
- // question timeoutaligned to the turn absolute cap resolves it with {timedout}
124
- // before this hard ceiling, which sits just above as the last-resort backstop).
122
+ // Separate from tool_ack: a question waits for the user, possibly for hours. The
123
+ // DAEMON owns the lifecycle it resolves the ask with the user's answer, or sweeps
124
+ // it {timedout} at its configured question timeout (POLYGRAM_QUESTION_TIMEOUT_MS,
125
+ // default 24h). This local timer is ONLY a last-resort backstop for the narrow case
126
+ // where the daemon stays connected but never calls back; it sits a margin ABOVE the
127
+ // daemon timeout so the daemon always resolves first (with the proper user-facing
128
+ // message). It must track the daemon value — a hardcoded 32min here once fired long
129
+ // before the 24h wait, resolving {timedout} on a question the user answered an hour
130
+ // later (0.17.5).
125
131
  const pendingQuestions = new Map() // tool_call_id → { resolve, timer }
126
- const QUESTION_ANSWER_TIMEOUT_MS = 32 * 60 * 1000
132
+ const QUESTION_BACKSTOP_MARGIN_MS = 5 * 60 * 1000
133
+ const DAEMON_QUESTION_TIMEOUT_MS = Number(process.env.POLYGRAM_QUESTION_TIMEOUT_MS) || (24 * 60 * 60 * 1000)
134
+ const QUESTION_ANSWER_TIMEOUT_MS = DAEMON_QUESTION_TIMEOUT_MS + QUESTION_BACKSTOP_MARGIN_MS
127
135
 
128
136
  function awaitQuestionAnswer(toolCallId) {
129
137
  return new Promise((resolve) => {
@@ -48,6 +48,10 @@ const { Process, UnsupportedOperationError } = require('./process');
48
48
  const { ChannelsBridgeServer } = require('./channels-bridge-server');
49
49
  const { writeHookFiles, removeHookFiles } = require('./hook-settings');
50
50
  const { createHookTail } = require('./hook-event-tail');
51
+ // Single source of truth for the question wait: the daemon owns the question
52
+ // lifecycle (answer or {timedout} sweep), and we pass this to the bridge so its
53
+ // last-resort `ask` backstop sits ABOVE it instead of undercutting it.
54
+ const { DEFAULT_TIMEOUT_MS: QUESTION_TIMEOUT_MS } = require('../questions/store');
51
55
  // File-send staging: reuse the dispatcher's allowlist root so the dir we
52
56
  // create exactly matches the realpath the validator accepts (no /tmp vs
53
57
  // /private/tmp drift — one of the original Music-topic failures).
@@ -343,6 +347,9 @@ class CliProcess extends Process {
343
347
  // is the broader surface (hooks + pane heartbeat + bridge tool calls).
344
348
  this._lastHookEventAt = 0;
345
349
  this._lastActivityAt = 0;
350
+ // Monotonic count of work hooks (all but the terminal Stop) — the rung-2
351
+ // no-reply backstop snapshots it at Stop capture to detect a later resume.
352
+ this._workHookSeq = 0;
346
353
  // 0.13 D2: the InputLedger — every user-shaped input written to the bridge
347
354
  // gets an observable lifecycle: written → seen → resolved | dropped |
348
355
  // superseded | fold-suspected. Pre-P3, injectUserMessage minted a turn_id
@@ -553,13 +560,25 @@ class CliProcess extends Process {
553
560
  await this.bridgeServer.listen();
554
561
  }
555
562
 
556
- async _spawnTmuxClaude({ tmuxName, opts }) {
557
- const bridgeEnv = {
558
- POLYGRAM_SESSION_KEY: this.sessionKey,
559
- POLYGRAM_SOCK: this.sockPath,
560
- POLYGRAM_SOCK_SECRET: this.sockSecret,
561
- POLYGRAM_CLAUDE_SESSION_ID: this.claudeSessionId,
563
+ /**
564
+ * Env for the spawned channels-bridge MCP subprocess. POLYGRAM_QUESTION_TIMEOUT_MS
565
+ * tells the bridge our question wait so its last-resort `ask` backstop sits ABOVE
566
+ * it — without it the bridge fell back to a hardcoded 32min that fired long before
567
+ * the daemon's 24h wait, so a question the user answered an hour later was already
568
+ * resolved {timedout}. Extracted (pure) so the alignment is unit-testable.
569
+ */
570
+ _bridgeEnv() {
571
+ return {
572
+ POLYGRAM_SESSION_KEY: this.sessionKey,
573
+ POLYGRAM_SOCK: this.sockPath,
574
+ POLYGRAM_SOCK_SECRET: this.sockSecret,
575
+ POLYGRAM_CLAUDE_SESSION_ID: this.claudeSessionId,
576
+ POLYGRAM_QUESTION_TIMEOUT_MS: String(QUESTION_TIMEOUT_MS),
562
577
  };
578
+ }
579
+
580
+ async _spawnTmuxClaude({ tmuxName, opts }) {
581
+ const bridgeEnv = this._bridgeEnv();
563
582
  const mcpConfig = {
564
583
  mcpServers: {
565
584
  'polygram-bridge': {
@@ -1764,20 +1783,41 @@ class CliProcess extends Process {
1764
1783
  }
1765
1784
  }
1766
1785
 
1786
+ /**
1787
+ * Is this turn eligible for the rung-2 activity-quiet finalize? Eligible when the
1788
+ * answer is already captured where a finalize can deliver it:
1789
+ * - a delivered FINAL reply (it went out incrementally), OR
1790
+ * - seen + consumed-acked (the answer rode a sibling turn_id — fold-id echo;
1791
+ * see _ledgerAckConsumed), OR
1792
+ * - an attributed Stop captured the answer AND no work hook has fired since
1793
+ * (_workHookSeq unchanged from the capture) — i.e. claude is genuinely done,
1794
+ * not resumed into more work. A reply-less turn's only finalizer is its Stop grace;
1795
+ * when a pane-thinking heartbeat cancels that grace (the turn's own residual
1796
+ * "esc to interrupt"), this is the backstop that still delivers the captured
1797
+ * last_assistant_message instead of orphaning to the idle ceiling. The
1798
+ * hook-recency check withdraws eligibility the moment claude resumes (a resume
1799
+ * emits PreToolUse/etc. that increments _workHookSeq past the capture), so a
1800
+ * stale early Stop can't finalize over a still-working turn — that also covers
1801
+ * an in-flight sub-agent, which emits work hooks after any boundary Stop.
1802
+ * An interim-only turn with no captured answer stays ineligible (it must keep working).
1803
+ */
1804
+ _activityQuietEligible(pending) {
1805
+ if (this._turnHasFinalReply(pending)) return true;
1806
+ if (pending.seen === true && pending._consumedAcked === true) return true;
1807
+ if (pending._stopHookData
1808
+ && (this._workHookSeq || 0) === (pending._stopHookDataSeq || 0)) return true;
1809
+ return false;
1810
+ }
1811
+
1767
1812
  /**
1768
1813
  * D1 rung 2: arm/refresh the activity-quiet finalize for one pending.
1769
- * Preconditions: hooks live, ≥1 delivered reply (a reply-less turn ends via
1770
- * rung 1 or the ceilings), no open question (waiting-on-user suspends the
1771
- * clock — claude is legitimately silent), and no rung-1 grace in flight.
1814
+ * Preconditions: hooks live, the answer is captured (see _activityQuietEligible),
1815
+ * no open question (waiting-on-user suspends the clock — claude is legitimately
1816
+ * silent), and no rung-1 grace in flight.
1772
1817
  */
1773
1818
  _armActivityQuiet(turnId, pending) {
1774
1819
  if (!this._sawHookStream) return;
1775
- // ≥1 FINAL reply, OR seen + consumed-acked (the answer rode a sibling turn_id —
1776
- // fold-id echo; see _ledgerAckConsumed). Same eligibility as the fire site. An
1777
- // interim-only turn (status promise, no final reply) is NOT eligible — it must
1778
- // keep working, not quiet-finalize as done. docs/progress-is-not-turn-end-spec.md
1779
- if (!this._turnHasFinalReply(pending)
1780
- && !(pending.seen === true && pending._consumedAcked === true)) return;
1820
+ if (!this._activityQuietEligible(pending)) return;
1781
1821
  if (this._openQuestions.size > 0) return;
1782
1822
  if (pending._stopGracePending) return;
1783
1823
  if (pending._activityQuietTimer) clearTimeout(pending._activityQuietTimer);
@@ -1797,20 +1837,19 @@ class CliProcess extends Process {
1797
1837
 
1798
1838
  /**
1799
1839
  * D1 rung 2 fire: the whole activity surface (hooks + pane heartbeat + bridge
1800
- * tool calls) has been quiet for activityQuietMs on a replied turn the tail
1801
- * is over (Stop was lost, foreign, or the hook stream died mid-session; the
1802
- * pre-D1 `_sawHookStream` one-way boolean left that last class with NO
1803
- * finalizer until a 10-min TURN_TIMEOUT *rejection* after a delivered answer).
1840
+ * tool calls) has been quiet for activityQuietMs and the answer is captured (a
1841
+ * delivered reply, a consumed-ack, or an attributed Stop see
1842
+ * _activityQuietEligible). The tail is over (Stop was lost, foreign, the hook
1843
+ * stream died mid-session, or — the no-reply case the Stop grace was cancelled
1844
+ * by a pane-thinking heartbeat racing the Stop's own residual streaming hint).
1804
1845
  */
1805
1846
  _activityQuietFinalize(turnId) {
1806
1847
  const pending = this.pendingTurns.get(turnId);
1807
1848
  if (!pending) return;
1808
1849
  if (pending._stopGracePending) return;
1809
1850
  if (this._openQuestions.size > 0) return; // re-check at fire time
1810
- // Eligibility: ≥1 bound reply, OR seen + consumed-acked (the answer went
1811
- // out under a sibling turn_id — fold-id echo; see _ledgerAckConsumed).
1851
+ if (!this._activityQuietEligible(pending)) return;
1812
1852
  const consumedAcked = pending.seen === true && pending._consumedAcked === true;
1813
- if (!this._turnHasFinalReply(pending) && !consumedAcked) return;
1814
1853
  const lastHookAgeMs = this._lastHookEventAt ? Date.now() - this._lastHookEventAt : null;
1815
1854
  this._logEvent('cli-activity-quiet-finalize', {
1816
1855
  turn_id: turnId,
@@ -1819,6 +1858,16 @@ class CliProcess extends Process {
1819
1858
  last_hook_age_ms: lastHookAgeMs,
1820
1859
  had_stop: !!pending._stopHookData,
1821
1860
  });
1861
+ // The no-reply rescue: a reply-less, not-consumed-acked turn finalizing here
1862
+ // qualified ONLY via its captured Stop — i.e. it would have orphaned to the idle
1863
+ // ceiling before this backstop existed. Distinct event so the soak can count it.
1864
+ if (!this._turnHasFinalReply(pending) && !consumedAcked) {
1865
+ this._logEvent('cli-noreply-stop-rescued', {
1866
+ turn_id: turnId,
1867
+ last_hook_age_ms: lastHookAgeMs,
1868
+ text_len: (pending._stopHookData?.lastAssistantMessage || '').length,
1869
+ });
1870
+ }
1822
1871
  if (lastHookAgeMs != null && lastHookAgeMs >= this.activityQuietMs) {
1823
1872
  // A previously-live hook stream went quiet enough that rung 2 (not an
1824
1873
  // attributed Stop) ended the turn — the soak's mid-session-death signal.
@@ -1827,13 +1876,25 @@ class CliProcess extends Process {
1827
1876
  this._finalizeTurn(turnId);
1828
1877
  }
1829
1878
 
1879
+ /**
1880
+ * Capture a Stop hook's data on a pending, recording the work-hook count AT capture.
1881
+ * The rung-2 no-reply backstop (_activityQuietEligible) compares the live _workHookSeq
1882
+ * against this snapshot to tell "claude is done" (no work hook since the Stop) from
1883
+ * "claude resumed" (a later work hook bumped the count). A monotonic counter — not a
1884
+ * timestamp — so a Stop and a resume hook landing in the same millisecond still differ.
1885
+ */
1886
+ _captureStopHookData(pending, info) {
1887
+ pending._stopHookData = info;
1888
+ pending._stopHookDataSeq = this._workHookSeq || 0;
1889
+ }
1890
+
1830
1891
  /**
1831
1892
  * D1 rung 1: an attributed Stop (the pending was `seen` at pickup, or has
1832
1893
  * ≥1 turn_id-bound reply) finalizes through a short grace that any
1833
1894
  * subsequent same-session activity cancels (see _noteActivity #2).
1834
1895
  */
1835
1896
  _beginAttributedStopGrace(turnId, pending, info) {
1836
- pending._stopHookData = info;
1897
+ this._captureStopHookData(pending, info);
1837
1898
  pending._stopGracePending = true;
1838
1899
  if (pending._activityQuietTimer) {
1839
1900
  clearTimeout(pending._activityQuietTimer);
@@ -1932,7 +1993,7 @@ class CliProcess extends Process {
1932
1993
  let graceCount = 0;
1933
1994
  for (const p of this.pendingTurns.values()) if (p._stopGracePending) graceCount++;
1934
1995
  if (graceCount !== 1) return;
1935
- pending._stopHookData = info;
1996
+ this._captureStopHookData(pending, info);
1936
1997
  clearTimeout(pending._stopGraceTimer);
1937
1998
  pending._stopGraceTimer = null;
1938
1999
  finalize();
@@ -2876,6 +2937,10 @@ class CliProcess extends Process {
2876
2937
  this._lastHookEventAt = Date.now();
2877
2938
  } else if (ev.type && ev.type !== 'parse-error' && ev.type !== 'unknown') {
2878
2939
  this._lastHookEventAt = Date.now();
2940
+ // Monotonic count of WORK hooks (everything but the terminal Stop). The rung-2
2941
+ // no-reply backstop snapshots this at Stop capture; a later increment means
2942
+ // claude resumed work, withdrawing the stale Stop's finalize eligibility.
2943
+ this._workHookSeq = (this._workHookSeq || 0) + 1;
2879
2944
  this._noteActivity(`hook:${ev.type}`);
2880
2945
  }
2881
2946
 
@@ -3058,7 +3123,7 @@ class CliProcess extends Process {
3058
3123
  // sub-agent: refresh the captured last_assistant_message so the
3059
3124
  // eventual finalize delivers the LATEST produced answer (claude's real
3060
3125
  // end-of-work text), not the boundary Stop's stale/partial text.
3061
- p._stopHookData = info;
3126
+ this._captureStopHookData(p, info);
3062
3127
  }
3063
3128
  } else if (this.pendingTurns.size > 1) {
3064
3129
  // Can't attribute Stop to one of several concurrent turns — surface
@@ -216,6 +216,10 @@ function createSdkCallbacks({
216
216
  const text = (msg && typeof msg.text === 'string' && msg.text)
217
217
  || extractAssistantText(msg);
218
218
  if (!text) return;
219
+ // 0.13 D3 fix: the cycle delivered its answer → stop its "typing…" NOW. endCycle
220
+ // (Process idle = SESSION idle) is delayed by any later turn, so typing otherwise
221
+ // spins minutes past the delivered answer. docs/typing-tracks-activity-spec.md
222
+ sessionFeedback?.stopCycleTyping?.(sessionKey);
219
223
  const chatId = getChatIdFromKey(sessionKey);
220
224
  const threadIdRaw = getThreadIdFromKey(sessionKey);
221
225
  const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.17.4",
3
+ "version": "0.17.7",
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": {