polygram 0.17.0 → 0.17.2

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.
@@ -317,6 +317,13 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
317
317
  turn_id: { type: 'string', description: 'Echo of turn_id from inbound channel meta (required for correct turn routing).' },
318
318
  text: { type: 'string', description: 'Message body (markdown ok).' },
319
319
  files: { type: 'array', items: { type: 'string' }, description: 'Optional absolute file paths to attach.' },
320
+ interim: {
321
+ type: 'boolean',
322
+ description: 'Set true ONLY for a short status/progress update on a long task '
323
+ + '(e.g. "Looking into that now…"). An interim reply is shown to the user but is '
324
+ + 'NOT the turn\'s answer — you MUST still deliver the real result as a later reply '
325
+ + 'with interim omitted/false in the SAME turn. NEVER end a turn on an interim reply.',
326
+ },
320
327
  // 0.13 D2 Tier 2C: the fold-acknowledgment contract. The single turn_id
321
328
  // field can't express a combined reply that covers a mid-turn follow-up
322
329
  // (P0 spike Q-B: claude echoes only the trigger id) — this array can.
@@ -776,14 +776,21 @@ class CliProcess extends Process {
776
776
  '',
777
777
  'So once you are clearly into multi-step work — you have run a couple of tool',
778
778
  'calls without replying, or the request plainly needs research / several steps —',
779
- 'send a SHORT one-line status via `reply` (it returns a `message_id`), then use',
780
- '`mcp__polygram-bridge__edit_message` on that SAME `message_id` to update the',
781
- 'bubble as you progress. `edit_message` is for INTERIM status ONLY.',
779
+ 'send a SHORT one-line status via `reply` WITH `interim: true` (it returns a',
780
+ '`message_id`), then use `mcp__polygram-bridge__edit_message` on that SAME',
781
+ '`message_id` to update the bubble as you progress. `edit_message` is for',
782
+ 'INTERIM status ONLY.',
782
783
  '',
783
- 'Deliver the FINAL answer as a fresh `reply`, never as an edit: a fresh reply',
784
- 'notifies the user and carries `consumed_turn_ids`; an edit does neither. If you',
785
- 'no longer have the status bubble\'s message_id, just send a fresh `reply` ',
786
- 'never guess an id.',
784
+ 'HARD RULE a status is a MID-TURN update, NOT the end of work. After an',
785
+ 'interim reply you MUST keep working in the SAME turn and deliver the real',
786
+ 'result. NEVER end your turn on a status / "give me a couple min" / "looking',
787
+ 'into it" reply with no result behind it — that leaves the user staring at a',
788
+ 'promise with nothing delivered. Do the work, then answer.',
789
+ '',
790
+ 'Deliver the FINAL answer as a fresh `reply` with interim omitted/false, never',
791
+ 'as an edit: a fresh reply notifies the user and carries `consumed_turn_ids`; an',
792
+ 'edit does neither. If you no longer have the status bubble\'s message_id, just',
793
+ 'send a fresh `reply` — never guess an id.',
787
794
  '',
788
795
  'If you will finish in one or two tool calls, just answer — no status bubble.',
789
796
  'Status is for work that takes time, not for quick answers (do not spam it).',
@@ -1320,6 +1327,7 @@ class CliProcess extends Process {
1320
1327
  threadId: this.threadId,
1321
1328
  toolName: msg.name,
1322
1329
  text: args.text,
1330
+ interim: args.interim === true, // status/progress reply — not the turn's answer
1323
1331
  files: args.files,
1324
1332
  messageId: args.message_id, // 0.13: edit_message target bubble
1325
1333
  sourceMsgId, // reaction/quote target (A2)
@@ -1373,7 +1381,7 @@ class CliProcess extends Process {
1373
1381
  // fall back to the SINGLE pending turn if exactly one exists, else the
1374
1382
  // oldest pending — log a warning either way so we can audit drift.
1375
1383
  if (msg.name === 'reply' && result?.ok && typeof args.text === 'string' && args.text.length > 0) {
1376
- this._recordReplyForPendingTurn(args.text, args.turn_id);
1384
+ this._recordReplyForPendingTurn(args.text, args.turn_id, args.interim === true);
1377
1385
  }
1378
1386
  }
1379
1387
 
@@ -1382,8 +1390,10 @@ class CliProcess extends Process {
1382
1390
  *
1383
1391
  * @param {string} text
1384
1392
  * @param {string|undefined} replyTurnId — echoed from Claude's reply tool args
1393
+ * @param {boolean} interim — true for a status/progress reply (`interim:true`),
1394
+ * which is NOT the turn's answer. A reply is FINAL by default (fail-safe).
1385
1395
  */
1386
- _recordReplyForPendingTurn(text, replyTurnId) {
1396
+ _recordReplyForPendingTurn(text, replyTurnId, interim = false) {
1387
1397
  // 0.13 D2 (S5 tightening): a reply echoing a KNOWN ledgered turn_id that is
1388
1398
  // NOT the current pending is a LATE reply from an earlier cycle (post-
1389
1399
  // finalize tails, fireUserMessage cycles, ask wrap-ups). Pre-P3 the
@@ -1469,6 +1479,12 @@ class CliProcess extends Process {
1469
1479
 
1470
1480
  target.replies.push(text);
1471
1481
  target.replyCount = (target.replyCount || 0) + 1;
1482
+ // A status/progress reply (`interim:true`) is delivered but is NOT the turn's
1483
+ // answer — track it so the finalizer can tell an interim-only turn (a promise
1484
+ // like "give me a couple min") from a delivered result, and so the ceilings
1485
+ // keep extending it as still-working rather than resolving it as done.
1486
+ // docs/progress-is-not-turn-end-spec.md
1487
+ if (interim) target._interimReplyCount = (target._interimReplyCount || 0) + 1;
1472
1488
 
1473
1489
  if (this._sawHookStream) {
1474
1490
  // 0.13 D1: a delivered reply is ACTIVITY — rung 2 (activity-quiet) owns
@@ -1756,9 +1772,11 @@ class CliProcess extends Process {
1756
1772
  */
1757
1773
  _armActivityQuiet(turnId, pending) {
1758
1774
  if (!this._sawHookStream) return;
1759
- // ≥1 reply, OR seen + consumed-acked (the answer rode a sibling turn_id —
1760
- // fold-id echo; see _ledgerAckConsumed). Same eligibility as the fire site.
1761
- if ((!pending.replies || pending.replies.length === 0)
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)
1762
1780
  && !(pending.seen === true && pending._consumedAcked === true)) return;
1763
1781
  if (this._openQuestions.size > 0) return;
1764
1782
  if (pending._stopGracePending) return;
@@ -1792,7 +1810,7 @@ class CliProcess extends Process {
1792
1810
  // Eligibility: ≥1 bound reply, OR seen + consumed-acked (the answer went
1793
1811
  // out under a sibling turn_id — fold-id echo; see _ledgerAckConsumed).
1794
1812
  const consumedAcked = pending.seen === true && pending._consumedAcked === true;
1795
- if ((!pending.replies || pending.replies.length === 0) && !consumedAcked) return;
1813
+ if (!this._turnHasFinalReply(pending) && !consumedAcked) return;
1796
1814
  const lastHookAgeMs = this._lastHookEventAt ? Date.now() - this._lastHookEventAt : null;
1797
1815
  this._logEvent('cli-activity-quiet-finalize', {
1798
1816
  turn_id: turnId,
@@ -1821,18 +1839,43 @@ class CliProcess extends Process {
1821
1839
  clearTimeout(pending._activityQuietTimer);
1822
1840
  pending._activityQuietTimer = null;
1823
1841
  }
1824
- pending._stopGraceTimer = setTimeout(() => {
1842
+ const fire = () => {
1825
1843
  pending._stopGraceTimer = null;
1844
+ // Don't finalize a turn while a sub-agent is provably still in flight — a Stop
1845
+ // that fired at a sub-agent boundary (or during a quiet sub-agent stretch)
1846
+ // would otherwise CLEAR THE REACTION and end the turn mid-work, with the result
1847
+ // arriving later as a detached cycle. Defer: keep the turn (and its 👾 reaction,
1848
+ // held by B3) alive and re-check. Single-pending only — _pendingSubagentStarts
1849
+ // is proc-wide, so don't cross-attribute. The idle/absolute ceilings are
1850
+ // untouched (we don't reset them), so a lost SubagentStop can't hang — the
1851
+ // ceiling backstops it. docs/progress-is-not-turn-end-spec.md
1852
+ if (this.pendingTurns.has(turnId)
1853
+ && this.pendingTurns.size === 1
1854
+ && (this._pendingSubagentStarts?.length || 0) > 0) {
1855
+ if (!pending._stopGraceDeferred) {
1856
+ pending._stopGraceDeferred = true;
1857
+ this._logEvent('cli-stop-grace-deferred-subagent', {
1858
+ turn_id: turnId, in_flight: this._pendingSubagentStarts.length,
1859
+ session_id: this.claudeSessionId,
1860
+ });
1861
+ }
1862
+ pending._stopGracePending = true;
1863
+ pending._stopGraceTimer = setTimeout(fire, this.stopGraceMs);
1864
+ pending._stopGraceTimer.unref?.();
1865
+ return;
1866
+ }
1826
1867
  pending._stopGracePending = false;
1827
1868
  this._logEvent('cli-turn-resolved-by-stop', {
1828
1869
  turn_id: turnId,
1829
1870
  reply_count: pending.replies?.length || 0,
1830
1871
  via_text_fallback: (pending.replies?.length || 0) === 0,
1831
1872
  attributed: pending.seen === true ? 'seen' : 'reply-bound',
1873
+ deferred_for_subagent: pending._stopGraceDeferred === true,
1832
1874
  session_id: this.claudeSessionId,
1833
1875
  });
1834
1876
  this._finalizeTurn(turnId);
1835
- }, this.stopGraceMs);
1877
+ };
1878
+ pending._stopGraceTimer = setTimeout(fire, this.stopGraceMs);
1836
1879
  pending._stopGraceTimer.unref?.();
1837
1880
  }
1838
1881
 
@@ -1910,6 +1953,81 @@ class CliProcess extends Process {
1910
1953
  this.on('stop-hook', onStop);
1911
1954
  }
1912
1955
 
1956
+ /**
1957
+ * Has this turn delivered a FINAL (non-interim) reply? A reply is final by
1958
+ * default; only `interim:true` status replies don't count. A turn whose only
1959
+ * output is a status promise has NOT delivered its answer. Used by the
1960
+ * finalizer and the absolute checkpoint so an interim-only turn is treated as
1961
+ * still-working (keep extending / deliver the produced result), not as done.
1962
+ */
1963
+ _turnHasFinalReply(pending) {
1964
+ return (pending?.replies?.length || 0) > (pending?._interimReplyCount || 0);
1965
+ }
1966
+
1967
+ /**
1968
+ * Compute the {text, alreadyDelivered} a resolving turn delivers, honoring the
1969
+ * interim-reply rules. Shared by BOTH resolve paths — `_finalizeTurn` (Stop /
1970
+ * activity-quiet) AND the `fireTimeout` ceiling-resolve — so neither drops the
1971
+ * produced answer of an interim-only turn. docs/progress-is-not-turn-end-spec.md
1972
+ *
1973
+ * - a FINAL reply landed → its text was already delivered incrementally
1974
+ * (polygram.js short-circuits) → alreadyDelivered.
1975
+ * - zero replies → 0.12/0.13 Stop-fallback: deliver last_assistant_message
1976
+ * unless a consuming sibling already carried it (consumed-ack).
1977
+ * - interim-only (status promise, no final) → deliver the produced final answer
1978
+ * (last_assistant_message) if it exists and is distinct from the status / a
1979
+ * sibling's text; otherwise leave the status (nothing more to send).
1980
+ */
1981
+ _resolveTurnDelivery(pending, turnId) {
1982
+ const norm = (s) => (s || '').trim();
1983
+ const interimText = pending.replies.join('\n\n');
1984
+ const fallbackText = pending._stopHookData?.lastAssistantMessage || '';
1985
+
1986
+ if (this._turnHasFinalReply(pending)) {
1987
+ return { text: interimText, alreadyDelivered: true };
1988
+ }
1989
+ if (pending.replies.length === 0) {
1990
+ // 0.12 Phase 1.7 fallback: no reply tool call landed — use the Stop hook's
1991
+ // last_assistant_message so the user isn't left with silence (rc.41 H4).
1992
+ const usedStopFallback = !!fallbackText;
1993
+ const text = usedStopFallback ? fallbackText : '';
1994
+ if (usedStopFallback) {
1995
+ this.logger.warn?.(`[${this.label}] cli: turn finalized via stop-hook fallback (no reply tool call); text_len=${text.length}`);
1996
+ }
1997
+ // A _consumedAcked turn is "already delivered" ONLY when the consuming sibling
1998
+ // reply actually carried THIS text — not merely an ack (prod 2026-06-13: a
1999
+ // "Researching now…" ack then the real answer as Stop-fallback was suppressed
2000
+ // and dropped for 5h20m). docs/0.13-consumed-ack-stop-fallback-drop-spec.md
2001
+ const consumedCoversFallback = !usedStopFallback || norm(text) === norm(pending._consumedByText);
2002
+ const alreadyDelivered = pending._consumedAcked === true && consumedCoversFallback;
2003
+ if (usedStopFallback && pending._consumedAcked === true && !consumedCoversFallback) {
2004
+ this.logger.warn?.(`[${this.label}] cli: consumed-ack did NOT cover the Stop-fallback answer — delivering rescued text (len=${text.length})`);
2005
+ this._logEvent('cli-consumed-ack-fallback-rescued', {
2006
+ turn_id: turnId, session_key: this.sessionKey, backend: this.backend,
2007
+ rescued_len: text.length, ack_len: norm(pending._consumedByText).length,
2008
+ });
2009
+ }
2010
+ return { text, alreadyDelivered };
2011
+ }
2012
+ // Interim-only: the turn delivered ONLY status/progress promises ("give me a
2013
+ // couple min") and never a final reply. If claude produced a substantive final
2014
+ // answer as its last assistant message — distinct from the status, and not text a
2015
+ // consuming sibling already delivered — DELIVER it (the status bubbles are already
2016
+ // on screen, so send the FINAL only). Else leave the status; don't re-deliver it.
2017
+ const interimRescue = !!fallbackText
2018
+ && norm(fallbackText) !== norm(interimText)
2019
+ && norm(fallbackText) !== norm(pending._consumedByText);
2020
+ if (interimRescue) {
2021
+ this.logger.warn?.(`[${this.label}] cli: interim-only turn — delivering the produced final answer the status promise didn't (len=${fallbackText.length})`);
2022
+ this._logEvent('cli-interim-only-final-rescued', {
2023
+ turn_id: turnId, session_key: this.sessionKey, backend: this.backend,
2024
+ rescued_len: fallbackText.length, interim_count: pending.replies.length,
2025
+ });
2026
+ return { text: fallbackText, alreadyDelivered: false };
2027
+ }
2028
+ return { text: interimText, alreadyDelivered: true };
2029
+ }
2030
+
1913
2031
  _finalizeTurn(turnId) {
1914
2032
  const pending = this.pendingTurns.get(turnId);
1915
2033
  if (!pending) return;
@@ -1924,39 +2042,7 @@ class CliProcess extends Process {
1924
2042
  if (pending._stopGraceTimer) clearTimeout(pending._stopGraceTimer);
1925
2043
  if (pending._activityQuietTimer) clearTimeout(pending._activityQuietTimer); // 0.13 D1
1926
2044
  if (pending._onStop) { this.off('stop-hook', pending._onStop); pending._onStop = null; }
1927
- const hadReplyToolCalls = pending.replies.length > 0;
1928
- let text = pending.replies.join('\n\n');
1929
- // 0.12 Phase 1.7 fallback: if no reply tool calls landed (claude ended
1930
- // the turn without calling mcp__polygram-bridge__reply), use the Stop
1931
- // hook's last_assistant_message as the text. Same rescue pattern rc.41
1932
- // H4 uses on tmux backend when JSONL stream is broken.
1933
- const usedStopFallback = !text && !!pending._stopHookData?.lastAssistantMessage;
1934
- if (usedStopFallback) {
1935
- text = pending._stopHookData.lastAssistantMessage;
1936
- this.logger.warn?.(`[${this.label}] cli: turn finalized via stop-hook fallback (no reply tool call); text_len=${text.length}`);
1937
- }
1938
- // A _consumedAcked turn is "already delivered" ONLY when the consuming
1939
- // sibling reply actually carried THIS text — not merely an ack. Prod
1940
- // 2026-06-13 (Shumabit@UMI/37): the consuming reply was a 294-char
1941
- // "Researching now…" ack, then the real answer arrived as Stop-fallback
1942
- // text. Suppressing it (alreadyDelivered=true → polygram.js short-circuit)
1943
- // dropped the answer silently for 5h20m. Only suppress when the rescued
1944
- // text matches what the sibling delivered.
1945
- // docs/0.13-consumed-ack-stop-fallback-drop-spec.md
1946
- const norm = (s) => (s || '').trim();
1947
- const consumedCoversFallback = !usedStopFallback || norm(text) === norm(pending._consumedByText);
1948
- const alreadyDelivered = hadReplyToolCalls
1949
- || (pending._consumedAcked === true && consumedCoversFallback);
1950
- if (usedStopFallback && pending._consumedAcked === true && !consumedCoversFallback) {
1951
- this.logger.warn?.(`[${this.label}] cli: consumed-ack did NOT cover the Stop-fallback answer — delivering rescued text (len=${text.length})`);
1952
- this._logEvent('cli-consumed-ack-fallback-rescued', {
1953
- turn_id: turnId,
1954
- session_key: this.sessionKey,
1955
- backend: this.backend,
1956
- rescued_len: text.length,
1957
- ack_len: norm(pending._consumedByText).length,
1958
- });
1959
- }
2045
+ const { text, alreadyDelivered } = this._resolveTurnDelivery(pending, turnId);
1960
2046
  const duration = Date.now() - pending.startedAt;
1961
2047
  // Review AC4: cost=null + metrics-tokens=null signal "unmeasured-subscription"
1962
2048
  // (channels protocol doesn't expose per-turn cost or token breakdowns).
@@ -2007,7 +2093,13 @@ class CliProcess extends Process {
2007
2093
  // claude copied in to send don't accumulate on disk across turns. Only
2008
2094
  // when fully idle, so a file staged for a still-pending concurrent turn
2009
2095
  // isn't yanked mid-send.
2010
- if (this.pendingTurns.size === 0) this._purgeStagingDir();
2096
+ if (this.pendingTurns.size === 0) {
2097
+ this._purgeStagingDir();
2098
+ // B3: fully idle — drop any in-flight sub-agent bookkeeping so a lost
2099
+ // SubagentStop can't leak a stale count (a stuck "working" hold) into the
2100
+ // next turn. Safe only when no turn is pending (it's proc-wide state).
2101
+ this._pendingSubagentStarts = [];
2102
+ }
2011
2103
  }
2012
2104
 
2013
2105
  /**
@@ -2129,14 +2221,20 @@ class CliProcess extends Process {
2129
2221
  // 2026-06-11 19:49 false ⏱; see _ledgerAckConsumed).
2130
2222
  if ((pending.replies?.length || 0) > 0
2131
2223
  || (pending.seen === true && pending._consumedAcked === true)) {
2224
+ // Interim-aware: an interim-only turn delivers its PRODUCED final answer
2225
+ // here too (not the status promise) — the same rescue as _finalizeTurn, so
2226
+ // the answer isn't dropped when the turn resolves at a ceiling rather than
2227
+ // via Stop. docs/progress-is-not-turn-end-spec.md
2228
+ const { text, alreadyDelivered } = this._resolveTurnDelivery(pending, turnId);
2132
2229
  this._logEvent('cli-turn-ceiling-resolved', {
2133
2230
  reason, turnTimeoutMs, reply_count: pending.replies?.length || 0,
2134
2231
  consumed_acked: pending._consumedAcked === true,
2232
+ interim_only: !this._turnHasFinalReply(pending),
2135
2233
  });
2136
2234
  this.emit('idle');
2137
2235
  resolve({
2138
- text: pending.replies.join('\n\n'),
2139
- alreadyDelivered: true,
2236
+ text,
2237
+ alreadyDelivered,
2140
2238
  sessionId: this.claudeSessionId,
2141
2239
  cost: null,
2142
2240
  duration: Date.now() - pending.startedAt,
@@ -2378,8 +2476,11 @@ class CliProcess extends Process {
2378
2476
  async _checkpointAbsolute(turnId) {
2379
2477
  if (!this.pendingTurns.has(turnId)) return;
2380
2478
  let pending = this.pendingTurns.get(turnId);
2381
- // Replied turn (or consumed-acked): the ceiling RESOLVES it, never extends.
2382
- if ((pending.replies?.length || 0) > 0
2479
+ // Turn with a FINAL reply (or consumed-acked): the ceiling RESOLVES it, never
2480
+ // extends. An interim-only turn (status promise, no final reply) is still
2481
+ // working — fall through to the busy-aware probe so it extends, not resolves.
2482
+ // docs/progress-is-not-turn-end-spec.md
2483
+ if (this._turnHasFinalReply(pending)
2383
2484
  || (pending.seen === true && pending._consumedAcked === true)) {
2384
2485
  pending._fireTimeout('absolute');
2385
2486
  return;
@@ -2394,7 +2495,7 @@ class CliProcess extends Process {
2394
2495
  // now would resurrect a settling turn (spurious "still working" right as the
2395
2496
  // real answer lands). It will finalize through its own quiet/grace path.
2396
2497
  if (pending._stopGracePending
2397
- || (pending.replies?.length || 0) > 0
2498
+ || this._turnHasFinalReply(pending)
2398
2499
  || (pending.seen === true && pending._consumedAcked === true)) return;
2399
2500
  const now = Date.now();
2400
2501
  const elapsed = now - pending.startedAt;
@@ -2830,6 +2931,9 @@ class CliProcess extends Process {
2830
2931
  // SubagentStop). We still emit the start event so the reactor
2831
2932
  // can transition into SUBAGENT state immediately.
2832
2933
  toolUseId: ev.toolUseId,
2934
+ // B3: in-flight sub-agent count so the reactor holds a "working" face
2935
+ // (suppresses the 🥱/😨 decay) until the LAST sub-agent finishes.
2936
+ inFlight: this._pendingSubagentStarts.length,
2833
2937
  backend: this.backend,
2834
2938
  });
2835
2939
  return;
@@ -2879,6 +2983,9 @@ class CliProcess extends Process {
2879
2983
  agentId: ev.agentId,
2880
2984
  durationMs: ev.durationMs,
2881
2985
  toolUseId: subagentToolUseId,
2986
+ // B3: remaining in-flight sub-agents (post-decrement). 0 ⇒ the reactor
2987
+ // resumes the normal stall/freeze cascade.
2988
+ inFlight: this._pendingSubagentStarts.length,
2882
2989
  backend: this.backend,
2883
2990
  });
2884
2991
  return;
@@ -2931,6 +3038,12 @@ class CliProcess extends Process {
2931
3038
  session_id: this.claudeSessionId,
2932
3039
  });
2933
3040
  }
3041
+ } else if (p._stopGraceDeferred === true) {
3042
+ // A Stop landed while we're deferring finalize for an in-flight
3043
+ // sub-agent: refresh the captured last_assistant_message so the
3044
+ // eventual finalize delivers the LATEST produced answer (claude's real
3045
+ // end-of-work text), not the boundary Stop's stale/partial text.
3046
+ p._stopHookData = info;
2934
3047
  }
2935
3048
  } else if (this.pendingTurns.size > 1) {
2936
3049
  // Can't attribute Stop to one of several concurrent turns — surface
@@ -787,7 +787,13 @@ function createSdkCallbacks({
787
787
  // prior tool's emoji. The plan promised this; previously the handler
788
788
  // only persisted the DB row and never touched the reactor.
789
789
  const r = entry?.pendingQueue?.[0]?.context?.reactor;
790
- if (r) r.setState('SUBAGENT');
790
+ if (r) {
791
+ r.setState('SUBAGENT');
792
+ // B3: hold a "working" face for the whole sub-agent run — the quiet
793
+ // stretch between its tool hooks is expected, not a stall, so suppress
794
+ // the 🥱/😨 decay until it finishes. docs/progress-is-not-turn-end-spec.md
795
+ if (typeof r.setWorkInFlight === 'function') r.setWorkInFlight(true);
796
+ }
791
797
  } catch (err) {
792
798
  logger.error?.(`[${botName}] subagent-start handler: ${err.message}`);
793
799
  }
@@ -798,7 +804,12 @@ function createSdkCallbacks({
798
804
  // L9/L14: heartbeat at subagent end so the cascade/stall clock
799
805
  // resets; the next tool's PreToolUse sets the following state.
800
806
  const r = entry?.pendingQueue?.[0]?.context?.reactor;
801
- if (r && typeof r.heartbeat === 'function') r.heartbeat();
807
+ if (r) {
808
+ // B3: release the working-hold only when the LAST sub-agent finishes
809
+ // (inFlight === 0) — nested/parallel sub-agents keep it held.
810
+ if (typeof r.setWorkInFlight === 'function') r.setWorkInFlight((payload?.inFlight ?? 0) > 0);
811
+ if (typeof r.heartbeat === 'function') r.heartbeat();
812
+ }
802
813
  logEvent('subagent-done', {
803
814
  chat_id: getChatIdFromKey(sessionKey),
804
815
  session_key: sessionKey,
@@ -226,6 +226,9 @@ function createReactionManager({
226
226
  // Chaining all applies through `applyChain` guarantees they're sent
227
227
  // to Telegram in setState() invocation order.
228
228
  let applyChain = Promise.resolve();
229
+ // B3: set true while a sub-agent / background work is in flight — suppresses the
230
+ // stall/freeze decay so a working-but-quiet turn never shows 🥱/😨.
231
+ let workInFlight = false;
229
232
  // States the auto-stall path may transition to. Once we've already
230
233
  // shown STALL or TIMEOUT we don't downgrade or rearm — only an
231
234
  // explicit setState() call (Claude resumed) can move us forward.
@@ -330,6 +333,10 @@ function createReactionManager({
330
333
  const armStallTimers = () => {
331
334
  clearStallTimers();
332
335
  if (stopped) return;
336
+ // B3: while a sub-agent (or background work) is genuinely in flight, a quiet
337
+ // stretch is EXPECTED — the turn is working, not stalled. Don't arm the
338
+ // 🥱/😨 decay; hold the current working face until work drains.
339
+ if (workInFlight) return;
333
340
  if (!STALL_PROMOTABLE.has(currentState)) return;
334
341
  stallTimer = setTimeout(() => {
335
342
  stallTimer = null;
@@ -432,6 +439,7 @@ function createReactionManager({
432
439
 
433
440
  const stop = () => {
434
441
  stopped = true;
442
+ workInFlight = false; // B3: defense-in-depth if a reactor is ever reused
435
443
  if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
436
444
  clearStallTimers();
437
445
  clearDeepeningTimers();
@@ -452,11 +460,24 @@ function createReactionManager({
452
460
  armStallTimers();
453
461
  };
454
462
 
463
+ // B3: mark whether work (a sub-agent / background shell) is in flight. While
464
+ // active, the silence between tool hooks is expected, so the stall/freeze decay
465
+ // is suppressed and the reactor holds its working face. When work drains, the
466
+ // normal cascade resumes from now. docs/progress-is-not-turn-end-spec.md
467
+ const setWorkInFlight = (active) => {
468
+ const next = !!active;
469
+ if (next === workInFlight) return;
470
+ workInFlight = next;
471
+ if (workInFlight) clearStallTimers(); // cancel any pending 🥱/😨 decay
472
+ else armStallTimers(); // work drained — resume the cascade
473
+ };
474
+
455
475
  return {
456
476
  setState,
457
477
  clear,
458
478
  stop,
459
479
  heartbeat,
480
+ setWorkInFlight,
460
481
  // Introspection for tests:
461
482
  get currentState() { return currentState; },
462
483
  get currentEmoji() { return currentEmoji; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
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": {