switchroom 0.19.18 → 0.19.22

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.
Files changed (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +56 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +5242 -2239
  7. package/dist/host-control/main.js +12241 -11375
  8. package/dist/vault/approvals/kernel-server.js +113 -7
  9. package/dist/vault/broker/server.js +259 -76
  10. package/package.json +6 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/skills/switchroom-release/SKILL.md +103 -20
  13. package/telegram-plugin/bridge/bridge.ts +14 -0
  14. package/telegram-plugin/card-format.ts +92 -3
  15. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
  17. package/telegram-plugin/dist/server.js +13 -0
  18. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  19. package/telegram-plugin/format.ts +19 -7
  20. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  21. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  23. package/telegram-plugin/gateway/gateway.ts +66 -56
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  25. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  26. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  27. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  28. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  29. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  30. package/telegram-plugin/gateway/store-file.ts +244 -0
  31. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  32. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  33. package/telegram-plugin/retry-api-call.ts +15 -2
  34. package/telegram-plugin/send-gate.ts +1 -1
  35. package/telegram-plugin/status-no-truncate.ts +64 -1
  36. package/telegram-plugin/status-pin-driver.ts +50 -27
  37. package/telegram-plugin/status-pin.ts +43 -5
  38. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  40. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  41. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  42. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  43. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  44. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  45. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  46. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  47. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  48. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  49. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  50. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  51. package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
  52. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  53. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  54. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  55. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  56. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  57. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  58. package/telegram-plugin/tool-activity-summary.ts +85 -13
  59. package/telegram-plugin/worker-activity-feed.ts +56 -2
  60. package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
  61. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  62. package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
  63. package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
  64. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  65. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  66. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  67. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  73. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
  76. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -195,11 +195,33 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
195
195
  # the child keeps appending to the same (now-empty) inode. Worst-case
196
196
  # race is a few log lines written between the cp and the truncate being
197
197
  # lost; acceptable for a debug trace log. Keeps at most ~2×cap on disk
198
- # (live + one .1 generation). Cap + interval are env-overridable.
198
+ # (live + one .1 generation), and since #3596 that .1 is compressed on
199
+ # rotate and age-reaped, so it can't outlive its usefulness. Cap,
200
+ # interval, max-age and compression are all env-overridable.
199
201
  _switchroom_log_rotator() {
200
202
  local _logfile="$1"
201
203
  local _max="${SWITCHROOM_SIDECAR_LOG_MAX_BYTES:-52428800}" # 50 MiB default
202
204
  local _interval="${SWITCHROOM_SIDECAR_LOG_ROTATE_INTERVAL_SEC:-300}" # 5 min
205
+ # #3596: the `.1` generation was written once and then NEVER reaped.
206
+ # A log that grows fast during an incident and slowly afterwards keeps
207
+ # its giant `.1` forever — on the live host clerk/gateway-supervisor.log.1
208
+ # was 582 MB (dated Jul 11) while the live log sat at ~5 MB, i.e. months
209
+ # away from ever being overwritten by the next rotation. Two bounds:
210
+ # * compress on rotate (gzip typically takes a text log to <10%), and
211
+ # * an AGE bound — a `.1`/`.1.gz` older than this is deleted on the
212
+ # next check even if no rotation happens.
213
+ # Be honest about what the age bound COSTS (#3600 review): right after
214
+ # a rotation the `.1` holds everything and the live log is empty, so
215
+ # if the agent then goes quiet for 14 days that history is DELETED
216
+ # while the near-empty live log survives. This is deliberate for a
217
+ # debug trace log — bounding shared host disk beats retaining stale
218
+ # traces — but it is deletion of the only copy, not merely eviction of
219
+ # a superseded backup. Raise SWITCHROOM_SIDECAR_LOG_ROTATE_MAX_AGE_SEC
220
+ # (or set it to 0 to disable the reaper) where that matters. Agent
221
+ # AUDIT trails are not affected: those are the hostd/vault logs, which
222
+ # rotate by count, never by age.
223
+ local _max_age_sec="${SWITCHROOM_SIDECAR_LOG_ROTATE_MAX_AGE_SEC:-1209600}" # 14 days
224
+ local _compress="${SWITCHROOM_SIDECAR_LOG_COMPRESS:-1}"
203
225
  # Validate BOTH env overrides up front; garbage falls back to the
204
226
  # default. Unvalidated, a non-numeric interval makes `sleep` fail
205
227
  # instantly and the `while true` loop hot-spins a CPU core for the
@@ -208,12 +230,28 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
208
230
  # interval would also hot-spin, so it falls back too.
209
231
  case "$_max" in ''|*[!0-9]*) _max=52428800;; esac
210
232
  case "$_interval" in ''|0|*[!0-9]*) _interval=300;; esac
233
+ # Same garbage guard for the age bound; 0 disables the age reaper
234
+ # (the size-triggered overwrite still applies).
235
+ case "$_max_age_sec" in ''|*[!0-9]*) _max_age_sec=1209600;; esac
236
+ # `find -mmin` takes MINUTES; round up so a sub-minute age still reaps.
237
+ local _max_age_min=$(( (_max_age_sec + 59) / 60 ))
211
238
  # A cap of 0 disables rotation entirely (operator escape hatch).
212
239
  # (Negative values contain '-' → non-numeric per the guard above →
213
240
  # default cap, i.e. rotation stays on.)
214
241
  [ "$_max" -eq 0 ] && return 0
242
+ local _dir _base
243
+ _dir=$(dirname "$_logfile")
244
+ _base=$(basename "$_logfile")
215
245
  while true; do
216
246
  sleep "$_interval"
247
+ # Age-reap the retained generation first — this runs every cycle,
248
+ # INDEPENDENT of whether the live log is over cap, which is exactly
249
+ # the case the old code never handled (#3596).
250
+ if [ "$_max_age_min" -gt 0 ]; then
251
+ find "$_dir" -maxdepth 1 -type f \
252
+ \( -name "$_base.1" -o -name "$_base.1.gz" \) \
253
+ -mmin "+$_max_age_min" -exec rm -f {} + 2>/dev/null || true
254
+ fi
217
255
  [ -f "$_logfile" ] || continue
218
256
  local _size
219
257
  _size=$(wc -c < "$_logfile" 2>/dev/null || echo 0)
@@ -222,6 +260,13 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
222
260
  if [ "$_size" -gt "$_max" ]; then
223
261
  if cp "$_logfile" "$_logfile.1" 2>/dev/null; then
224
262
  : > "$_logfile"
263
+ # Drop any stale compressed generation, then (best-effort)
264
+ # compress the fresh one. gzip failing (missing binary, ENOSPC)
265
+ # just leaves the plain `.1` — never fatal.
266
+ rm -f "$_logfile.1.gz" 2>/dev/null || true
267
+ if [ "$_compress" != "0" ] && command -v gzip >/dev/null 2>&1; then
268
+ gzip -f "$_logfile.1" 2>/dev/null || true
269
+ fi
225
270
  echo "[supervise] rotated $_logfile (${_size} bytes > ${_max} cap) → ${_logfile}.1" >> "$_logfile"
226
271
  else
227
272
  # cp failed (e.g. ENOSPC — exactly when rotation matters most).
@@ -1104,6 +1149,21 @@ if [ -f /opt/switchroom/webkite/config.toml ]; then
1104
1149
  unset sr_wk_target
1105
1150
  fi
1106
1151
 
1152
+ # Shared cloakbrowser Chromium cache (#TBD). The image pins
1153
+ # CLOAKBROWSER_CACHE_DIR to /opt/switchroom/cloakbrowser-cache and
1154
+ # compose bind-mounts the operator's single ~/.switchroom/cloakbrowser/
1155
+ # copy RO onto it. If that mount didn't materialise the dir is empty and
1156
+ # root-owned, so cloakbrowser cannot silently re-download its own ~700MB
1157
+ # private copy (verified live: it aborts with `[Errno 13] Permission
1158
+ # denied` instead of extracting) — it only loses local render. Warn
1159
+ # loudly, never fatally: webkite still cloud-renders when the
1160
+ # Cloudflare/Firecrawl credentials are present.
1161
+ sr_cb_dir="${CLOAKBROWSER_CACHE_DIR:-/opt/switchroom/cloakbrowser-cache}"
1162
+ if ! ls -d "$sr_cb_dir"/chromium-*/ >/dev/null 2>&1; then
1163
+ echo "WARNING (cloakbrowser shared cache): shared cloakbrowser Chromium missing at $sr_cb_dir — webkite local render unavailable (cloud render still works if Cloudflare/Firecrawl creds are set). On the host run: CLOAKBROWSER_CACHE_DIR=~/.switchroom/cloakbrowser cloakbrowser install && switchroom apply" >&2
1164
+ fi
1165
+ unset sr_cb_dir
1166
+
1107
1167
  # LiteLLM routing (opt-in, #litellm). When SWITCHROOM_LITELLM is set (compose
1108
1168
  # env, gated on litellm.enabled && keyConfirmed), route the unmodified `claude`
1109
1169
  # CLI through the operator's LiteLLM proxy at ANTHROPIC_BASE_URL: fetch the
@@ -12,7 +12,9 @@ Cut a release of `switchroom/switchroom` and get it live on the fleet. This is a
12
12
 
13
13
  - A release = a `vX.Y.Z` **git tag** on `main` (the merge commit of the CHANGELOG PR).
14
14
  - The tag is the version source of truth (`scripts/build.mjs:resolveVersion()`). `package.json` `version` is a **stale placeholder by design** — never bump it in a commit (the `//version` comment + #2733 discipline). The uncommitted pack-time bump happens in CI now, not by hand.
15
- - Cutting the tag fires TWO workflows in parallel on tag push: `docker-images` (builds + pushes the 6 ghcr images) and `npm-publish` (builds, packs, verifies, publishes to npm). **Rollout is gated on BOTH going green.**
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
+ - **`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
+ - **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.
16
18
 
17
19
  ## Before you start — pre-flight (verify, don't assume)
18
20
 
@@ -28,30 +30,97 @@ Cut a release of `switchroom/switchroom` and get it live on the fleet. This is a
28
30
  - The release commit touches **CHANGELOG.md only**. Do NOT bump `package.json` (placeholder discipline).
29
31
  - Branch protection blocks direct push to `main`, so: create a `release/vX.Y.Z` branch, push it, open a `chore: release vX.Y.Z` PR (base `main`), arm auto-merge (squash, delete-branch) on green CI.
30
32
 
31
- ## Step 2 — Cut the tag (on the merge commit, not the PR branch)
33
+ ## Step 2 — Create the DRAFT release on a PINNED SHA, then push the tag
32
34
 
33
- Once the changelog PR is merged:
34
- - `git fetch origin && git checkout main && git pull --ff-only`
35
- - Tag the merge commit and create the GitHub Release:
36
- - `gh release create vX.Y.Z -R switchroom/switchroom --target main --title 'vX.Y.Z — <summary>' --notes-file <notes-file>`
35
+ Once the changelog PR is merged. **Order matters, and so does the pin.**
36
+
37
+ ### 2a Resolve and PIN the commit
38
+
39
+ ```bash
40
+ git fetch origin
41
+ SHA="$(git rev-parse origin/main)"
42
+ git --no-pager log -1 --oneline "$SHA" # show the operator exactly what is being released
43
+ ```
44
+
45
+ **Never pass `--target main`.** `main` is resolved server-side at the moment the API call lands, and agents merge PRs in parallel here — a PR that merged in the seconds between your pre-flight check and your `gh release create` would be silently swallowed into the release. Resolve `$SHA` once, confirm it is the commit you inspected in pre-flight, and use that literal SHA everywhere below.
46
+
47
+ ### 2b — Create the release as a DRAFT
48
+
49
+ ```bash
50
+ gh release create "vX.Y.Z" -R switchroom/switchroom \
51
+ --draft \
52
+ --target "$SHA" \
53
+ --title 'vX.Y.Z — <summary>' \
54
+ --notes-file <notes-file>
55
+ ```
56
+
57
+ - **`--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.
37
59
  - **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.
38
- - **`gh release create` has been silently dropped in past runs.** After running it, verify: `gh release view vX.Y.Z` must return the release. If it didn't create, re-run.
60
+ - **`gh release create` has been silently dropped in past runs.** Verify it exists and is a draft:
61
+ ```bash
62
+ gh release view vX.Y.Z -R switchroom/switchroom --json tagName,isDraft
63
+ ```
64
+ `gh` resolves drafts by tag name (it falls back to scanning the release list). Note the raw REST `GET /releases/tags/{tag}` does **not** — it 404s on a draft. That difference is why the workflow scripts use the list endpoint; don't "fix" them to use the by-tag endpoint.
39
65
 
40
- ## Step 3 Wait for BOTH tag-push workflows (hard gates)
66
+ ### 2cPush the tag at that same pinned SHA
41
67
 
42
- The tag push triggers `docker-images` AND `npm-publish` in parallel. **Do not proceed to rollout until both are green AND verified.**
68
+ GitHub does not create the tag ref for a *draft* release (it creates it on publish), so the tag push below is what actually starts the pipeline. Confirm that before pushing, because if the ref already existed at `$SHA` the push would be a silent no-op and **no workflow would fire**:
43
69
 
44
- ### Gate A — npm publish (`npm-publish.yml`)
45
- - `gh run list --workflow=npm-publish.yml --limit 1` wait for it to reach `completed` / `success`.
46
- - Verify the publish is live: `npm view switchroom version` must return `X.Y.Z` (not the old version). Retry a few times — npm registry propagation can lag a few seconds.
47
- - If this workflow fails: **the release is not published.** Do NOT roll. Diagnose (NPM_TOKEN unset? npm 5xx? empty dist?). Re-run via `gh workflow run npm-publish.yml --ref vX.Y.Z` after fixing.
70
+ ```bash
71
+ git ls-remote --tags origin "refs/tags/vX.Y.Z" # expect NO output
72
+ git push origin "$SHA:refs/tags/vX.Y.Z"
73
+ ```
74
+
75
+ Pushing the SHA-to-ref form rather than `git tag && git push --tags` guarantees the tag lands on the commit you pinned in 2a, not on whatever your local `main` happens to be. (If the ref *does* already exist at a different commit, the push is rejected — that is the safe direction. Do not force it; work out why first.)
76
+
77
+ Then confirm both workflows actually started before you walk away:
78
+
79
+ ```bash
80
+ gh run list -R switchroom/switchroom --branch vX.Y.Z --limit 5
81
+ ```
48
82
 
49
- ### Gate B docker images (`docker-images.yml`)
83
+ You should see a `docker-images` run and a `release` run. If you see neither, the tag ref already existed and nothing fired.
84
+
85
+ ## Step 3 — Wait for the pipeline (two workflows, one of them orchestrated)
86
+
87
+ The tag push triggers `docker-images` and `release`. `release` internally waits for `docker-images`, then publishes to npm, then un-drafts the GitHub Release. **Do not proceed to rollout until both are green AND verified.**
88
+
89
+ ### Gate A — docker images (`docker-images.yml`)
50
90
  - `gh run list --workflow=docker-images.yml --limit 1` — wait for `completed` / `success`.
51
- - Verify all 6 images are published: `docker manifest inspect ghcr.io/switchroom/<image>:vX.Y.Z` for `agent`, `auth-broker`, `kernel`, `broker`, `web`, `hostd`. Each must resolve.
52
- - If any image is missing: do NOT roll — the rollout canary version-assert fails on an unpublished tag. Wait + re-check.
91
+ - Verify all 6 images are published. The repository name is `switchroom-<name>`, **not** `<name>` — `.github/workflows/docker-images.yml` builds `${REGISTRY}/${IMAGE_NAMESPACE}/switchroom-${{ matrix.image.name }}`, so `ghcr.io/switchroom/agent` does not exist and returns `manifest unknown`. Check each of:
92
+ ```bash
93
+ for n in agent auth-broker kernel broker web hostd; do
94
+ docker manifest inspect "ghcr.io/switchroom/switchroom-$n:vX.Y.Z" >/dev/null \
95
+ && echo "OK switchroom-$n" || echo "MISS switchroom-$n"
96
+ done
97
+ ```
98
+ Each must resolve.
99
+ - If any image is missing: do NOT roll — the rollout canary version-assert fails on an unpublished tag. Wait + re-check. `release` will block on this by itself, so a red image build means npm never publishes and the release never leaves draft. That is the design.
100
+
101
+ ### Gate B — the release pipeline (`release.yml`)
102
+ - `gh run list --workflow=release.yml --limit 1` — wait for `completed` / `success`. Expect ~25-30 minutes: four native build legs plus the wait on `docker-images`.
103
+ - Its jobs, in order: `guard` (release exists + held out of `latest`) → `build` ×4 → `bundle` → `publish` (attach) → `images-gate` (wait on docker-images) → `npm` → `finalize` (un-draft). A red job anywhere leaves the release a **draft** and npm **unpublished** — which is the correct, recoverable state.
104
+ - Verify the release page actually has assets **and is no longer a draft**:
105
+ ```bash
106
+ gh release view vX.Y.Z -R switchroom/switchroom --json isDraft,assets \
107
+ --jq '{isDraft, assets: [.assets[].name]}'
108
+ ```
109
+ `isDraft` must be `false`, and `assets` must list all four binaries (`switchroom-{linux,macos}-{amd64,arm64}`) **and** `switchroom-checksums.txt`. This is the gate that did not exist through v0.19.19 — every release up to then shipped **zero** assets and the advertised `curl | sh` installer (`install.sh`) was dead on every platform (#3633).
110
+ - Confirm the installer's own resolution path agrees:
111
+ ```bash
112
+ gh api repos/switchroom/switchroom/releases/latest --jq '{tag_name, assets: [.assets[].name]}'
113
+ ```
114
+ This is literally what `install.sh` calls. If it still reports the previous version, `finalize` did not run.
115
+
116
+ ### Gate C — npm publish
117
+ - npm is published by the `npm` job **inside** the `release` run, not by a separate workflow run. `gh run list --workflow=npm-publish.yml` will show nothing new for a normal release — that is expected, not a failure.
118
+ - Verify the publish is live: `npm view switchroom version` must return `X.Y.Z` (not the old version). Retry a few times — npm registry propagation can lag a few seconds.
119
+
120
+ **Only when Gates A, B AND C are green + verified** do you proceed.
53
121
 
54
- **Only when Gate A AND Gate B are green + verified** do you proceed.
122
+ ### Rehearsing a workflow change without releasing anything
123
+ `gh workflow run release.yml --ref <branch>` — `dry_run` defaults to `true`, so it builds, checksums and verifies the full bundle and attaches it as a workflow artifact, touching no GitHub Release, no npm, and no image tags. This is the only supported way to prove a change to the release pipeline before a real tag.
55
124
 
56
125
  ## Step 4 — Fleet rollout (operator-gated, canary-first)
57
126
 
@@ -63,14 +132,28 @@ The tag push triggers `docker-images` AND `npm-publish` in parallel. **Do not pr
63
132
 
64
133
  - **Never bump `package.json` `version` in a commit.** It's a stale placeholder; the tag is the source of truth and `npm-publish.yml` does the uncommitted pack-time bump.
65
134
  - **Never run `npm publish` by hand from the agent container.** You can't reach the operator's npm auth, and the workflow is the reliable path. If the workflow is broken, fix the workflow — don't side-step it.
66
- - **Never roll the fleet before Gate A (npm) AND Gate B (images) are both green + verified.** A release that's on the fleet but not on npm is the exact regression this skill exists to prevent.
135
+ - **Never create the GitHub Release without `--draft`,** and **never `gh release create --target main`.** Pin the SHA (step 2a).
136
+ - **Never take the release out of draft by hand** while the pipeline is still running. `finalize` is the only thing that should publish it; un-drafting early puts an incomplete release on `/releases/latest` and breaks every installer.
137
+ - **Never roll the fleet before Gates A, B AND C are green + verified.** A release that's on the fleet but not on npm is the exact regression this skill exists to prevent.
67
138
  - **Never push directly to `main`.** The CHANGELOG PR goes through auto-merge on green.
68
139
  - **Never force-push `main` or bypass hooks (`--no-verify`).**
69
140
 
70
141
  ## If something goes wrong
71
142
 
72
- - **npm-publish failed but the tag is already pushed:** fix + `gh workflow run npm-publish.yml --ref vX.Y.Z`. Do NOT roll until it's green + `npm view` confirms.
73
- - **Images failed but npm succeeded:** the npm package is live but the fleet can't roll yet. Fix the image workflow / re-run. (npm being ahead of images is fine — the CLI is published for npm consumers; the fleet waits on images.)
143
+ **The single recovery command for almost everything is a re-dispatch of the orchestrator:**
144
+
145
+ ```bash
146
+ gh workflow run release.yml -R switchroom/switchroom --ref vX.Y.Z -f dry_run=false
147
+ ```
148
+
149
+ It re-runs every leg — including `finalize`, which is what actually takes the release out of draft. Re-running one leg on its own generally leaves the release stuck as a draft.
150
+
151
+ **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.
152
+
153
+ - **`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.
154
+ - **`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.
155
+ - **`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.
156
+ - **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.
74
157
  - **Rollout started before publish verified (the old bug):** abort the rollout, publish, then re-roll. Do not let a half-published release sit on the fleet.
75
158
 
76
159
  ## Operator one-time setup (tell them once, not every release)
@@ -133,6 +133,20 @@ const TOOL_SCHEMAS = [
133
133
  required: ['chat_id', 'text'],
134
134
  },
135
135
  },
136
+ {
137
+ name: 'progress_update',
138
+ description:
139
+ 'Post a short interim progress line to Telegram mid-task ("still working through X"). Sends a NEW plain message to the chat — it is not an edit and not a card row, so use it sparingly and only when the user genuinely benefits from knowing where a long task stands. The gateway enforces its own limits: text is truncated at 300 chars, at most one update per 20s per chat+thread, and at most 5 per turn; over-limit calls return {ok:false, reason:"too_soon"|"turn_limit"} instead of sending. Prefer edit_message when you already own a message to update, and always deliver the actual answer with reply.',
140
+ inputSchema: {
141
+ type: 'object',
142
+ properties: {
143
+ chat_id: { type: 'string', description: 'Chat to post the progress line in — pass chat_id from the inbound message.' },
144
+ text: { type: 'string', description: 'The progress line. One short sentence; truncated at 300 chars by the gateway.' },
145
+ message_thread_id: { type: 'string', description: 'Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified.' },
146
+ },
147
+ required: ['chat_id', 'text'],
148
+ },
149
+ },
136
150
  {
137
151
  name: 'react',
138
152
  description: 'Add an emoji reaction to a Telegram message. Telegram only accepts a fixed whitelist (👍 👎 ❤ 🔥 👀 🎉 etc) — non-whitelisted emoji will be rejected.',
@@ -38,6 +38,69 @@ import { normalizeDashes } from './text-voice-scrub.js'
38
38
  */
39
39
  export { escapeMarkdown, codeSpanSafe }
40
40
 
41
+ /**
42
+ * Separator appended to every card line that is followed by a hard break when
43
+ * `stackCardLines` runs in `collapseSafe` mode (#3666).
44
+ *
45
+ * It is a NO-BREAK SPACE (U+00A0), chosen deliberately:
46
+ *
47
+ * - It is a real character in the message text, so it survives Telegram's
48
+ * pinned-bar collapse (which drops the `\n` and substitutes nothing) and
49
+ * keeps the two lines' glyphs apart in the one-line preview.
50
+ * - It is NOT ASCII whitespace, so neither this module's own
51
+ * trailing-whitespace strip nor the GFM parser's hard-break handling
52
+ * (which consumes only the ASCII spaces immediately before the newline)
53
+ * can eat it.
54
+ * - It is invisible where the card is normally read — the chat feed — since
55
+ * it lands at end-of-line. That is the whole reason a separator with
56
+ * visible ink (`' ·'`, `' —'`) was rejected: it would put dangling
57
+ * punctuation on every line of every pinned card to fix a defect that only
58
+ * manifests on the pin bar.
59
+ *
60
+ * If a real-pin eyeball shows a single space is too weak a break, this constant
61
+ * is the one place to strengthen it (e.g. `' ·'`) — the seam is deliberate.
62
+ */
63
+ export const COLLAPSE_SAFE_SEPARATOR = '\u00A0'
64
+
65
+ /**
66
+ * Normalise a line's tail before `stackCardLines` re-terminates it.
67
+ *
68
+ * Two strips, both there for the SAME reason: this function must be idempotent,
69
+ * so re-stacking an already-stacked body can never accumulate terminator
70
+ * characters.
71
+ *
72
+ * - trailing ASCII whitespace, so exactly one ` \n` hard break is emitted;
73
+ * - in `collapseSafe` mode, any `COLLAPSE_SAFE_SEPARATOR` run the line
74
+ * already carries (interleaved with ASCII whitespace, since a caller may
75
+ * have split the body on `\n` rather than on ` \n`), so exactly one
76
+ * separator is emitted.
77
+ *
78
+ * The separator strip is written against the CONSTANT, not against a hardcoded
79
+ * U+00A0, so it stays correct if the separator is ever strengthened to visible
80
+ * ink. That one-constant seam is what COLLAPSE_SAFE_SEPARATOR's own doc comment
81
+ * promises, and it would be a lie if this strip did not follow it.
82
+ */
83
+ function normalizeLineTail(line: string, collapseSafe: boolean): string {
84
+ let out = line.replace(/[ \t\r]+$/, '')
85
+ // The length guard is not defensive noise: `''.endsWith('')` is true and
86
+ // `''.slice(0, -0)` is `''`, so an empty separator would spin this loop
87
+ // forever — a hang in the gateway's render path. The doc comment above the
88
+ // constant invites future edits to it, so make that edit unable to hang.
89
+ if (!collapseSafe || COLLAPSE_SAFE_SEPARATOR.length === 0) return out
90
+ while (out.endsWith(COLLAPSE_SAFE_SEPARATOR)) {
91
+ out = out.slice(0, -COLLAPSE_SAFE_SEPARATOR.length).replace(/[ \t\r]+$/, '')
92
+ }
93
+ return out
94
+ }
95
+
96
+ /** Options for `stackCardLines`. */
97
+ export interface StackCardLinesOpts {
98
+ /** Append `COLLAPSE_SAFE_SEPARATOR` to each hard-broken line so the stack
99
+ * stays readable on a surface that collapses it to one line (#3666).
100
+ * Opt-in: only pinned cards pay the (tiny) extra character per line. */
101
+ collapseSafe?: boolean
102
+ }
103
+
41
104
  /**
42
105
  * Join a card's pre-rendered, single-line entries so they STACK in the
43
106
  * Bot API 10.1 rich-message renderer (#2669) — the same visual result the
@@ -63,8 +126,19 @@ export { escapeMarkdown, codeSpanSafe }
63
126
  * This is the card-surface analogue of the reply path's
64
127
  * `normalizeParagraphBreaks`: it guarantees a card authored as stacked
65
128
  * bullet/step lines renders identically to a normal reply.
129
+ *
130
+ * `opts.collapseSafe` (#3666) additionally makes the stack survive being
131
+ * rendered on a surface that COLLAPSES the message to one line — Telegram's
132
+ * pinned-message bar drops the newlines and substitutes nothing, mashing the
133
+ * last glyph of each line into the first glyph of the next
134
+ * (`… · opus 5✓ Reading gateway.ts→ Running search …`). Opt-in, because it is
135
+ * only correct for cards that are actually pinned; see COLLAPSE_SAFE_SEPARATOR.
66
136
  */
67
- export function stackCardLines(lines: string[]): string {
137
+ export function stackCardLines(
138
+ lines: string[],
139
+ opts?: StackCardLinesOpts,
140
+ ): string {
141
+ const collapseSafe = opts?.collapseSafe === true
68
142
  const pieces: string[] = []
69
143
  for (let i = 0; i < lines.length; i++) {
70
144
  const line = lines[i]
@@ -75,12 +149,27 @@ export function stackCardLines(lines: string[]): string {
75
149
  // A blank current or next line is a genuine `\n\n` paragraph gap — leave
76
150
  // the separator a plain newline so the blank entry reconstructs the gap.
77
151
  if (cur === '' || next === '') {
152
+ // The gap still needs a collapse separator when THIS line has content:
153
+ // a collapsing surface drops the `\n\n` with no substitute exactly as it
154
+ // drops a ` \n`, and the blank entry is the empty string, so it
155
+ // contributes NO character of its own to hold the two sides apart. Only
156
+ // when `cur` is blank is the separator skipped — appending it to a blank
157
+ // entry would turn the gap into a visible U+00A0 paragraph in the FEED.
158
+ if (collapseSafe && cur !== '') {
159
+ pieces[pieces.length - 1] = normalizeLineTail(line, true) + COLLAPSE_SAFE_SEPARATOR
160
+ }
78
161
  pieces.push('\n')
79
162
  continue
80
163
  }
81
164
  // Strip any trailing whitespace the line already carried so we emit
82
- // exactly one ` \n` hard break (never accumulate spaces on a re-run).
83
- pieces[pieces.length - 1] = line.replace(/[ \t\r]+$/, '')
165
+ // exactly one ` \n` hard break (never accumulate spaces on a re-run) and,
166
+ // under collapseSafe, exactly one separator — see normalizeLineTail.
167
+ // The collapse separator is appended AFTER the strip (it is not ASCII
168
+ // whitespace, so it survives both this strip and the markdown parser's
169
+ // own trailing-whitespace handling) and BEFORE the two hard-break spaces,
170
+ // which stay immediately adjacent to the `\n` so the break still parses.
171
+ pieces[pieces.length - 1] =
172
+ normalizeLineTail(line, collapseSafe) + (collapseSafe ? COLLAPSE_SAFE_SEPARATOR : '')
84
173
  pieces.push(' \n')
85
174
  }
86
175
  return pieces.join('')
@@ -25004,6 +25004,19 @@ var TOOL_SCHEMAS = [
25004
25004
  required: ["chat_id", "text"]
25005
25005
  }
25006
25006
  },
25007
+ {
25008
+ name: "progress_update",
25009
+ description: 'Post a short interim progress line to Telegram mid-task ("still working through X"). Sends a NEW plain message to the chat \u2014 it is not an edit and not a card row, so use it sparingly and only when the user genuinely benefits from knowing where a long task stands. The gateway enforces its own limits: text is truncated at 300 chars, at most one update per 20s per chat+thread, and at most 5 per turn; over-limit calls return {ok:false, reason:"too_soon"|"turn_limit"} instead of sending. Prefer edit_message when you already own a message to update, and always deliver the actual answer with reply.',
25010
+ inputSchema: {
25011
+ type: "object",
25012
+ properties: {
25013
+ chat_id: { type: "string", description: "Chat to post the progress line in \u2014 pass chat_id from the inbound message." },
25014
+ text: { type: "string", description: "The progress line. One short sentence; truncated at 300 chars by the gateway." },
25015
+ message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified." }
25016
+ },
25017
+ required: ["chat_id", "text"]
25018
+ }
25019
+ },
25007
25020
  {
25008
25021
  name: "react",
25009
25022
  description: "Add an emoji reaction to a Telegram message. Telegram only accepts a fixed whitelist (\uD83D\uDC4D \uD83D\uDC4E \u2764 \uD83D\uDD25 \uD83D\uDC40 \uD83C\uDF89 etc) \u2014 non-whitelisted emoji will be rejected.",