switchroom 0.20.4 → 0.20.5

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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.20.4", COMMIT_SHA = "7f669382";
2123
+ var VERSION = "0.20.5", COMMIT_SHA = "fc40cc8e";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -32408,7 +32408,9 @@ var init_scaffold = __esm(() => {
32408
32408
  "mcp__switchroom-telegram__forward_message",
32409
32409
  "mcp__switchroom-telegram__download_attachment",
32410
32410
  "mcp__switchroom-telegram__get_recent_messages",
32411
- "mcp__switchroom-telegram__progress_update"
32411
+ "mcp__switchroom-telegram__progress_update",
32412
+ "mcp__switchroom-telegram__send_checklist",
32413
+ "mcp__switchroom-telegram__update_checklist"
32412
32414
  ];
32413
32415
  HINDSIGHT_MCP_TOOLS = [
32414
32416
  "mcp__hindsight__recall",
@@ -32464,6 +32466,7 @@ var init_scaffold = __esm(() => {
32464
32466
  "Task",
32465
32467
  "TodoWrite",
32466
32468
  "ExitPlanMode",
32469
+ "Bash",
32467
32470
  "Skill"
32468
32471
  ];
32469
32472
  WEBKITE_FLEET_DENY_TOOLS = ["WebFetch", "WebSearch"];
@@ -21432,7 +21432,7 @@ function allocateAgentUid(name) {
21432
21432
  }
21433
21433
 
21434
21434
  // src/build-info.ts
21435
- var VERSION = "0.20.4";
21435
+ var VERSION = "0.20.5";
21436
21436
 
21437
21437
  // src/setup/hindsight-recall-tunables.ts
21438
21438
  var RECALL_DEADLINE_HEADROOM_SECONDS = 2;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.20.4",
4
+ "version": "0.20.5",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -15,6 +15,7 @@ Cut a release of `switchroom/switchroom` and get it live on the fleet. This is a
15
15
  - Cutting the tag fires **two** workflows: `docker-images` (builds + pushes the 6 ghcr images) and `release` (the **orchestrator** — builds the four static binaries, attaches them, waits for `docker-images`, then calls `npm-publish`, then takes the GitHub Release out of draft).
16
16
  - **`npm-publish.yml` no longer has a tag trigger (#3654).** It is reachable only via `workflow_call` from `release.yml` and via `workflow_dispatch`. npm is the one leg that cannot be undone, so it runs last and only once everything else is green. It also re-proves both preconditions from inside its own run, so a hand dispatch cannot bypass the ordering either.
17
17
  - **The GitHub Release is created as a DRAFT and stays one until every leg is green.** `install.sh` resolves the version to install from `/releases/latest`, and that endpoint excludes drafts — so a half-finished release is invisible to `curl | sh` users and the previous complete release keeps serving them. This is not theoretical: v0.19.19 shipped published-with-zero-assets and broke the installer on every platform.
18
+ - **A tag push is now SUFFICIENT: if no GitHub Release exists, `release.yml`'s `guard` job AUTO-CREATES it as a draft from the `## vX.Y.Z` section of `CHANGELOG.md` (#4331).** The manual `gh release create` in step 2b is therefore belt-and-suspenders — do it (it lets you pin an exact title/notes and shortens the guard's wait), but forgetting it no longer silently strands the release. This closed the v0.20.3/v0.20.4 hole: those tags shipped to the fleet with `gh release create` skipped, so `guard` failed, `npm` was skipped, and npm `latest` sat at 0.20.2 until it was recovered by hand. The one hard requirement is that `CHANGELOG.md` on the tagged commit HAS a section for the tag — if it doesn't, `guard` FAILS LOUDLY (it never invents an empty release).
18
19
 
19
20
  ## Before you start — pre-flight (verify, don't assume)
20
21
 
@@ -55,7 +56,7 @@ gh release create "vX.Y.Z" -R switchroom/switchroom \
55
56
  ```
56
57
 
57
58
  - **`--draft` is mandatory.** A published release with no assets immediately becomes `/releases/latest` and 404s every `curl | sh` install for the entire ~25-minute build window. `release.yml` will forcibly re-draft an incomplete published release within about a minute, but do not rely on the safety net — it exists for the case where this step was done wrong.
58
- - Creating the release object *before* the tag is what removes the race: `release.yml`'s `guard` job needs it to exist within ~150s of the tag push.
59
+ - Creating the release object *before* the tag is what removes the race: `release.yml`'s `guard` job polls ~150s for it. **If you skip this step entirely, `guard` auto-creates the draft release from `CHANGELOG.md` (#4331)** — so a skipped `gh release create` is recoverable, not fatal, as long as the CHANGELOG section exists. Still create it here when you can: it is one fewer thing depending on the auto-heal, and it lets you author a title/notes that differ from the raw CHANGELOG section.
59
60
  - **Notes extraction gotcha (historical):** the naive `awk '/^## vX/,/^## v/' CHANGELOG` range collapses to a single line. Use a start-flag awk: `awk 'f{print} /^## vX\.Y\.Z/{print; f=1} f && /^## v/ && !/^## vX\.Y\.Z/{exit}'` — or extract the section to a temp file by line range.
60
61
  - **`gh release create` has been silently dropped in past runs.** Verify it exists and is a draft:
61
62
  ```bash
@@ -151,7 +152,7 @@ It re-runs every leg — including `finalize`, which is what actually takes the
151
152
 
152
153
  **But a re-dispatch cannot fix a defect that lives in the tag's own tree.** `workflow_dispatch` executes the workflow YAML **at the dispatched ref**, so `--ref vX.Y.Z` re-runs the same broken file and fails identically, every time. This is not theoretical — it is how v0.19.20 died (#3691). Before reaching for the re-dispatch, root-cause the failure in the workflow source **at that tag** (`git show vX.Y.Z:.github/workflows/release.yml`) and compare it to `main`. If the bug is in the tag's tree, the only path is a fix merged to `main` plus a **fresh tag**; abandon the old tag as a permanent draft (do not delete it, do not hand-publish it) and burn a patch version. Re-dispatch is for *transient* failures — a flaky runner, an npm 5xx, a `docker-images` run that has since gone green.
153
154
 
154
- - **`release` failed at `guard` ("no GitHub Release exists"):** step 2b was skipped or the tag name is misspelled. Create the draft release, then re-dispatch as above.
155
+ - **`release` failed at `guard` ("cannot auto-create … no usable CHANGELOG.md section"):** the tag has no matching `## vX.Y.Z` section in `CHANGELOG.md` on the tagged commit, so `guard` could not auto-create the release from it (#4331). This is the fail-loud fallback, not the old "no release exists" failure — `guard` now auto-creates the DRAFT release from the CHANGELOG section whenever `gh release create` was skipped, so a missing release object no longer blocks the pipeline. The fix is a CHANGELOG defect in the tag's own tree: consolidate the notes under `## vX.Y.Z — <summary>` (step 1), merge to `main`, and cut a **fresh** tag — a re-dispatch on the same tag re-runs the same CHANGELOG-less tree and fails identically (see the "cannot fix a defect in the tag's tree" note above). If instead you simply want to override the auto-notes, `gh release create vX.Y.Z --draft --notes-file …` by hand before re-dispatching still works.
155
156
  - **`release` failed at `images-gate`:** `docker-images` was not green for this tag+commit. Fix it, `gh workflow run docker-images.yml --ref vX.Y.Z`, wait for green, then re-dispatch `release.yml`. Nothing was published to npm and the release is still a draft — nothing to undo.
156
157
  - **`release` failed at `npm` (e.g. a transient npm 5xx):** re-dispatch `release.yml` as above. `npm-publish` treats "already published" as success, so a re-run is safe and idempotent. Dispatching `npm-publish.yml --ref vX.Y.Z` directly also works and its own gates still apply, but it will NOT un-draft the release, so you would then have to re-dispatch `release.yml` anyway.
157
158
  - **Everything is green but the release is still a draft:** `finalize` did not run. Check `gh run view <run-id>` for a skipped job, then re-dispatch. Do not hand-publish — `finalize` re-verifies the asset set immediately before flipping the flag.
@@ -71555,6 +71555,7 @@ __export(exports_silence_poke, {
71555
71555
  noteThinking: () => noteThinking,
71556
71556
  noteProduction: () => noteProduction,
71557
71557
  noteOutbound: () => noteOutbound2,
71558
+ noteCardRender: () => noteCardRender,
71558
71559
  noteBackgroundShellDead: () => noteBackgroundShellDead,
71559
71560
  noteBackgroundShellAlive: () => noteBackgroundShellAlive,
71560
71561
  longestInFlightTool: () => longestInFlightTool,
@@ -71567,7 +71568,8 @@ __export(exports_silence_poke, {
71567
71568
  __getStateForTests: () => __getStateForTests,
71568
71569
  __bgMarkerParserConfirmedForTests: () => __bgMarkerParserConfirmedForTests,
71569
71570
  DEFAULT_THRESHOLDS: () => DEFAULT_THRESHOLDS,
71570
- DEFAULT_POLL_INTERVAL_MS: () => DEFAULT_POLL_INTERVAL_MS
71571
+ DEFAULT_POLL_INTERVAL_MS: () => DEFAULT_POLL_INTERVAL_MS,
71572
+ CARD_RENDER_FRESH_MS: () => CARD_RENDER_FRESH_MS
71571
71573
  });
71572
71574
 
71573
71575
  // turn-liveness-floor.ts
@@ -71633,6 +71635,7 @@ var DEFAULT_THRESHOLDS = {
71633
71635
  fallback: 300000
71634
71636
  };
71635
71637
  var DEFAULT_POLL_INTERVAL_MS = 5000;
71638
+ var CARD_RENDER_FRESH_MS = 30000;
71636
71639
  var state2 = new Map;
71637
71640
  var timer = null;
71638
71641
  var activeDeps = null;
@@ -71651,6 +71654,7 @@ function startTurn(key, now) {
71651
71654
  fallbackFired: false,
71652
71655
  floorFired: false,
71653
71656
  inFlightTools: new Map,
71657
+ lastCardRenderAt: null,
71654
71658
  sawBashThisTurn: false,
71655
71659
  aliveShells: new Set
71656
71660
  });
@@ -71682,6 +71686,12 @@ function noteProduction(key, now) {
71682
71686
  s.lastOutboundAt = now;
71683
71687
  s.fallbackFired = false;
71684
71688
  }
71689
+ function noteCardRender(key, now) {
71690
+ const s = state2.get(key);
71691
+ if (s == null)
71692
+ return;
71693
+ s.lastCardRenderAt = now;
71694
+ }
71685
71695
  function noteThinking(key, now) {
71686
71696
  const s = state2.get(key);
71687
71697
  if (s == null)
@@ -71828,6 +71838,8 @@ function tick(now) {
71828
71838
  if (underCeiling) {
71829
71839
  if (activeDeps.isCompactionInFlight?.(key) === true)
71830
71840
  continue;
71841
+ if (s.lastCardRenderAt != null && now - s.lastCardRenderAt < CARD_RENDER_FRESH_MS)
71842
+ continue;
71831
71843
  const forceDisable = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === "0";
71832
71844
  if (!forceDisable && activeDeps.isLegitimatelyWorking != null) {
71833
71845
  if (activeDeps.isLegitimatelyWorking(key))
@@ -82138,6 +82150,12 @@ function noteProduction2(key, now) {
82138
82150
  s.lastOutboundAt = now;
82139
82151
  s.fallbackFired = false;
82140
82152
  }
82153
+ function noteCardRender2(key, now) {
82154
+ const s = state5.get(key);
82155
+ if (s == null)
82156
+ return;
82157
+ s.lastCardRenderAt = now;
82158
+ }
82141
82159
  function endTurn2(key) {
82142
82160
  state5.delete(key);
82143
82161
  }
@@ -83706,6 +83724,7 @@ function createNarrativeLane(deps) {
83706
83724
  if (isSendGateShed(editRes))
83707
83725
  break;
83708
83726
  }
83727
+ noteCardRender2(statusKey(chat, thread), Date.now());
83709
83728
  turn.activityLastSentRender = target;
83710
83729
  } catch (err) {
83711
83730
  const msg = err instanceof Error ? err.message : String(err);
@@ -102409,10 +102428,10 @@ function startOutboxSweep(deps) {
102409
102428
  }
102410
102429
 
102411
102430
  // ../src/build-info.ts
102412
- var VERSION2 = "0.20.4";
102413
- var COMMIT_SHA = "7f669382";
102414
- var COMMIT_DATE = "2026-08-04T01:43:38Z";
102415
- var LATEST_PR = 4331;
102431
+ var VERSION2 = "0.20.5";
102432
+ var COMMIT_SHA = "fc40cc8e";
102433
+ var COMMIT_DATE = "2026-08-04T04:03:52Z";
102434
+ var LATEST_PR = 4338;
102416
102435
  var COMMITS_AHEAD_OF_TAG = 0;
102417
102436
 
102418
102437
  // gateway/boot-version.ts
@@ -109926,6 +109945,9 @@ async function executeProgressUpdate(args) {
109926
109945
  try {
109927
109946
  noteSignal(key, Date.now());
109928
109947
  } catch {}
109948
+ try {
109949
+ noteOutbound2(key, Date.now());
109950
+ } catch {}
109929
109951
  return {
109930
109952
  content: [
109931
109953
  {
@@ -12573,6 +12573,8 @@ async function executeProgressUpdate(args: Record<string, unknown>): Promise<unk
12573
12573
  try {
12574
12574
  signalTracker.noteSignal(key, Date.now())
12575
12575
  } catch { /* best-effort signal */ }
12576
+ // #4330: model-driven user-visible outbound — reset the silence clock like a reply send.
12577
+ try { silencePoke.noteOutbound(key, Date.now()) } catch { /* best-effort */ }
12576
12578
 
12577
12579
  return {
12578
12580
  content: [
@@ -47,6 +47,7 @@
47
47
  // ─────────────────────────────────────────────────────────────────────────
48
48
 
49
49
  import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
50
+ import * as silencePoke from '../silence-poke.js'
50
51
  import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrative-flush.js'
51
52
  import { richMessage } from '../rich-send.js'
52
53
  import { isSendGateShed } from '../send-gate.js'
@@ -496,6 +497,20 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
496
497
  // payload IS on screen, so it advances normally below.
497
498
  if (isSendGateShed(editRes)) break
498
499
  }
500
+ // #4330 — a card render LANDED (fresh open above, or a non-shed
501
+ // edit): the pinned "→ Working…" status card the user watches just
502
+ // visibly moved. Stamp silence-poke's card-render clock so the 300s
503
+ // terminal fallback is DEFERRED (bounded by the hard ceiling) while
504
+ // the card keeps updating — a visibly-live card must not be torn
505
+ // down as "silence". This is the single transport chokepoint for
506
+ // every card producer (tool label, narrative SHOW, liveness open,
507
+ // heartbeat climb), and it is a DEFER-stamp only — it never touches
508
+ // `lastOutboundAt`, so the silence CLOCK still resets only at the
509
+ // model-driven production sites (stream-render tool-label / draft,
510
+ // foreground sub-agent render). That keeps the #1556 heartbeat-
511
+ // safety guard intact: a hung turn's climbing card defers the
512
+ // teardown at most to `fallbackHardCeiling`, never forever.
513
+ silencePoke.noteCardRender(statusKey(chat, thread), Date.now())
499
514
  turn.activityLastSentRender = target
500
515
  } catch (err) {
501
516
  const msg = err instanceof Error ? err.message : String(err)
@@ -28,6 +28,14 @@
28
28
  * is visibly running; nulling `currentTurn` there would darken the very feed the
29
29
  * user is watching. A turn with no in-flight tool is unaffected.
30
30
  *
31
+ * #4330 caveat: an activity-card render that LANDED on Telegram within
32
+ * `CARD_RENDER_FRESH_MS` (stamped via `noteCardRender` from the card-drain
33
+ * transport) likewise DEFERS the terminal unwedge, bounded by the same hard
34
+ * ceiling — a visibly-updating pinned "→ Working…" card is user-visible
35
+ * activity, and killing the turn under it is a false positive. It never
36
+ * resets the clock: the framework heartbeat climbs the card on pure wall
37
+ * clock even on a hung turn, so a reset would pin a dead turn forever.
38
+ *
31
39
  * Terminal action, once per turn:
32
40
  *
33
41
  * t=0 startTurn() — silence clock starts at turnStartedAt
@@ -110,6 +118,21 @@ export interface SilencePokeState {
110
118
  * silence clock — a real reply / feed edit still does that; this only
111
119
  * gates the terminal teardown. */
112
120
  sawBashThisTurn: boolean
121
+ /**
122
+ * #4330: wall-clock ms of the last activity-card render (open or in-place
123
+ * edit) that actually LANDED on Telegram for this turn — the pinned
124
+ * "→ Working… · Nm · N tools" status card the user is watching. Stamped by
125
+ * `noteCardRender` from the card-drain transport site
126
+ * (`gateway/narrative-lane.ts` drainActivitySummary), which covers every
127
+ * producer: tool labels, narrative SHOWs, the liveness open, and the
128
+ * framework heartbeat climb. Deliberately NOT a clock reset — the heartbeat
129
+ * climb re-renders on pure wall clock, so a hung turn's card keeps
130
+ * "updating" and an unbounded reset would pin a dead turn forever (the
131
+ * #1556 class). Instead the tick() DEFERS the terminal fallback while the
132
+ * card moved within `CARD_RENDER_FRESH_MS`, bounded by
133
+ * `fallbackHardCeiling` exactly like the in-flight-tool / compaction
134
+ * defers. null until the first card render of the turn. */
135
+ lastCardRenderAt: number | null
113
136
  /**
114
137
  * #3519 sharpen: claude-CLI background shells PROVEN alive right now. A
115
138
  * shell is added on its launch marker (structured `backgroundTaskId`, via
@@ -153,6 +176,16 @@ export const DEFAULT_THRESHOLDS: ThresholdsMs = {
153
176
 
154
177
  export const DEFAULT_POLL_INTERVAL_MS = 5_000
155
178
 
179
+ /**
180
+ * #4330 — how recent the last landed activity-card render must be for the
181
+ * card to count as "actively updating in front of the user". The framework
182
+ * heartbeat edits the card every ~6s (FEED_HEARTBEAT_MIN_STALE_MS); 30s
183
+ * tolerates several shed/coalesced cosmetic edits under flood-control
184
+ * backoff while still lapsing quickly once the card genuinely stops moving
185
+ * (turn ended → card cleared, or the drain wedged).
186
+ */
187
+ export const CARD_RENDER_FRESH_MS = 30_000
188
+
156
189
  export interface FrameworkFallbackContext {
157
190
  key: string
158
191
  chatId: string
@@ -326,6 +359,7 @@ export function startTurn(key: string, now: number): void {
326
359
  fallbackFired: false,
327
360
  floorFired: false,
328
361
  inFlightTools: new Map(),
362
+ lastCardRenderAt: null,
329
363
  sawBashThisTurn: false,
330
364
  aliveShells: new Set(),
331
365
  })
@@ -397,6 +431,36 @@ export function noteProduction(key: string, now: number): void {
397
431
  s.fallbackFired = false
398
432
  }
399
433
 
434
+ /**
435
+ * #4330: record an activity-card render (open or in-place edit) that actually
436
+ * LANDED on Telegram for `key` — the pinned "→ Working…" status card visibly
437
+ * moved. Called from the card-drain transport site
438
+ * (`gateway/narrative-lane.ts` drainActivitySummary) after a successful
439
+ * sendRichMessage / non-shed editMessageText, so it covers every card
440
+ * producer — tool labels, narrative SHOWs, the liveness open, AND the
441
+ * framework heartbeat climb (`feedHeartbeatTick`), which is exactly the path
442
+ * that has no `noteProduction` site.
443
+ *
444
+ * Deliberately does NOT reset the silence clock (`lastOutboundAt` untouched,
445
+ * `fallbackFired` untouched): the heartbeat climb renders on pure wall clock
446
+ * (`now - turn.startedAt`), so a genuinely hung turn's card keeps climbing
447
+ * forever — counting that as production would keep a dead turn alive
448
+ * indefinitely (the #1556 dangling-turn class the stream-render tool-label
449
+ * comment warns about). Instead, tick() DEFERS the terminal fallback while
450
+ * `now - lastCardRenderAt < CARD_RENDER_FRESH_MS`, bounded by
451
+ * `fallbackHardCeiling` — same semantics as the #1292/#3519/#4058 defers.
452
+ * Model-driven card progress (a NEW tool label, a foreground sub-agent nested
453
+ * render, a draft update) additionally resets the clock via the existing
454
+ * `noteProduction` sites; this stamp is the floor beneath them.
455
+ *
456
+ * No-op when the kill switch is on or the key has no live turn.
457
+ */
458
+ export function noteCardRender(key: string, now: number): void {
459
+ const s = state.get(key)
460
+ if (s == null) return
461
+ s.lastCardRenderAt = now
462
+ }
463
+
400
464
  /**
401
465
  * Record a `thinking` session event. Used to pick "still thinking…" vs
402
466
  * "still working…" wording for the 300s framework fallback.
@@ -801,6 +865,22 @@ function tick(now: number): void {
801
865
  // gated by SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS=0 — that flag
802
866
  // scopes the TOOL defers; compaction has no tool in flight.
803
867
  if (activeDeps.isCompactionInFlight?.(key) === true) continue
868
+ // #4330 — the pinned activity card is visibly updating. A card render
869
+ // (open or edit) landed on Telegram within CARD_RENDER_FRESH_MS, so
870
+ // the user is watching a live "→ Working… · Nm · N tools" surface —
871
+ // the "silent to the user" premise does not hold, and tearing the
872
+ // turn down would kill the very card they are watching, then re-ask
873
+ // their message under it. DEFER (clock untouched, fallbackFired
874
+ // unset, re-checked next tick), bounded by the enclosing
875
+ // `underCeiling` guard: the heartbeat climb keeps a hung turn's card
876
+ // moving on pure wall clock, so a truly wedged turn still unwedges at
877
+ // the hard ceiling. Like the #4058 compaction defer, deliberately NOT
878
+ // gated by SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS=0 — that flag
879
+ // scopes the TOOL defers; a card render is not a tool signal.
880
+ if (
881
+ s.lastCardRenderAt != null &&
882
+ now - s.lastCardRenderAt < CARD_RENDER_FRESH_MS
883
+ ) continue
804
884
  const forceDisable = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === '0'
805
885
  if (!forceDisable && activeDeps.isLegitimatelyWorking != null) {
806
886
  if (activeDeps.isLegitimatelyWorking(key)) continue
@@ -0,0 +1,300 @@
1
+ /**
2
+ * #4330 — a visibly-updating pinned activity card must not be treated as
3
+ * silence by the 300s terminal fallback.
4
+ *
5
+ * The bug (user-reported, with screenshot): the framework fired the terminal
6
+ * unwedge — "⚠️ no output for 5 min — the framework ended that stalled turn"
7
+ * — on a HEALTHY turn whose pinned "→ Working… · Nm · N tools" status card
8
+ * WAS actively updating the whole time. The card edits are driven by
9
+ * `feedHeartbeatTick` (the 0-label climb / labelled-step elapsed re-render)
10
+ * through `drainActivitySummary`'s editMessageText, and that transport path
11
+ * had NO `noteProduction`/`noteOutbound` site — deliberately, because the
12
+ * heartbeat climbs on pure wall clock and an unbounded clock RESET would keep
13
+ * a genuinely hung turn alive forever (the #1556 class). So when every defer
14
+ * signal (in-flight tool, pending async dispatch, alive shells, compaction)
15
+ * read false at the 300s tick, the fallback tore down the very card the user
16
+ * was watching and re-asked their message.
17
+ *
18
+ * The fix: `drainActivitySummary` stamps `silencePoke.noteCardRender` on
19
+ * every card render that actually lands (open or non-shed edit); tick()
20
+ * DEFERS the terminal fallback while the last landed render is younger than
21
+ * `CARD_RENDER_FRESH_MS`, bounded by `fallbackHardCeiling` exactly like the
22
+ * #1292/#3519/#4058 defers. These tests drive the REAL tick loop (and, for
23
+ * the wiring case, the REAL narrative-lane transport) and assert outcomes:
24
+ * - card renders arriving faster than the fresh window → NO fire past 300s;
25
+ * - renders that keep coming forever → STILL fires at the hard ceiling
26
+ * (a hung turn's heartbeat keeps climbing, so the net must stay bounded);
27
+ * - a genuinely silent turn (no renders, no tool, no reply) → STILL fires
28
+ * at 300s (the safety net is not disabled);
29
+ * - renders that STOP → the fallback fires once silence ≥ threshold and
30
+ * the last render has aged out (no unearned extension);
31
+ * - wiring: a card edit through the REAL `createNarrativeLane` drain
32
+ * defers the fallback for its status key.
33
+ */
34
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
35
+ import { readFileSync } from 'node:fs'
36
+ import { resolve } from 'node:path'
37
+ import { tmpdir } from 'node:os'
38
+ import { createNarrativeLane } from '../gateway/narrative-lane.js'
39
+ import type { CurrentTurn, NarrativeLaneDeps } from '../gateway/gateway.js'
40
+ import * as silencePoke from '../silence-poke.js'
41
+ import {
42
+ startTurn,
43
+ noteCardRender,
44
+ __tickForTests,
45
+ __setDepsForTests,
46
+ __getStateForTests,
47
+ __resetAllForTests,
48
+ DEFAULT_THRESHOLDS,
49
+ CARD_RENDER_FRESH_MS,
50
+ type SilencePokeMetric,
51
+ type FrameworkFallbackContext,
52
+ type ThresholdsMs,
53
+ } from '../silence-poke.js'
54
+
55
+ const HARD_CEILING = 900_000 // SILENCE_FALLBACK_HARD_MS default
56
+
57
+ interface TestFixtures {
58
+ emitted: SilencePokeMetric[]
59
+ fallbacks: FrameworkFallbackContext[]
60
+ }
61
+
62
+ function setupDeps(opts?: { thresholdsMs?: ThresholdsMs }): TestFixtures {
63
+ const fixtures: TestFixtures = { emitted: [], fallbacks: [] }
64
+ __setDepsForTests({
65
+ emitMetric: (e) => fixtures.emitted.push(e),
66
+ onFrameworkFallback: (ctx) => { fixtures.fallbacks.push(ctx) },
67
+ thresholdsMs: opts?.thresholdsMs
68
+ ?? { ...DEFAULT_THRESHOLDS, fallbackHardCeiling: HARD_CEILING },
69
+ // Mirror production wiring: the callback path is active (liveness-wiring
70
+ // always wires isLegitimatelyWorking), returning false = "no tool work".
71
+ isLegitimatelyWorking: () => false,
72
+ })
73
+ return fixtures
74
+ }
75
+
76
+ beforeEach(() => {
77
+ __resetAllForTests()
78
+ delete process.env.SWITCHROOM_DISABLE_SILENCE_POKE
79
+ delete process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS
80
+ })
81
+
82
+ afterEach(() => {
83
+ __resetAllForTests()
84
+ })
85
+
86
+ describe('silence-poke — #4330 card-render defer (outcome)', () => {
87
+ it('a turn whose ONLY output is card renders faster than the fresh window is NOT torn down past 300s', () => {
88
+ const fx = setupDeps()
89
+ startTurn('chat:1', 0)
90
+ // The heartbeat edits the card every ~6s; simulate a card that keeps
91
+ // visibly updating for 10 minutes with no reply, no tool, no draft.
92
+ for (let t = 6_000; t <= 600_000; t += 6_000) {
93
+ noteCardRender('chat:1', t)
94
+ __tickForTests(t)
95
+ }
96
+ // Pre-fix this fired at the first tick with silence >= 300_000 — the
97
+ // exact "framework ended that stalled turn" false positive.
98
+ expect(fx.fallbacks).toHaveLength(0)
99
+ expect(fx.emitted).toHaveLength(0)
100
+ })
101
+
102
+ it('card renders that keep coming forever STILL fire the fallback at the hard ceiling (bounded defer)', () => {
103
+ const fx = setupDeps()
104
+ startTurn('chat:2', 0)
105
+ // A hung turn: the framework heartbeat keeps climbing the card on pure
106
+ // wall clock even though the model is dead. The defer must stay bounded.
107
+ let fired: number | null = null
108
+ for (let t = 6_000; t <= 1_200_000; t += 6_000) {
109
+ noteCardRender('chat:2', t)
110
+ __tickForTests(t)
111
+ if (fx.fallbacks.length > 0 && fired == null) fired = t
112
+ }
113
+ expect(fx.fallbacks).toHaveLength(1)
114
+ expect(fired).not.toBeNull()
115
+ expect(fired!).toBeGreaterThanOrEqual(HARD_CEILING)
116
+ // Fires promptly AT the ceiling, not some window after it.
117
+ expect(fired!).toBeLessThanOrEqual(HARD_CEILING + 6_000)
118
+ })
119
+
120
+ it('a genuinely silent turn (no card renders, no tool, no reply) STILL fires at 300s — the net is intact', () => {
121
+ const fx = setupDeps()
122
+ startTurn('chat:3', 0)
123
+ __tickForTests(299_000)
124
+ expect(fx.fallbacks).toHaveLength(0)
125
+ __tickForTests(300_000)
126
+ expect(fx.fallbacks).toHaveLength(1)
127
+ expect(fx.emitted.at(-1)).toMatchObject({ kind: 'silence_fallback_sent' })
128
+ })
129
+
130
+ it('card renders that STOP do not extend the window — fallback fires once the last render ages out', () => {
131
+ const fx = setupDeps()
132
+ startTurn('chat:4', 0)
133
+ // Card updated until t=100s (turn wedged after; heartbeat drain stuck /
134
+ // card torn down), then nothing.
135
+ for (let t = 6_000; t <= 100_000; t += 6_000) {
136
+ noteCardRender('chat:4', t)
137
+ __tickForTests(t)
138
+ }
139
+ // At 305s: silence (never reset by noteCardRender) is 305s >= 300s and
140
+ // the last render is 200s+ old — well past CARD_RENDER_FRESH_MS. Fires.
141
+ __tickForTests(305_000)
142
+ expect(fx.fallbacks).toHaveLength(1)
143
+ expect(fx.fallbacks[0]!.silenceMs).toBe(305_000)
144
+ })
145
+
146
+ it('noteCardRender never resets the silence clock or re-arms a fired fallback', () => {
147
+ setupDeps()
148
+ startTurn('chat:5', 0)
149
+ noteCardRender('chat:5', 250_000)
150
+ const s = __getStateForTests('chat:5')!
151
+ expect(s.lastOutboundAt).toBeNull() // clock untouched — defer only
152
+ expect(s.lastCardRenderAt).toBe(250_000)
153
+ expect(s.fallbackFired).toBe(false)
154
+ })
155
+ })
156
+
157
+ // ── wiring proof: the REAL narrative-lane card drain stamps the defer ─────
158
+
159
+ const CHAT = '1001'
160
+
161
+ function makeLane() {
162
+ const noop = () => {}
163
+ let nextId = 3000
164
+ const calls: Array<{ method: string }> = []
165
+ const api = {
166
+ sendRichMessage: async () => {
167
+ calls.push({ method: 'sendRichMessage' })
168
+ return { message_id: ++nextId }
169
+ },
170
+ editMessageText: async () => {
171
+ calls.push({ method: 'editMessageText' })
172
+ return {}
173
+ },
174
+ deleteMessage: async () => true,
175
+ }
176
+ const fakeEA = {
177
+ mayDrain: () => true,
178
+ openOrEditCard: (_p: string, fn: () => void) => fn(),
179
+ finalizeCard: (fn: () => void) => fn(),
180
+ markSubstantiveFinalDelivered: (fn: () => void) => fn(),
181
+ }
182
+ const deps = {
183
+ ACTIVITY_CARD_STORE_PATH: `${tmpdir()}/silence-card-render-activity-cards.json`,
184
+ CLEAR_STATUS_ON_COMPLETION: false,
185
+ FEED_HEARTBEAT_ENABLED: false,
186
+ FEED_HEARTBEAT_MIN_STALE_MS: 6000,
187
+ FEED_LIVENESS_OPEN_ENABLED: false,
188
+ FEED_LIVENESS_OPEN_MS: 5000,
189
+ PIN_STATUS_WHILE_WORKING: false,
190
+ POST_ANSWER_LIVENESS_STALE_MS: 90000,
191
+ STATIC: false,
192
+ activeDraftStreams: new Map(),
193
+ activityCardPersistEnabled: false,
194
+ activityCardStoreFs: { readFileSync: () => '', writeFileSync: noop, mkdirSync: noop, renameSync: noop, unlinkSync: noop },
195
+ bot: { api },
196
+ cardDrainGate: (_t: unknown, _ea: unknown, run: () => void) => run(),
197
+ currentTurnMap: { get: () => null, byKey: new Map() },
198
+ earlyLivenessOpenTimers: new Map(),
199
+ emissionAuthorityFor: () => fakeEA,
200
+ feedOpenGateDeps: () => ({ hasOutboundDeliveredSince: () => false, historyEnabled: false, finalAnswerMinChars: 200 }),
201
+ getCurrentTurn: () => null,
202
+ reconcileStatusPin: noop,
203
+ robustApiCall: (fn: () => Promise<unknown>) => fn(),
204
+ // Same key shape silence-poke is driven with below.
205
+ statusKey: (c: string, t?: number | null) => `${c}:${t ?? ''}`,
206
+ } as unknown as NarrativeLaneDeps
207
+ const lane = createNarrativeLane(deps)
208
+ return { lane, calls }
209
+ }
210
+
211
+ function makeLaneTurn(lane: ReturnType<typeof createNarrativeLane>): CurrentTurn {
212
+ const turn = {
213
+ turnId: 'turn-card-render-1',
214
+ sessionChatId: CHAT,
215
+ sessionThreadId: undefined,
216
+ sourceMessageId: null,
217
+ registryKey: null,
218
+ startedAt: Date.now() - 3000,
219
+ currentModel: null,
220
+ totalTokens: 0,
221
+ labeledToolCount: 0,
222
+ mirrorLines: [] as string[],
223
+ foregroundSubAgents: new Map<string, string[]>(),
224
+ activityPendingRender: null as string | null,
225
+ activityLastSentRender: null as string | null,
226
+ activityMessageId: null as number | null,
227
+ activityInFlight: null as Promise<void> | null,
228
+ activityEverOpened: false,
229
+ activityDrainFailures: 0,
230
+ finalAnswerEverDelivered: false,
231
+ finalAnswerDelivered: false,
232
+ replyCalled: false,
233
+ capturedText: [] as string[],
234
+ lastReplyText: '',
235
+ answerStream: null,
236
+ liveness: { recentlyStreaming: () => false, onStreamEvent: () => {}, note: () => {} },
237
+ } as unknown as CurrentTurn
238
+ ;(turn as { narrativeGate?: unknown }).narrativeGate = lane.makeNarrativeGate(turn)
239
+ return turn
240
+ }
241
+
242
+ describe('silence-poke — #4330 wiring: the REAL card drain defers the fallback', () => {
243
+ it('a card render landed through drainActivitySummary defers a due fallback; without it the fallback fires', async () => {
244
+ const key = `${CHAT}:`
245
+ const now = Date.now()
246
+ // Real wall clock end-to-end: tiny fallback threshold, real
247
+ // CARD_RENDER_FRESH_MS (the lane stamps Date.now()).
248
+ const fx = setupDeps({
249
+ thresholdsMs: { fallback: 1_000, fallbackHardCeiling: HARD_CEILING },
250
+ })
251
+
252
+ // Control first: a turn past the threshold with NO card render fires.
253
+ startTurn(key, now - 5_000)
254
+ __tickForTests(now)
255
+ expect(fx.fallbacks).toHaveLength(1)
256
+
257
+ // Now the same shape, but a card render lands through the REAL lane
258
+ // transport before the tick — the fallback must be deferred.
259
+ __resetAllForTests()
260
+ const fx2 = setupDeps({
261
+ thresholdsMs: { fallback: 1_000, fallbackHardCeiling: HARD_CEILING },
262
+ })
263
+ startTurn(key, Date.now() - 5_000)
264
+ const { lane, calls } = makeLane()
265
+ const turn = makeLaneTurn(lane)
266
+ lane.showNarrativeStep(turn, 'Compiling the release notes')
267
+ await turn.activityInFlight
268
+ expect(calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
269
+ const st = __getStateForTests(key)!
270
+ expect(st.lastCardRenderAt).not.toBeNull() // the drain stamped it
271
+ __tickForTests(Date.now())
272
+ expect(fx2.fallbacks).toHaveLength(0) // deferred: the card just moved
273
+
274
+ // And the defer stays bounded: past the hard ceiling it fires anyway,
275
+ // even with a render landed inside the fresh window at fire time.
276
+ const tFinal = Date.now() + HARD_CEILING + CARD_RENDER_FRESH_MS
277
+ noteCardRender(key, tFinal - 1_000) // card still "moving" at the ceiling
278
+ __tickForTests(tFinal)
279
+ expect(fx2.fallbacks).toHaveLength(1)
280
+ })
281
+ })
282
+
283
+ // ── structural: progress_update (a model-driven user-visible send inside the
284
+ // gateway IIFE, not instantiable in-process — same pattern as
285
+ // silence-liveness-wiring.test.ts) must reset the silence clock ──────────
286
+
287
+ describe('silence-poke — #4330 progress_update liveness wiring (structural)', () => {
288
+ it('the progress_update handler calls silencePoke.noteOutbound after the send lands', () => {
289
+ const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
290
+ const start = "if (!args.chat_id) throw new Error('progress_update: chat_id is required')"
291
+ const end = '`ask_user` MCP tool'
292
+ const block = (gatewaySrc.split(start)[1] ?? '').split(end)[0] ?? ''
293
+ expect(block.length).toBeGreaterThan(100) // sanity: slice found the handler
294
+ // A progress_update is a fresh user-visible outbound the model authored —
295
+ // it must reset the 300s silence clock exactly like a reply send does
296
+ // (outbound-send-path.ts). Pre-#4330 only signalTracker was ticked, so a
297
+ // turn narrating solely via progress_update was torn down as "silent".
298
+ expect(block).toMatch(/silencePoke\.noteOutbound\(key, Date\.now\(\)\)/)
299
+ })
300
+ })