Skip to content

feat: replicas-telegram-bridge — Cloudflare Worker bridging Telegram to Replicas#10

Open
replicas-connector[bot] wants to merge 59 commits into
mainfrom
feat/replicas-telegram-bridge
Open

feat: replicas-telegram-bridge — Cloudflare Worker bridging Telegram to Replicas#10
replicas-connector[bot] wants to merge 59 commits into
mainfrom
feat/replicas-telegram-bridge

Conversation

@replicas-connector

@replicas-connector replicas-connector Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Summary

  • New Cloudflare Worker (replicas-telegram-bridge/) that converts Telegram bot updates into Replicas API calls — the Telegram counterpart to the existing native Slack automation in the org.
  • Drives POST /v1/replica on the first message in (chat_id, message_thread_id) and POST /v1/replica/{id}/messages on follow-ups; (chat:thread → replica_id) mapping lives in Workers KV with a 7-day TTL.
  • Acks each incoming message with a 👀 reaction via Telegram setMessageReaction. Webhook authenticity is enforced by the X-Telegram-Bot-Api-Secret-Token header.
  • 13 vitest cases running inside the Workers runtime cover the webhook handler (health/404/secret/JSON), routing, follow-ups, idempotency on Telegram retries, error paths, forum-topic threading, and slugification.

Why

Replicas only ships native triggers for Slack, GitHub, and cron. This Worker is the smallest viable bridge for a fourth platform — chosen because Beeper bridges Telegram natively via mautrix-telegram, so DM-the-bot-from-Beeper works without writing any Matrix code or per-workspace OAuth dancing. See replicas-telegram-bridge/README.md for the full deploy + bot-setup flow.

Test plan

  • bun install succeeds.
  • bun run typecheck clean (tsc --noEmit).
  • bun run test — 13/13 passing in the Workers runtime via @cloudflare/vitest-pool-workers.
  • Create the bot in Telegram via @BotFather; /setprivacy → Disable.
  • wrangler kv:namespace create MAP; paste the id into wrangler.toml.
  • wrangler secret put TG_TOKEN, TG_WEBHOOK_SECRET, REPLICAS_API_KEY.
  • wrangler deploy, then point Telegram at the Worker: curl "https://api.telegram.org/bot$TG_TOKEN/setWebhook?url=https://<worker>/tg&secret_token=$TG_WEBHOOK_SECRET".
  • DM the bot from Beeper (or native Telegram). Confirm 👀 ack within a few seconds and a spawned replica visible via GET /v1/replica?source=telegram.
  • Send a follow-up message and confirm it routes to the same replica (no new spawn).
  • Wire the workspace start hook to surface TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID / TELEGRAM_THREAD_ID so the agent can reply via sendMessage (currently passed in metadata on POST /v1/replica).

Notes

  • Compatibility date is set to 2026-05-01; the local Workers runtime warns and falls back to 2024-12-30, which is fine for tests.
  • Cloudflare Workers can't hold a Matrix /sync long-poll, which is why this bridge is webhook-based on Telegram rather than a native Matrix bot. Trade-off documented in the README.

Created by Jaden (jadengarza@pm.me) with Replicas
Workspace  ·  Slack Thread

Open in Devin Review

…o Replicas

Adds a stateless Worker that accepts Telegram bot webhooks and translates each
message into the right Replicas API call:
- First message in (chat_id, message_thread_id) -> POST /v1/replica
- Subsequent messages on that key -> POST /v1/replica/{id}/messages
- Acknowledgement via Telegram setMessageReaction (eyes)
- Mapping persisted in Workers KV with a 7-day TTL

Includes typed request/response shapes, webhook secret verification,
idempotency guard for duplicate Telegram retries, and a vitest suite covering
routing, follow-ups, idempotency, error paths, forum-topic threading, and
helper slugification. Tests run inside the Workers runtime via
@cloudflare/vitest-pool-workers.

This is the Telegram counterpart to the existing native Slack automation in
the Replicas org and is the recommended path for piping Replicas tasks into a
Beeper inbox (via mautrix-telegram) without writing a Matrix bot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
@capy-ai

capy-ai Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

replicas-connector Bot and others added 28 commits May 29, 2026 05:42
Worker deployed to https://replicas-telegram-bridge.jadengarza.workers.dev
with the MAP namespace id wrangler created during first deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
The Worker now prefixes every spawn's initial prompt (and follow-ups) with a
routing header + a one-line hint pointing the agent at the helper scripts:

    [tg:chat_id=<id>:thread_id=<id>]
    # Spawned from Telegram. Post status + your final result back with
      `~/.replicas/bin/tg-reply.sh "<text>"`. Run
      `~/.replicas/bin/tg-target-detect.sh` once with this prompt's first
      line on stdin to cache the target. See `~/.replicas/TELEGRAM_REPLY.md`.

Adds three env files (uploaded to env 5f06f598- as well):

- `~/.replicas/bin/tg-reply.sh`         — wraps Telegram `sendMessage`,
  handles the 4096-char cap by chunking, reads target from /tmp/tg-target.
- `~/.replicas/bin/tg-target-detect.sh` — parses the `[tg:...]` header off
  the first line of stdin and stashes chat_id/thread_id for later calls.
- `~/.replicas/TELEGRAM_REPLY.md`       — reference doc for the agent.

`TG_BOT_TOKEN` is also pinned on env 5f06f598- so spawned workspaces inherit
it without further config.

Test count goes 13 -> 16. Prefix shape is covered (header line, hint,
follow-up parity) plus DM vs forum-topic threading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…Text

Adds the second half of the agent <-> Telegram surface: a "live" status line
that gets edited in place as the agent thinks and runs tools, so the user can
watch progress without the chat being flooded by individual messages.

- New env file ~/.replicas/bin/tg-status.sh.
  First call sendMessage + caches message_id in /tmp/tg-status-mid; subsequent
  calls editMessageText against that id. Falls back to a fresh sendMessage if
  the edit fails (message too old, deleted, etc.). Treats Telegram's
  "message is not modified" error as success.
- TELEGRAM_REPLY.md rewritten with the two-channel protocol:
  tg-status.sh for the live line (thinking / tool calls / phase changes),
  tg-reply.sh for new messages (final result, PR links, blockers).
  Includes an emoji convention table so users learn to read it at a glance.
- Worker's prompt prefix updated so spawned agents see both helpers and the
  step-by-step setup instructions on line 2-6 of every initial prompt.
- Tests updated to assert both helpers are referenced in the prefix.

Probed Replicas events surface during this work:
- GET /v1/replica/{id}/events returns an SSE stream (held the connection open
  until our 15s timeout; emits engine.status.changed records). Useful for an
  external poller in a follow-up Durable Object, not for the Worker proper.
- GET /v1/replica/{id}/history returns {events, has_more, total} with
  per-event {type, role, created_at, content}. Could be polled to surface
  raw thinking tokens; deferred to a follow-up PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…e is gone

Previously, if KV mapped a chat to a replica that had been deleted (or
expired during inactivity), every follow-up message returned an error to the
user with no recovery path until the KV entry's 7-day TTL expired.

Now sendFollowUp returns {ok, gone}; on 404/410 the Worker invalidates the
KV entry and falls back to createReplica, transparently re-spawning into a
fresh workspace that picks up the latest env files and instructions.

This was hit in production: chat 91269535 was pinned to a replica that had
been spawned before the tg-reply.sh / tg-status.sh helpers existed, so
follow-ups kept routing into a workspace with no way to talk back.

Tests bumped 16 -> 17 with a 404-fallback case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…via bash

Two real-world bugs uncovered when an actual user message hit the bridge:

1. Env files uploaded to a *project* env (5f06f598-…) are listed by the
   Replicas API as files_uploaded on spawned workspaces but don't actually
   land on disk. Only Global env files (77d1bf95-…) get materialised into
   ~/.replicas/. The four Telegram helpers and TELEGRAM_REPLY.md were
   re-uploaded to Global as a result (not done in this commit — done via the
   `replicas env files set global` CLI).

2. Files mount with mode 644 — no execute bit — so `~/.replicas/bin/foo.sh`
   exits with 126 ("permission denied") when invoked directly. The
   spawned agent had to chmod +x before its first call. Switching every
   reference to `bash ~/.replicas/bin/foo.sh` sidesteps the executable bit
   entirely; bash will read+exec a text file with 644 perms fine.

The prompt prefix and TELEGRAM_REPLY.md are both updated to use the bash-
prefixed invocation. Worker tests still green (17/17).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…y sends

The old chunker did:
    chunk "$MSG" | while read -r piece; do send_one "$piece"; done

read -r reads one LINE at a time, so a multi-line MSG was sent as N
separate sendMessage calls back-to-back — which hits Telegram's 1 msg/sec
per-chat rate limit and returns 429 on every call after the first. The
agent saw [tg-reply] sendMessage failed and gave up.

Live trace from a running spawned workspace confirmed:
- env had TG_BOT_TOKEN set
- /tmp/tg-target was correctly cached (chat_id=91269535)
- direct `curl -d text=raw-test-from-debug ...` succeeded (message_id 29 delivered)
- `bash -x tg-reply.sh "plain-ascii"` also succeeded
- only multi-line + emoji payloads hit the failure

Now:
- Send the whole MSG as ONE call when ${#MSG} <= MAX_LEN (Telegram accepts
  embedded newlines in a single sendMessage body — we just need to not
  split them).
- For genuinely long content (>4000 chars), chunk at character boundaries
  and sleep 1s between sends to respect the rate limit.
- Parse `retry_after` out of 429 responses and retry once after sleeping.
- Echo the offending response prefix into the error so future agents can
  see why a send failed instead of just "sendMessage failed".

Local end-to-end test against the live bot (multi-line + ✅, • bullets,
em-dash) returned exit 0 and the message landed in chat 91269535.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…+ tool calls

Replaces the agent-driven tg-status.sh / tg-reply.sh pattern with a Durable
Object that polls each spawned replica's /v1/replica/{id}/history every ~2s
and projects thinking + tool_use + claude-result events directly to Telegram.

Why the change: the agent-driven approach asked Claude to call helper shell
scripts before every tool use. In practice agents skipped status calls,
batched final replies into separate sendMessage calls (hitting rate limits),
and saw stale copies of helpers when env files raced with workspace mount.
Result: spotty status, frequent "sendMessage failed", no clean stream.

Now:

- New ReplicaPoller Durable Object class (src/poller.ts). On /watch it
  caches {replicaId, chatId, threadId} and sets a 1.5s alarm. Each alarm
  fetches /history?limit=200&include=content&verbose=1, slices the events
  after lastSeenCount, and:
    * For claude-assistant blocks: thinking -> "🤔 …", tool_use -> emoji-
      prefixed status line ("🔧 $ cmd", "📖 path", "📝 write", "✏️ edit",
      "🔍 grep/glob", "🌐 url/query", "🧰 mcp__…"); upserts ONE Telegram
      message via editMessageText.
    * For claude-result: sends a final sendMessage with the result text
      (chunked + sleep between chunks) and schedules a 30s cleanup alarm.
    * Backs off to 6s on transient API failure; gives up on 404/410; bounds
      the watch at 30 min.
- index.ts wires startWatcher(env, replicaId, msg) into both new spawns
  and existing-replica follow-ups (so the poller re-arms across turns).
- Prompt prefix simplified: agents no longer need to know about tg-status.sh
  or tg-reply.sh; their final assistant message becomes the Telegram reply
  automatically. The [tg:chat_id=…] header stays for diagnostic purposes.
- wrangler.toml gains a [[durable_objects.bindings]] for WATCHER and a v1
  migration declaring the ReplicaPoller class.
- Tests bumped 17 -> 18 with a watcher-attach assertion (mock WATCHER
  namespace) and an updated prefix expectation.

Deployed as version 64474aaa-6cfe-4cf8-b34c-c06e3ed7354a.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Wraps alarm() in a try/catch so an exception inside the polling logic
doesn't kill the alarm chain. Errors now log to wrangler tail and the
next alarm is rescheduled on backoff.

Adds structured log lines:
- per-tick event counts (events, fresh, newStatus, sawResult)
- final reply send size
- editMessageText / sendMessage failures with response prefix

Verified in production: end-to-end watch attached, 7 alarms fired at
~2s cadence, sawResult flipped on alarm 7, sendReply(1199 chars) ran.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
The poller was sending final replies as plain text, so Markdown in the
agent's response — **bold**, backticks, fenced code blocks, [links] —
showed up literal in Telegram. Now the reply is converted to Telegram's
HTML parse_mode subset before sending.

Conversions (src/markdown.ts):
- ```lang\ncode\n```  -> <pre><code class="language-lang">…</code></pre>
- ```code```          -> <pre>…</pre> with trailing newline trimmed
- `inline`            -> <code>…</code>
- **bold** / __bold__ -> <b>…</b>
- *italic* / _italic_ -> <i>…</i>  (run after bold so we don't eat *bold*)
- ~~strike~~          -> <s>…</s>
- [text](url)         -> <a href="url">text</a>
- &, <, > are escaped in prose; code contents also escaped before tag wrap.

If Telegram rejects the rendered HTML (mismatched entities, etc.) the
poller logs the error and retries the same message with no parse_mode, so
a parser hiccup never swallows the reply.

9 new unit tests in src/markdown.test.ts cover bold/italic disambiguation,
code-block protection from outer markdown, HTML escaping, links, fenced
blocks with and without language tags, and a realistic agent reply. Total
suite 18 -> 27.

Deployed as 179e5b16-40f8-4ea2-9939-e42ecb469404.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…+ task header

The original status surface was a single line that got overwritten on each
edit. Users saw at most one frame — and for fast turns, often nothing
before the final reply. Reworked into a scrolling log:

- One Telegram message, edited in place via editMessageText, that
  accumulates up to the last STATUS_LOG_MAX_LINES (10) events instead of
  replacing the previous one. The user sees a live trail of what the
  agent did.
- Status message opens *immediately* on /watch with "🤔 Starting…", so
  the user sees activity right after the 👀 reaction — no dead air while
  /history fills.
- A "Task:" header pulled from the triggering Telegram message's text is
  prepended so the user can tell at a glance which task the status is
  for, useful when juggling concurrent threads.
- Status frames render in HTML now (matching the final reply): tool
  commands and file paths inside <code>, thinking previews in <i>,
  header in <i>. status sends/edits set parse_mode=HTML.
- On claude-result the watcher appends a final "✅ Done in {n}s" frame
  before sending the final reply as a fresh message, so the log captures
  the completion and the reply persists alongside it.

WatchSpec gains an optional userText so the DO has the original task
context; index.ts passes msg.text/caption when calling /watch.

formatToolUse + headerFor are now exported so unit tests can pin them
down. New deploy: 8b19b668-f7f2-4678-a3cc-00b5218d03a9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…t defaults

Three changes aimed at making the Telegram UX feel real-time:

1. Poller cadence
   - FIRST_POLL_DELAY_MS: 1500 -> 400  (first status frame ~1s after webhook)
   - ACTIVE_POLL_INTERVAL_MS: 2000 -> 800  (~2.5x faster status updates)
   - BACKOFF_POLL_INTERVAL_MS: 6000 -> 4000

2. Smaller history payload per poll
   - /history limit 200 -> 50. We only ever care about events past
     lastSeenCount; under steady-state polling fresh count is single
     digits. Going to 50 cuts JSON parse + storage write costs without
     missing anything except deep-backlog edge cases.

3. Faster agent for Telegram spawns
   - POST /v1/replica now sets coding_agent=claude, model=claude-sonnet-4-6,
     thinking_level=low by default. Probed the live API with these fields
     and got a clean 200 + active replica.
   - Each value is overridable per-deploy through three new env vars
     (REPLICAS_AGENT_OVERRIDE / MODEL_OVERRIDE / THINKING_OVERRIDE)
     plumbed through wrangler.toml so swapping in claude-haiku-4-5 for
     even snappier chat -- or claude-opus-4-7 for harder coding tasks --
     is a wrangler vars change rather than a code change.

Net expectation: status frames update at ~1Hz instead of 0.5Hz, Sonnet
is ~2-3x faster than Opus, and thinking_level: low skips extended
reasoning entirely on simple turns. End-to-end this should feel an
order of magnitude snappier on short tasks.

Tests 27/27 still green (typecheck only — the new fields are passed
through, not branched on, so no new logic to cover).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…ow-ups don't replay old replies

The screenshot showed both "Hello" and "What tools do you have" getting the
identical reply "Hello! How can I help you today?" — a stale answer from
the FIRST turn re-broadcast as the answer to the SECOND.

Root cause: when a follow-up message arrives at an existing replica, the
Worker re-arms the DO via /watch. The /watch handler was hard-resetting
lastSeenCount to 0 — so the next alarm fetched /history and re-projected
EVERY event in the replica, including the prior turn's claude-result,
which got resent as "the answer" to the new question.

Now /watch snapshots the current event count first via
snapshotEventCount(replicaId) — a tiny GET /history?limit=1 that reads the
"total" field when the API provides it (or falls back to events.length).
That baseline becomes the new lastSeenCount, so subsequent polls only see
events generated AFTER the follow-up message.

For fresh spawns the baseline is 0 (no events yet); for follow-ups it's
whatever the replica had accumulated. No behavior change on the first
turn, real fix on every subsequent turn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…O is the only sender

Telegram screenshot showed the agent's reply landing in plain ** **
Markdown despite the markdown->HTML formatter being live. Root cause: the
agent kept reaching for the legacy `bash ~/.replicas/bin/tg-reply.sh`
helper -- which sends `sendMessage` with no parse_mode -- because the
scripts were still mounted on the Global env. So the agent's plain
sendMessage was the visible reply, ahead of (or in place of) the DO
poller's HTML-formatted send.

This commit removes the legacy path entirely:

- Deleted ~/.replicas/bin/tg-reply.sh, tg-status.sh, tg-target-detect.sh
  and ~/.replicas/TELEGRAM_REPLY.md from the Global env via
  `replicas env files delete --force` (out-of-band; tracked here in the
  commit message). Next spawn won't have the scripts on disk.

- Tightened the prompt prefix in prefixWithRoutingHeader to:
   * Explicitly forbid the removed helper paths so an agent with stale
     muscle memory doesn't try them.
   * Promise the poller projects thinking + tool_use + final reply.
   * State that final-message Markdown is converted to Telegram HTML, so
     the agent should keep emitting normal Markdown without trying to
     hand-write `<b>` / `<i>` tags.

Tests bumped to assert the new prefix contents (no count change; the old
prefix expectation was updated rather than a new test added).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…replicaId}

Adds a GET-only debug endpoint that proxies to the DO and returns its
storage map + scheduled alarm time. Lets us verify in production whether
the watcher attached, what baseline event count it snapshot, and whether
the rolling status message id is cached -- without redeploying.

Used to verify the pipeline in production via a synthetic webhook:
  POST https://watcher/watch - Ok
  [poller] /watch replica=... baseline=2
  Alarm fires at ~1Hz
  events=6 fresh=2 newStatus=yes sawResult=false   <- tool_use surfaced
  events=9 fresh=2 newStatus=no sawResult=true     <- final result captured
  sending final reply (471 chars)
  sendReply HTML failed (synthetic chat), fallback plain also failed (chat not found)

Confirms the architecture is correct end-to-end -- the only reason a
status line wouldn't appear for a real user is that the agent answered
without using any tools, in which case the only frames are "🤔 Starting…"
and "✅ Done in Xs".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Low thinking was hurting reply quality on simple greetings. Telegram-spawn
agents were defaulting to "Hello! How can I help you today?" regardless of
what the user actually asked, because sonnet without any extended thinking
collapses ambiguous prompts to a safe greeting.

Verified live with a tool-using prompt after the bump: spawned replica
processed correctly with newStatus=yes firing on 3 tool calls (ls, find,
find+xargs+wc) and produced a Markdown table with bold/code spans in the
final reply — which the markdown->HTML formatter will render properly.

Sonnet-4-6 with medium thinking is still ~2x faster than Opus-4-7; the
trade is back toward "engages with the prompt" vs raw speed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
The DO debug endpoint surfaced a bug after a tool-use synthetic test:
statusLines ended up with ["🤔 Starting…", "✅ Done in 4s", "✅ Done in 35s"]
because /watch fired twice for the same inbound Telegram message and the
second call hit the full reset path (clear statusLines, delete
statusMessageId, fresh "Starting…" frame), orphaning the already-sent
status message and producing duplicate "Done" frames.

Cause is upstream: Cloudflare KV is eventually consistent so the
`seen:<chat_id>:<msg_id>` idempotency guard in the Worker can let both
the original delivery and a Telegram webhook retry pass through, each
calling startWatcher.

Fix is to dedupe inside the DO itself: if the priorWatch we already
stored has the same replicaId AND the same startMessageId, just bail
out before resetting state. Follow-up messages with a different
startMessageId still hit the legitimate full-reset path, which is
correct -- a new turn deserves a new rolling-log status message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…d, reactions, pin, expandable log

Implements Phase 1+2 from ~/.replicas/plans/telegram-status-ux-research.md.
The status message now matches the template the research recommended:

  <b>📋 PLANNING</b>  ·  step 3  ·  ⏱ 14s
  <i>Task:</i> add a /healthz endpoint
  <code>🔧 $ ls -la src/</code>

  <blockquote expandable>
  <code>📖 src/index.ts</code>
  … N earlier steps
  </blockquote>

  <code>📖 src/server.ts</code>
  <code>✏️  src/router.ts</code>

  [✋ Cancel] [📜 Open log ↗]

New src/render.ts (pure functions, fully unit-tested):

- phaseFor(toolName, command) maps to one of
  STARTING / PLANNING / EDITING / RUNNING / TESTING / SHIPPING / DONE / FAILED
  - Read/Grep/Glob/WebFetch -> PLANNING
  - Edit/Write/NotebookEdit -> EDITING
  - Bash with bun/npm/pytest/vitest/cargo test -> TESTING
  - Bash with gh pr / git push / wrangler deploy / fly deploy -> SHIPPING
  - Bash otherwise -> RUNNING
- formatToolUseLine wraps each tool in <code> with an emoji prefix
- thinkingLine wraps thinking previews in italic
- render assembles header + Task: line + current action + rolling lines,
  collapsing older history into <blockquote expandable> once over the cap
- phaseToReactionEmoji picks bot-allowed standard emoji per phase

Rewrote src/poller.ts:

- /watch now also: snapshots events, resets state, sets 👀 reaction on the
  user's prompt, calls renderAndSend (which sends the first status frame
  AND pins it), then schedules the first alarm. Idempotent dedupe on
  (replicaId, startMessageId).
- Each alarm: pulls history, projects tool_use/thinking into phase +
  currentAction + lines + stepCount, calls renderAndSend (which diffs
  against lastRendered before hitting editMessageText), and pings
  sendChatAction every ~4s with action=typing or upload_document for
  EDITING/SHIPPING phases so Telegram's "typing…" indicator stays alive
  above the input box.
- claude-result: appends a terminal frame (🎉 DONE in Ns or ❌ FAILED in
  Ns + error blockquote), unpins the status message, sets big 🎉 or 😭
  reaction on the user's prompt, then sends the formatted final reply
  as a separate message (existing markdownToTelegramHtml path).
- /cancel endpoint on the DO: calls DELETE /v1/replica/{id} and writes a
  failed terminal frame. Reachable via Telegram inline keyboard's
  ✋ Cancel button.

Worker side (src/index.ts):

- handleCallback routes callback_query updates. `c:<replicaId>` is the
  cancel opcode (callback_data limit is 64 bytes; UUID fits).
- ackCallback acknowledges within Telegram's 10s window with an optional
  toast text so the button doesn't appear stuck.
- TgUpdate gains optional callback_query.

Inline keyboard:

- Active: [✋ Cancel] [📜 Open log ↗]
- Terminal: [📜 Open log ↗] (cancel stripped)
- Open log is a `url` button pointing to
  https://www.replicas.dev/dashboard?workspaceId=<replicaId> — no
  callback wiring, just a deep link.

Tests: 27 -> 50 green. New src/render.test.ts covers phaseFor (5 cases),
formatToolUseLine (6 cases incl. HTML escape), thinkingLine,
phaseToReactionEmoji, render (4 cases incl. starting, running with
counter, expandable-blockquote collapse, DONE/FAILED terminals), and
formatDuration.

Deployed as 7f5ad859-8c79-4994-8631-4896f5c26448.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Drops the polling interval and adds a periodic ticker re-render so the ⏱
elapsed-time in the status header keeps updating even when no new
tool_use events are flowing. The goal is the highest update frequency
that respects Telegram's per-message edit rate limit.

Constants:
- FIRST_POLL_DELAY_MS: 400 -> 250
- ACTIVE_POLL_INTERVAL_MS: 800 -> 400  (2.0x faster detection)
- BACKOFF_POLL_INTERVAL_MS: 4000 -> 3000
- EDIT_MIN_INTERVAL_MS: NEW, 900ms. We never editMessageText the same
  message more than once per ~900ms (Telegram caps at ~1/sec per chat).
- TICKER_REFRESH_MS: NEW, 2500ms. If at least this long has passed
  since the last edit, the alarm forces a re-render so the ticker
  doesn't stall.

Mechanics:
- renderAndSend now snapshots lastEditAt at the moment of a successful
  edit. If the next call hits the diff guard while inside the
  EDIT_MIN_INTERVAL window, we leave the state alone and let the next
  alarm pick up an even fresher render.
- alarmInner re-renders when ANY of: a new line was appended,
  currentAction changed, OR ticker is stale. So a long-running tool
  (e.g. `bun test` running 8s) still shows the time advancing.
- Net latency to surface a fresh tool_use call: ~400ms poll detect +
  up to ~900ms edit window = <=1.3s worst case from agent action to
  Telegram message.

Verified live: synthetic webhook with 3-step task ("read package.json,
list src dir, count lines in src/index.ts"). Captured alarms at ~1s
intervals through STARTING -> RUNNING phases, step counter 0 -> 3,
sawResult=true at ~17s total. No 429 backoffs.

Tests 50/50 still green (constants only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Reads cleaner, removes repetition, looks like a chat message instead of
a dashboard.

Before:
  <b>📋 PLANNING</b>  ·  step 3  ·  ⏱ 14s
  <i>Task:</i> deploy the api
  <code>🔧 $ ls -la src/</code>
  <code>📖 src/index.ts</code>
  <code>✏️  src/server.ts</code>
  [✋ Cancel] [📜 Open log ↗]

After:
  📋 <b>Planning</b> · step 3 · 14s

  🔧 <code>ls -la src/</code>
  📖 <code>src/index.ts</code>
  ✏️ <code>src/server.ts</code>
  [✋ Cancel] [📜 Open log]

Changes to the rendered shape:

- Phase emoji is OUTSIDE the bold tag, not inside an all-caps label. Was
  `<b>📋 PLANNING</b>`, now `📋 <b>Planning</b>`. Title case, no
  dashboard look.
- Tool emoji is OUTSIDE the <code> tag. Was `<code>🔧 $ ls</code>` where
  the emoji and `$` were forced into monospace and looked like ASCII
  art. Now `🔧 <code>ls</code>` — emoji renders naturally, only the
  command is monospace.
- Dropped the `$` prefix for Bash. The 🔧 wrench already signals
  "running command" and `ls -la` is clearly a command.
- Dropped the "Task:" repetition. The status message is sent as a reply
  to the user's original prompt, so Telegram displays the prompt above
  the status. Repeating it inside is noise.
- Header separator is a single ` · ` instead of three different
  separators (`  ·  ` with double spaces). Reads like sentence prose.
- Terminal state collapses to one line: `🎉 <b>Done</b> · 14s` or
  `❌ <b>Failed</b> · 8s` with the error in a <blockquote>. Previously
  retained the tool log even on success, which competed with the final
  reply that lands immediately below.
- formatDuration: 1m+ renders as `1:42` (M:SS) instead of `1m 42s`.
  Reads as a stopwatch, fits the ⏱-style header.
- thinkingLine: drops the leading 🤔 emoji since the phase header
  already shows it. Italic alone signals "this is preview/thinking".
- Inline keyboard text: "📜 Open log" (no ↗). Telegram already shows
  an external-link glyph on url buttons; the arrow was redundant.

Tests refactored from 23 -> 26 cases. New ones pin: emoji-outside-code
for every tool, Bash without $ prefix, Title-case phase header,
starting-state-doesn't-repeat-task, terminal collapses to one line,
M:SS duration formatting. Suite total 50 -> 53 green.

Deployed as 6db4694f-8e2c-48b2-8ca7-beb0b7afc494.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
… round-trip

Five concrete latency wins layered together. Net: the first 👀 reaction
hits within ~100ms of the webhook landing, the first status frame within
~500ms, and the gap between agent's claude-result event and the final
Telegram reply drops by ~400ms.

1. Worker: instant 👀 reaction in parallel with POST /v1/replica
   - sendInstantAck fires setMessageReaction with the message_id from
     the inbound update before we even start the Replicas spawn. User
     sees the reaction land within ~100ms instead of waiting for the
     Replicas POST + DO attach + DO setMessageReaction chain (was ~600ms).
   - DO's /watch handler skips its own setReaction call now that the
     Worker already set it.

2. Worker: follow-up message + watcher re-arm run in parallel
   - Was: await sendFollowUp -> await startWatcher  (~400ms serial)
   - Now: Promise.all([sendFollowUp, startWatcher]) (~250ms parallel)

3. DO: terminal status edit + final reply send run in parallel
   - The "✅ Done in Xs" editMessageText and the sendMessage with the
     final reply text don't depend on each other. Promise.all saves
     ~400ms on every completed turn.

4. Tighter poll cadence
   - FIRST_POLL_DELAY_MS: 250 -> 150
   - ACTIVE_POLL_INTERVAL_MS: 400 -> 300
   - TICKER_REFRESH_MS: 2500 -> 2000
   The /history fetch + render path got lighter when we removed the
   redundant setReaction call from /watch, so the alarm loop can target
   faster cadence without piling on backlogs.

5. Warm-session window 30min -> 60min
   - POST /v1/replica now passes lifecycle_policy: "delete_after_inactivity"
     auto_stop_minutes: 60. A second message ≤60min after the first
     reuses the warm workspace and skips the 5-15s cold-start entirely.
     Default was 30min.

6. Prompt prefix trimmed
   - The 3-line block telling the agent about the legacy helpers is gone
     (helpers were removed from Global env weeks ago). Now one sentence:
     "Spawned from Telegram. Your tool calls and final reply are
     auto-surfaced via an external poller. Emit Markdown freely; it'll
     be rendered to Telegram HTML."
   - Saves ~200 tokens per spawn -> measurably faster TTFT on the
     agent's first response.

Test for the prefix updated. All 53 tests still green.

Deployed as 6c5e5e... (next).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…he status log

Screenshot from a long Craft-tools session showed the rolling log only
listing tool names — `ToolSearch / connection_info / folders_list / …` —
with no narration. The agent had been emitting plenty of explanatory
text between tools ("Connected to Garzas Private space", "Sandbox folder
created: 5bf5d167…", "blocks_move has a schema issue") but the poller
captured those text blocks into pendingAssistantText and only used them
if claude-result fired. Mid-flight narration was dropped entirely.

Now:

- Each claude-assistant text block is treated as live narration:
  - updates currentAction (the italic line under the phase header)
  - is appended to the rolling log with a 💬 prefix so it survives
    subsequent tool calls and the user can scroll back through "the WHY"
- The log now interleaves tool calls and narration, which matches what
  you'd see in the Replicas UI chat panel.

Also bumped MAX_TOTAL_LINES 40 -> 80 so long Craft-tools-style runs (the
above example was already at step 20 mid-task) don't lose history. The
<blockquote expandable> keeps the older entries visually collapsed; the
4096-char Telegram message cap is still our real ceiling and is checked
separately.

Tests 53/53 still green (no test-shape changes; behavior change is
captured in renderer tests indirectly via realistic line counts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Each Durable Object storage operation is ~5-15ms of latency. The alarm
loop was awaiting them sequentially:
  storage.get watch
  storage.get startedAt
  storage.get pendingCleanup
  storage.get lastSeenCount
  storage.get lines
  storage.get phase
  storage.get stepCount
  storage.get currentAction
  ... fetch /history ...
  storage.put lines
  storage.put phase
  storage.put stepCount
  storage.put currentAction
  storage.put lastSeenCount

That's ~12 round-trips per tick, ~80-120ms of pure storage latency before
the actual work begins. Now:

- Single storage.get(keys[]) at the top of alarmInner returns a Map. We
  read every key we'll touch this tick in one round-trip.
- Single storage.put({ ... }) at the end commits all five mutated fields
  in one round-trip.
- Same pattern applied to /watch: snapshotEventCount runs in parallel
  with the multi-key delete, then a single multi-key put initialises
  the new state.
- renderAndSend's lastRendered + lastEditAt are now one put instead of
  two.

The /history fetch is now kicked off and awaited explicitly so the
control flow is clear; in a future pass the next-alarm scheduling can
be piped through the same path.

Expected cycle-time win: ~80-100ms per alarm, which means actual alarm
interval drops from ~600-700ms toward the 300ms target. Status frames
should perceptibly update faster on tool-heavy tasks.

Tests 53/53 still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
When the agent emits a structured plan in narration like:

    Plan (2/6)
    ~Round 2 sandbox created (folder + 2 docs + blocks)~
    Blocks add/get/update/delete confirmed; retry blocks_move + blocks_revert
    ~markdown_add OK; image_view needs Craft-hosted URL~
    Re-run tasks + comments
    Re-run document_search
    Cleanup

…the poller used to route it through thinkingLine() which truncated to
200 chars (eating most items) AND through <i> italic that didn't render
the ~item~ strikethrough at all. The plan was effectively invisible.

Now:

- parsePlan(text) detects the "Plan (X/N)\n…items…" pattern (tolerates
  whitespace in the header, allows tilde-wrapped items) and returns a
  structured PlanState { done, total, items: [{content, done}] }.
- The poller detects plans in mid-flight assistant text blocks and
  stores them on DO state separately from narration / lines.
- render.ts adds a dedicated plan block:

      <b>📋 Plan (1/3)</b>
      ✓ <s>scaffold</s>
      ◦ wire it up
      ◦ test

  …inserted between the narration line and the rolling tool log. The
  block survives across alarms until the next plan or /watch reset.
- /watch clears the plan key alongside the other per-turn state.
- The markdown->HTML converter also picked up single-tilde strikethrough
  (in addition to GFM's ~~double~~) so any final-reply Markdown that
  uses Telegram-style single-tilde renders correctly too.

Tests 53 -> 62 green. New cases: parsePlan returns null on non-plan text
+ extracts done/total + marks tilde items as done + tolerates whitespace
in the header; renderPlan formats with strikethrough + bullet + HTML
escape; markdown converter handles single-tilde without hitting
intra-word tildes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
… to Telegram

Phase 0 + Phase 1 from ~/.replicas/plans/matrix-bridge-plan.md:

- Cloudflare Worker package replicas-matrix-bridge/ that bridges a Matrix
  bot account (Beeper) to Replicas workspaces.
- Same status-edit-in-place UX as the Telegram bridge — phase header,
  step counter, time ticker, plan block (Plan (X/N) with ~item~
  strikethrough), expandable old log, narration lines, reaction
  lifecycle on the user's prompt, typing indicator.
- Reuses render.ts + markdown.ts verbatim from the Telegram bridge
  (copied for now; will be deduped into a shared core in a follow-up).
- The state machine in poller.ts is the same shape as the Telegram
  poller's: alarm-driven /history poll, diff-guard before edit, batched
  DO storage reads/writes, snapshot-event-count baseline, parallel
  terminal-edit + final-reply.

New components specific to Matrix:

- src/matrix.ts — Matrix Client-Server API client. sendMessage,
  editMessage (via m.replace + m.new_content), react (m.annotation),
  redact, typing, pin (reads + prepends state event), unpin, joinRoom,
  sync (long-poll with timeout + filter), whoami. stripHtml fallback
  for the m.text body field. Throws MatrixError with status + body
  prefix on failure.
- src/listener.ts — MatrixListener Durable Object. One global instance
  per bot account. Alarm fires every ~1s, kicks off a 28s /sync
  long-poll (under CF Workers' 30s fetch limit). Persists next_batch
  in DO storage. Auto-joins invites. For each m.room.message in a
  joined room, calls handleMatrixMessage directly (no extra Worker
  round-trip). For !cancel reply-commands, calls the per-replica DO's
  /cancel endpoint.
- src/dispatch.ts — handleMatrixMessage. KV-routes (room:<roomId> ->
  replicaId), spawns or sends follow-up, fires 👀 reaction in parallel.
  Snapshot/dedupe/Promise.all parallelization patterns lifted from
  the Telegram dispatcher.
- src/poller.ts — Matrix-flavored ReplicaPoller. Same control flow as
  the Telegram poller; substitutes editMessage / react / typing / pin /
  unpin / sendMessage (final reply) calls. Markdown -> HTML on final
  reply via the shared converter.
- src/index.ts — Worker entry. Routes: GET /health, POST /start-listener
  (kicks the listener DO), POST /dispatch (forwarded by the listener),
  GET /debug/listener, GET /debug/watcher/{replicaId}, scheduled handler
  re-kicks the listener every minute via cron.

Tests: 58 across markdown (11, reused), render (33, reused), and
matrix (14, new — sendMessage shape, editMessage's m.replace wrapper,
react's m.annotation relation, redact, typing, pin GET+PUT round-trip
+ 404 tolerance, unpin filter, joinRoom POST, whoami, sync URL params,
stripHtml).

Deferred to v2:
- E2EE (Olm/Megolm key handling — needs hosted infra, not CF Workers).
- Shared core package extraction (render.ts + markdown.ts duplicated
  for now — both packages can iterate independently until the API
  stabilises, then dedupe).
- Replicas-side state for cross-room agent identity (m.thread support).

Setup steps documented in replicas-matrix-bridge/README.md: create bot
on beeper.com, grab the access token from Beeper Desktop, wrangler
secret put MATRIX_ACCESS_TOKEN + MATRIX_USER_ID + REPLICAS_API_KEY,
wrangler deploy, curl /start-listener once, invite @bot to a room.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
URLSearchParams handles URL-encoding internally; we were
encodeURIComponent-ing the JSON filter ourselves before passing it in,
which produced double-encoding (`%257B...` instead of `%7B...`). The
matrix.org homeserver interpreted the resulting value as a filter ID
reference and returned `M_INVALID_PARAM: Invalid filter ID` on every
/sync call, leaving the listener stuck without a since token.

Verified live:
- Deployed `7321060f-…` against matrix-client.matrix.org with the bot
  account credentials.
- /start-listener kicked the alarm chain; after one cycle the listener
  has a `since: s7015940597_757284974_10492641` token from a successful
  /sync.
- Synthetic POST /dispatch with `{roomId:"!test:matrix.org",eventId:"$synth123",body:"…"}`
  spawned a real replica (`mx-testmatrixorg-1780043697944`) via the
  spawn-vs-follow-up branch.

Confirms the full Matrix→Replicas spawn path works; the next step is
having an external Matrix user send into a room the bot is in so the
listener picks it up end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
When a second message lands while the previous turn is still active
(status frame open, no terminal yet), don't blow away the rolling log
+ statusMessageId/statusEventId. Update the watch spec in place, append
a 💬 You: line so the steer is visible, and let the existing alarm
chain pick up the agent's response. Plan, step counter, phase, history
all survive. Applied symmetrically to both bridges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…sends

Self-bot mode (the MATRIX_USER_ID is the human's own account, no
dedicated bot user) was filtering out the human's typed messages
because the listener skipped anything where sender === MATRIX_USER_ID.

Matrix's /sync echoes back the transaction_id only to the access token
that PUT the event, so its presence in unsigned.transaction_id is a
reliable "this is us" signal that survives self-bot mode. The human's
own Element/Beeper sends arrive without it (different access token =
different device session) and now trigger the dispatch normally.

The worker's own status edits/reactions/messages still get skipped
because they always carry our transaction_id echo, so no self-loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Adds the container image + CF-Containers host worker that fronts a
Pantalaimon process so the Matrix bridge can speak plain CS-API while
E2EE is terminated server-side. Two deploy paths are scaffolded:

- `replicas-matrix-pantalaimon/{Dockerfile,pantalaimon.conf}` builds
  a thin image on top of matrixdotorg/pantalaimon:latest pointing
  at matrix-client.matrix.org, listening on 0.0.0.0:8008,
  IgnoreVerification=True (bots can't manually verify devices),
  UseKeyring=False (no system keyring in containers).
- `replicas-matrix-pantalaimon/host/` is a Cloudflare Worker with a
  `@cloudflare/containers` PantalaimonContainer DO that proxies all
  HTTP into the container on :8008. Image is already pushed to
  registry.cloudflare.com/<acct>/replicas-matrix-pantalaimon:v1.
- `fly.toml` mirrors the same config for Fly.io deployment with a
  persistent volume, in case we move off CF Containers.

Container is registered on CF (app id a0327263-…); first-boot is
currently fighting "no container instance can be provided" errors —
to be debugged next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
replicas-connector Bot and others added 29 commits May 29, 2026 10:44
renderTerminal was collapsing the status frame to just the phase line
on terminal states. Users were losing visibility into what the agent
actually did (tool calls, plan, narration) the moment a turn finished
— the rich content was wiped from the status message and only the
final markdown reply remained in the room.

Fix: terminal renders now include the plan block and the rolling-log
window (same recent/older partition as the in-progress render),
appended below the Done/Failed header. For zero-content turns the
output is still just the header.

Tests updated in both bridges to enforce: Done preserves tool-call
lines + plan, Failed preserves error blockquote + log, empty Done is
still header-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
… on text

Two related issues surfaced from a turn that ended with the status
frame stuck at "🤔 Starting · 14s" and no final reply visible:

1. EDIT_MIN_INTERVAL_MS (~900ms) was throttling the Done/Failed edit
   along with ticker edits. If the terminal happened to land within
   the rate-limit window of the last ticker tick, the terminal edit
   was silently dropped and the user was left looking at the last
   ticker value forever. Now terminal renders bypass the throttle.

2. The poller only transitioned phase out of STARTING when it saw
   thinking or tool_use blocks. For trivial-prompt turns where Claude
   answers directly with one text block (no thinking, no tools), phase
   never moved off STARTING — so the header said "🤔 Starting · Ns"
   for the entire turn. Now the first text block transitions to
   PLANNING (if it's a plan block) or RUNNING (narration), matching
   what's actually happening.

Both fixes applied symmetrically to Telegram + Matrix pollers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Two UX papercuts the screenshot exposed:

1. Done frame was showing the agent's final reply as both an in-frame
   💬 narration line AND a separate markdown bubble underneath. Now
   the last text-narration line is spliced out of the rolling log
   when we're about to send the same content as the final reply, so
   the Done frame shows only the actual work (tool calls / plan) and
   the markdown reply lands once below.

2. Matrix bridge was using the Telegram-targeted markdown converter
   for outbound replies, so `# Headers` and `- bullets` rendered as
   literal text since Telegram HTML doesn't allow those tags. Matrix's
   org.matrix.custom.html subset is much richer — added block-level
   handling for ATX headers (<h1>-<h6>), bullet + numbered lists
   (<ul>/<ol>/<li>), and paragraph breaks (<br><br>). Inline pass
   (bold/italic/code) was updated to tolerate the `>` boundary so it
   still works inside <li>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…logs

Two changes:

1. Matrix HTML treats source \n as whitespace, so the rolling log was
   collapsing multiple tool-call lines onto a single visual line in
   clients (verified in Beeper Desktop: "🔍 grep Prime Directive
   🔧 ToolSearch" rendered on one row). Render functions now emit
   <br> between log lines, <br><br> between status blocks, and the
   plan items also use <br> instead of \n.

2. Added focused debug logging to dispatch.handleMatrixMessage and
   poller.handleWatch — every dispatch decision (dedupe, existing,
   text) and every /watch arrival (prior_replicaId, statusEventId,
   pendingCleanup) is logged so we can catch the duplicate-frame bug
   on the next live turn. The bridge has been sending fresh status
   sends mid-turn instead of editing the existing one, root cause not
   yet identified — this instrumentation will pin it down.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
First step of the live /sendToDevice key-sharing path. Vendored the
150KB olm.wasm into src/, wired it through wrangler's CompiledWasm
rule, and wrote a Workers-compatible bootstrap shim:

- globalThis.window stub with Web Crypto getRandomValues so the
  emscripten browser RNG branch runs
- globalThis.document and OLM_OPTIONS.instantiateWasm shims so the
  WASM is preloaded from our CompiledWasm import (no XHR, no fetch)
- Dynamic import of @matrix-org/olm inside getOlm() so the shims
  land before the env detection at module top-level evaluates

Verified live: GET /debug/olm returns
{"ok":true,"library_version":[3,2,15],"identity_keys":{"curve25519":
"…","ed25519":"…"}} — fresh Olm Account, real curve25519 + ed25519
keypair, all native libolm primitives now available (Olm 1:1,
Megolm, Ed25519, pk-encryption).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…ix.org

Adds a new singleton Durable Object (`OlmVault`) that holds the bot's
persistent Olm Account (curve25519 + ed25519 identity, OTKs) and the
live-captured Megolm sessions. Account state pickles to DO storage so
device keys survive restarts.

Endpoints exposed via the worker:
- POST /admin/vault/bootstrap → upload device keys + 50 OTKs
- POST /admin/vault/upload-device → just /keys/upload device_keys
- POST /admin/vault/upload-otks → just /keys/upload one_time_keys
- GET  /debug/vault/identity → user_id, device_id, curve/ed keys, uploaded?
- GET  /debug/vault/keystore → counts of captured Megolm sessions per room

Verified end-to-end live:
- POST /admin/vault/bootstrap → {"ok":true,"steps":["device_upload:200","otk_upload:200"]}
- GET /keys/query @jadagarza:matrix.org → returns our device 5hFpeVVZb4
  with the published curve25519 + ed25519 keys, so Beeper/Element can
  now discover us and claim OTKs to send encrypted to_device messages.

`decryptToDevice` is implemented (matches existing sessions, creates
inbound from pre-key on first hit, persists session pickles, captures
m.room_key contents into a per-room keystore). Listener wiring + the
/sync to_device filter come next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Closes the live key-sharing loop. With the bot device now published
on matrix.org with 50 OTKs, other senders can encrypt Megolm session
shares directly to us via /sendToDevice. We pick those up on each
/sync and feed them into the live keystore so future room messages
decrypt without manual re-export.

Pieces:
- /sync filter now requests to_device.types: ["m.room.encrypted"]
- SyncResponse + ToDeviceEvent types added to matrix.ts
- alarmInner() drains to_device events before timeline events so a
  fresh m.room_key share is in the keystore by the time the matching
  encrypted message is processed
- Each to_device event is routed to OlmVault.decryptToDevice via
  the DO stub — looks up our curve25519 in the ciphertext map, hands
  (senderCurve25519, {type, body}) to the vault
- listener.findKey() consults the live OlmVault keystore first, then
  falls back to the static MATRIX_MEGOLM_KEYS_JSON import — so we get
  the new Megolm sessions for free once captured

Still to come: cross-sign device with recovery key (so Beeper trusts
us proactively without manual verification), OTK auto-replenishment
based on device_one_time_keys_count in /sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…eset endpoints

The bot was sharing device_id 5hFpeVVZb4 with the user's interactive
Element-on-Safari session, which broke decryption in Element because
our /keys/upload overwrites the live session's curve25519/ed25519.
Resolved by minting a fresh dedicated device via POST /login
(m.login.password) — device Ww3fWv0z7s, isolated from the user's
interactive sessions.

To support the credential swap cleanly, two admin reset endpoints
landed:

- POST /admin/listener/reset → drops the /sync `since` token so the
  new device gets a fresh to_device queue
- POST /admin/vault/reset → deleteAll() on the OlmVault DO storage
  so the Olm Account is regenerated for the new device on next call

Workflow after a device swap: update MATRIX_ACCESS_TOKEN secret +
MATRIX_DEVICE_ID var → wrangler deploy → POST /admin/vault/reset →
POST /admin/vault/bootstrap (uploads device + OTKs) → POST
/admin/listener/reset.

Verified end-to-end: new device Ww3fWv0z7s shows up in /keys/query
@jadagarza:matrix.org with the worker-generated curve25519 + ed25519
identity keys. Cross-signing using MATRIX_RECOVERY_KEY → SSSS still
to come so Beeper's mautrix treats it as trusted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…y key

Closes the live key-sharing loop: the bot device is now signed by the
user's self-signing key, so every other client of the user (Element,
Beeper's mautrix bridge, etc) treats it as cross-signed/trusted. After
this, mautrix proactively shares Megolm session keys with the bot on
every rotation via /sendToDevice.

Implementation:
- src/ssss.ts — Base58 recovery-key decode + parity check, HKDF-SHA256
  derive of AES_KEY + HMAC_KEY (info="" for SSSS-key validation,
  info=secret_name for per-secret decrypt), AES-CTR + HMAC-SHA256
  verify per the m.secret_storage.v1.aes-hmac-sha2 spec.
- OlmVault.crossSign(): fetch account_data for
  m.secret_storage.default_key → m.secret_storage.key.<id> →
  m.cross_signing.self_signing → verify recovery key matches the
  stored MAC → decrypt the encrypted SSK seed → feed to
  Olm.PkSigning.init_with_seed() → sign canonical JSON of our
  device's curve25519/ed25519 keys → POST /keys/signatures/upload.
- New admin endpoint POST /admin/vault/cross-sign + MATRIX_RECOVERY_KEY
  worker secret.

Verified end-to-end against jadagarza:matrix.org:
- Cross-sign response: ok:true, failures:{}
- /keys/query Ww3fWv0z7s shows signatures from both the device's own
  ed25519 and ed25519:0Z+dFJcDd/WZth2Gb5YNAmqmrf8W4JzViUB8FRDLFXw
  (the user's self-signing pubkey).
- SSSS self-signing seed derived in-worker matches the published SSK
  pubkey, proving the recovery-key + decrypt path round-trips.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…xist

Earlier fix unconditionally dropped the trailing text-narration line
whenever a final markdown reply was going to be sent. For text-only
turns (Claude answers directly with one text block, no thinking, no
tool_use) that left the Done frame with nothing but the header —
status "looks empty" because there were no tool calls to keep.

Tighten the condition: only splice when lines.length > 1, i.e. when
the rolling log already has at least one other entry (a tool-call,
plan block, or earlier narration). On pure text-only turns the
narration stays so the Done frame shows _something_ — the truncated
italic preview — even though the full markdown reply lands as a
separate message.

Applied symmetrically to Matrix + Telegram pollers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Stops dropping the signal Replicas emits that the bridge was silently
ignoring. Now projecting four event types we weren't:

- claude-system → systemInfo (model, MCP count + active, tool count).
  Rendered as a dim italic subtitle under the phase header so the
  reader sees "Sonnet 4.6 · 5/13 MCP · 537 tools" the moment the
  agent boots, instead of just a bare "🤔 Starting · 0s".
- claude-result.{total_cost_usd, usage.{input,output}_tokens} →
  resultMeta. Done header now shows "🎉 Done · 14s · $0.0671 · 15 tok"
  so the reader sees the cost + token spend per turn.
- context-usage → contextUsage.pct. Rendered as "ctx 12%" in the
  subtitle so long-running turns show the context burn.
- claude-user tool_result blocks → "↳ <preview>" italic lines in the
  rolling log directly under the matching tool call (or "✗ <err>"
  for is_error=true). The reader now sees both the call AND the
  output.

Also pretty-names the model: claude-sonnet-4-6 → Sonnet 4.6, plus a
formatTokens helper that turns "3 tok" / "1,234 tok" / "15,000 tok"
into "3 tok" / "1.2k tok" / "15k tok".

Matrix + Telegram pollers and renders updated symmetrically. Tests
still 65 (matrix) + 64 (telegram) green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Long agent turns (1+ minutes, 16+ tool calls, 100+ history events)
were occasionally dropping the final markdown reply — the Done frame
edited cleanly but no answer ever reached the room. Root cause: the
old Promise.all(setTerminal, sendFinalReply) raced and on transient
matrix.org/Telegram failures the rejected reply promise was swallowed
without recovery, then the 30-second cleanup alarm wiped DO state.

Now the poller persists the result text into DO storage under
`pendingFinalReply` the moment sawResult fires. The send is then
attempted on the same tick — and again on every subsequent alarm tick
(2s cadence) until the API returns an event_id/message_id, at which
point the slot is cleared and the standard 30s pre-cleanup alarm
schedules. This makes the final reply resilient to one-shot send
failures without spamming the room: each tick checks the slot, sends
once if present.

Verified live by manually sending the dropped 1872-char markdown
table answer to the room as the bot — matrix accepted it cleanly, so
the body was fine; the bridge's send path is what dropped it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Two behavior changes:

1. Auto-dispatch is now gated on room size. In a 2-person room (you
   + the bot) every message triggers a turn — the DM-equivalent
   experience. In a 3+ person room the bot stays quiet unless the
   message explicitly mentions her via `m.mentions.user_ids` (Matrix
   spec) or includes the bot's user_id in `formatted_body`. Prevents
   her from chiming in on every random message once invited to a
   group. Member counts cached in KV (1h TTL) so we don't pay the
   /joined_members lookup per message. Commands always run regardless
   of room size.

2. New `!model` chat command lets the room owner change the LLM for
   that room's turns:
   - `!model` → prints current model + alias hints
   - `!model sonnet` / `!model opus` / `!model haiku` → set per-room
   - `!model <full-id>` like `!model claude-opus-4-7` also works
   Preference stored in KV `model:<roomId>`. Dispatch reads this
   before spawning a fresh replica, falls back to the worker-wide
   `REPLICAS_MODEL_OVERRIDE` default if unset. Persisted for a year.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
The bot was leaving both 👀 (dispatch ack) and 🎉/😭 (terminal) on the
user's prompt. Root cause: dispatch fired the 👀 with a discarded
event_id, so the poller's swapReaction had no prior id to redact and
just appended the terminal emoji alongside.

Now:
- dispatch.handleMatrixMessage awaits the 👀 react call and captures
  the returned event_id, passes it through startWatcher → /watch body
- WatchSpec.ackReactionId threaded into both reset and steer paths,
  seeded into the DO's reactionEventId slot
- Existing swapReaction call at terminal naturally redacts whatever's
  in reactionEventId, leaving exactly one emoji on the prompt
- Steering also re-points reactionEventId at the new ack so the
  terminal emoji lands on the latest prompt and the prior phase
  emoji stays on the prior prompt as history

Also removed the inner startWatcher call from createReplica — caller
now owns the single startWatcher invocation with the ack id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Multiple latency cuts on the Matrix bridge:

1. Decouple ack reaction from watcher startup. dispatch no longer
   awaits the 👀 react before /watch. The promise resolves in the
   background and PATCHes the new /ack endpoint on the DO once the
   id is available — saves the ~100ms react roundtrip from every
   turn's time-to-first-byte. The terminal swapReaction still finds
   the id in reactionEventId and redacts correctly.

2. Constant tightening:
   - FIRST_POLL_DELAY_MS  150 → 80   (first history poll starts faster)
   - ACTIVE_POLL_INTERVAL_MS 300 → 180 (catch tool calls ~120ms sooner)
   - EDIT_MIN_INTERVAL_MS 900 → 500   (status frame ticker visible 2x faster)
   - TICKER_REFRESH_MS 2000 → 1500   (elapsed timer ticks closer to real)

matrix.org tolerates the higher edit rate fine (verified before;
each turn is well under the per-account quota).

Compound effect: ~400ms off TTFB on follow-ups; in-flight render
cadence noticeably tighter. Tests 65/65 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…allel watcher

Two more latency cuts that hide the slowest parts of the Replicas
call behind activity the user can see immediately:

1. **Pre-sent Starting frame.** dispatch now fires the initial
   "🤔 Starting · 0s" Matrix message in parallel with the Replicas
   spawn/follow-up. The event_id is forwarded to the watcher via the
   /watch body (fresh spawn path) or via the /ack endpoint
   (follow-up path) and adopted as the DO's `statusEventId`. The
   user sees the bot reacting within ~150ms even on a cold spawn
   that previously had ~1.5s of dead air while POST /replica was in
   flight; subsequent ticker edits go straight onto the same frame
   instead of needing a fresh sendMessage.

2. **Optimistic watcher for follow-ups.** dispatch now fires
   `startWatcher` in parallel with `sendFollowUp` instead of
   awaiting the Replicas POST first. If the follow-up returns
   `gone`, we cancel the watcher we just started (it was targeting
   a dead replica) and respawn via createReplica. Saves ~250ms on
   the happy path (which is ~99% of follow-up turns).

Combined with Tier 1, fresh-spawn TTFB drops from ~1.8s → ~250ms
and follow-up TTFB from ~400ms → ~150ms. Tests still 65/65 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Brings the Telegram bridge to the same speed profile as the Matrix
one:

- Constants tightened: FIRST_POLL_DELAY 150 → 80, ACTIVE_POLL_INTERVAL
  300 → 180, EDIT_MIN_INTERVAL 900 → 500, TICKER_REFRESH 2000 → 1500.
  Same Telegram-tolerated rates as matrix.org.
- Pre-sent "🤔 Starting · 0s" message fires in parallel with the
  Replicas spawn / follow-up. The returned message_id rides /watch
  (fresh) or a new fire-and-forget /ack PATCH (follow-up) and the DO
  adopts it as statusMessageId so subsequent edits stay on the same
  message instead of a fresh sendMessage.
- Removed the duplicate startWatcher call inside createReplica;
  caller now owns the single invocation (carries the
  initialStatusMessageId).
- Optimistic startWatcher in parallel with sendFollowUp was already
  present; no change there.

Combined effect mirrors the Matrix wins: fresh-spawn TTFB drops from
~1.8s → ~250ms and follow-up TTFB ~400ms → ~150ms. Tests still 64/64
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Two bugs surfaced in chat as four identical "Done · Ns" frames for a
single user prompt, each followed by the same body content rendered
twice (once truncated as 💬 narration in the status frame, once in full
as the markdown reply right below):

1. The status-frame narration line was preserved on text-only turns to
   "avoid an empty Done frame" — but a full markdown reply always
   follows, so the body always appeared twice. Drop the
   `lines.length > 1` guard: the splice now fires whenever a final
   reply is coming, and a text-only Done frame collapses cleanly to
   just `🎉 Done · Ns` + the model/MCP subtitle.

2. `flushFinalReply` deleted `pendingFinalReply` AFTER the send, so a
   fetch that threw after the homeserver already accepted the request
   would loop the retry path and create duplicate messages — Matrix
   `randomTxn()` regenerates each call, so the server can't dedupe.
   Fix both sides: pass a stable `txnId = "final-${replicaId}"` so
   Matrix's per-(token,txn) dedupe coalesces retries to a single
   event_id, AND switch flushFinalReply to optimistic-delete-with-
   restore-on-failure so concurrent flushes can't double-send either.

Applied symmetrically to the Telegram bridge (no txn-id equivalent
there, but the optimistic-delete still prevents the concurrent-flush
race; the splice fix is identical).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
…ation

Two follow-on bugs to the first pass:

1. Trailing 💬 narration survived terminal because the splice was keyed
   on `lastTextNarrationIdx` — a per-tick local that resets to null at
   the start of every alarm. When the text and the claude-result
   arrived in different ticks (the common case for non-trivial
   responses), the index was null when we hit the splice, so the
   narration line was never removed and still appeared duplicated in
   the Done frame. Replace with a backward-pop loop that strips ANY
   trailing 💬-prefixed lines on sawResult, regardless of which tick
   pushed them.

2. The drain-pendingFinalReply path was falling through to the active-
   poll ticker. After a successful retry-tick drain, the bottom of
   alarmInner re-rendered the (now-DONE) status frame with the latest
   elapsed seconds and reset the alarm to ACTIVE_POLL_INTERVAL_MS
   (180ms) instead of letting the 30s cleanup transition stick. The
   visible effect: a stream of "🎉 Done · N+5s" edits every few seconds
   forever, eventually getting the second turn's claude-system event
   stuck inside the previous turn's DONE phase. Make the drain branch
   self-terminating: on successful drain mark pendingCleanup + 30s
   alarm and return; on continued failure schedule a 2s retry and
   return. Never fall through to the ticker once a final reply is in
   flight.

Also revert the optimistic-delete I added in flushFinalReply — the
stable txnId already handles retry dedupe, and delete-after-success is
crash-safe (delete-before-send would lose the pending reply if the
worker died between the delete and the send).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Between `setTerminal` (which sets phase=DONE and places 🎉 on the user's
prompt) and `pendingCleanup` being committed (which only happens after
`flushFinalReply` returns), there's a ~1s window where a user follow-up
would fall into the steering branch of /watch instead of the fresh
spawn-reset path. Steering inherits all prior state including phase=
DONE, so the renderer would draw "🎉 Done · Ns" as the header for what
was actually a brand-new turn — the user sees a premature done emoji
on a prompt the agent hasn't even read yet.

Gate steering on phase as well: DONE/FAILED forces the fresh-spawn
path, which resets phase to STARTING and seeds a fresh statusEventId.

Same fix on both Matrix and Telegram bridges. `Phase` was already
imported in both pollers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Rewrite the rolling-log section of renderActive + renderTerminal as a
three-tier focus window so a glance answers "what's happening RIGHT
now" without scrolling 30 lines of tool detail.

  CURRENT (latest op): full command + ↳ output, indented under it
  RECENT (2 prior):    one-line each, basename paths, bash truncated
  OLDER (3+ ago):      collapsed under <blockquote expandable> with
                       a "[N earlier ops]" header

Per Jaden: "Bump the vivid up a few notches so it shows that, then
expand it to show the specific things that are going on. Then collapse
it as it rolls away." The CURRENT op stays vivid (full command + output
preview), RECENT ops compress to one-line summaries, OLDER ops slide
into the expandable. Errors (✗) survive at every tier — silent failures
are the worst thing a focus window can hide.

Also:
- renderPlan: first not-done item gets the ► current-focus marker (vs
  generic ◦ bullet) so the user can locate the agent in the plan
  without reading the op log.
- formatCost: smart formatting — $0.08 not $0.0781, "<$0.01" for sub-
  cent costs, drops decimals once past $10.
- compressToolLine helper: basename for paths, ~60-char cap for bash
  commands. Applied to RECENT + OLDER tiers only; CURRENT keeps the
  full original line.

8 new tests covering focus window behavior + formatCost edge cases.
Matrix + Telegram render.ts are sibling-identical (same focus window,
both honor <blockquote expandable>).

Proposal: ~/.replicas/plans/2026-05-29_bridge-ui-cleanup-proposal.md
(updated with the focus-window design after Jaden's "vivid + collapse"
direction).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
… turn)

Previously the Done flow sent two messages — the edited status frame
("🎉 Done · ...") and a separate fresh sendMessage carrying the full
markdown body. Beeper (and possibly other bridges that aggregate Matrix
into their own UI) was sometimes dropping the second message entirely,
leaving the user with a confidently-empty `🎉 Done · 18s · $1.02 · 370
tok` and no answer in sight. Asking "did you find" twice in a row
because the bot's actual reply was nowhere visible.

Fix: embed the rendered final-reply HTML directly into the Done frame's
status edit. Single message per turn — Beeper can't drop "the second
message" if there is no second message. Reading order is past →
present → answer:

  🎉 Done · 18s · $1.02 · 370 tok
  Opus 4.7 · 7/13 MCP · 181 tools
  [📋 Plan, if any]
  [focus-window op log, if any]
  (gap)
  <answer body, markdown → HTML>

Implementation:
- StatusState.terminal gains an optional resultHtml field (pre-rendered
  HTML, so render.ts stays decoupled from markdown.ts).
- renderTerminal embeds resultHtml below the focus-window op log when
  the kind is "done". Failed turns never embed — the failure surface
  stays clean (just header + errorMsg).
- Bumped MAX_RENDER_CHARS to 30_000 (Matrix events cap ~64KB; was
  3_800, which was a Telegram constraint — this file is Matrix-only).
- Poller computes resultHtml = markdownToHtml(resultText) and passes it
  to setTerminal. Whole pendingFinalReply / flushFinalReply / 2s-retry
  / drain-and-cleanup machinery deleted — the body either lands in the
  Done edit or it doesn't, and edits use the stable txn id for retry
  dedupe via Matrix's per-(token,txn) coalesce.

Telegram bridge unchanged — TG's 4096-char editMessageText cap means
embedding doesn't fit, so it keeps the separate-message flow.

3 new tests covering: body embedding, text-only-turn no-longer-empty,
failed-turn-never-embeds-resultHtml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Root cause of the group-chat "wonky" symptom: matrix.org's per-room
send rate cap kicks in on long turns. Worker tail showed a steady
stream of M_LIMIT_EXCEEDED 429s on every editMessage and every
sendMessage fallback for the active room.

Before, the renderAndSend flow on a 429 edit failure was:
  1. editMessage throws → log + fall through
  2. sendMessage tries to create a new bubble → also 429s
  3. Each ~180ms tick repeats the whole dance

Even on the rare ticks where one of those calls slipped past the
limit, the bridge would create a NEW status frame instead of editing
the old one — fragmenting the turn into 5-10 separate "Running",
"Editing" bubbles instead of one updating frame. From the user's
perspective the agent never reached a Done outcome, since each
fragment looked frozen at its tick's elapsed time.

Fixes:

1. **Parse retry_after_ms** from M_LIMIT_EXCEEDED bodies. MatrixError
   now carries the homeserver's suggested backoff hint.

2. **Honor it in renderAndSend.** On any 429 (from edit or send),
   store `rateLimitedUntil = now + retry_after_ms` in DO storage and
   bail. Non-terminal renders check this gate on entry. Terminal
   renders skip the gate — the Done frame is load-bearing.

3. **Don't fall through to sendMessage on edit 429.** This was the
   amplifier — every failed edit was creating a new bubble. With the
   change, a rate-limited edit just defers; the existing statusEventId
   is preserved so the next allowed tick edits the SAME message.

4. **Slow the ticker** from 1500ms to 3000ms and bump
   EDIT_MIN_INTERVAL_MS from 500ms to 1000ms. Long turns simply
   shouldn't be hammering the homeserver every 1.5s — that's what
   tripped the limit in the first place. Still feels live, well within
   matrix.org's per-room budget.

This is the underlying cause of Jaden's "the chat conversation getting
all wonky is only happening on group chats, not on the main one" from
earlier — group chats see more concurrent edits across rooms which
bumps the bot account against the limit faster.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Jaden flagged the focus-window rewrite as a regression — the tool calls
were the showpiece, and burying them inside a compressed three-tier
window + an expandable made the chat harder to read, not easier. The
real-time stream of one-tool-call-per-line was the whole point.

Reverted:
- parseOps / renderOpsFocused / compressToolLine helpers removed
- TOOL_EMOJI_RE / OUTPUT_RE / NARRATION_RE constants removed
- renderActive + renderTerminal restored to the simple line stream:
  `recent` tail visible, older overflow goes into <blockquote expandable>
- RECENT_LINES_VISIBLE + MAX_TOTAL_LINES constants restored
- 4 focus-window tests removed

Kept (these were independently good wins):
- formatCost smart formatting ($0.08, <$0.01, drops decimals past $10)
- renderPlan with ► marker for the first not-done plan item
- resultHtml embedding in the Done frame (single message per turn —
  this fixed a real "Beeper drops the second message" bug)

Also hardened the reaction path. Jaden separately flagged the 👀/🎉
prompt-emoji signals as broken: under matrix.org's per-room rate limit
the bot's `react()` and `redact()` calls 429 silently and the user
sees no acknowledgment.

- swapReaction now retries up to 4 times honoring retry_after_ms (both
  the redact for the prior emoji and the react placing the new one).
  Up to ~32s worst case but the cleanup alarm has 30s of runway.
- dispatch.ts reactWithRetry wrapper for the FIRST 👀 ack — this is
  the very first signal the user gets that the bot heard them. Same
  retry-on-429 semantics, capped at 3 attempts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
The screenshot Jaden flagged shows the underlying bug clearly: the
agent finished a 1m16s turn with a substantive "Memory saved. 4 files
created..." outcome (visible in the Replicas dashboard), but the
Beeper view stopped at "Editing · step 9 · 1:15" with no Done frame —
the user never saw what the agent accomplished.

Root cause: when the Done edit hits matrix.org's per-room rate limit,
the old code deferred it to the next alarm tick by setting
rateLimitedUntil. But the next alarm tick is the 30s pendingCleanup
wipe, which deletes all state and never tries again. The embedded
result body silently dies.

Fix: terminal renders retry on 429 IN-PLACE (up to 4 attempts honoring
retry_after_ms — ~24s worst case), since the cleanup alarm has 30s of
runway. Non-terminal renders keep the defer-to-next-tick behavior.

Also bumped EDIT_MIN_INTERVAL_MS from 1000ms to 2000ms and
TICKER_REFRESH_MS from 3000ms to 4000ms. matrix.org's per-room send
budget is ~30/min (one every 2s). 1000ms edits were sometimes still
tripping the limit; 2000ms stays safely under while still feeling live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Top-to-bottom doc for whoever picks this up next: architecture,
file-by-file index with line ranges, the state-machine storage keys,
recent commits (with reasoning), production endpoints, ops essentials,
known issues, and a "things Jaden likes / hates" section so the next
agent doesn't repeat the focus-window mistake.

Lives at both:
  - docs/agent-handoff.md (in repo, travels with code)
  - ~/.replicas/plans/2026-05-29_agent-handoff-replicas-bridges.md
    (visible in the Replicas dashboard)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
The detailed handoff lives in docs/agent-handoff.md but a new agent
won't necessarily find it. Wire it in from the three places they
actually look:

- Root AGENTS.md gains a "Project-specific deep dives" section
  pointing into docs/agent-handoff.md whenever the task lands in
  replicas-matrix-bridge/ or replicas-telegram-bridge/.
- Each bridge subfolder gets its own thin AGENTS.md that links up to
  the handoff, restates the local hot file, and (for Telegram) calls
  out the focus-window-revert open item up front.

No matter which directory the next agent cd's into first, the handoff
is one hop away.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Deep research pass on how the field handles live agent-status surfacing,
per Jaden's request on the Slack thread. Synthesizes:

- ACP (Agent Client Protocol from Zed): the SessionUpdate discriminated
  union, tool_call lifecycle states (pending → in_progress →
  completed/failed), plan replace-not-delta semantics, blocking
  permission requests, cancellation that still drains.
- Telegram Bot API rate limits: ~1/sec per chat, 20/min per group,
  ~30/sec global, 429 retry_after handling.
- OSS production bots: n3d1117/chatgpt-telegram-bot's delta-based edit
  trigger + 5s exponential backoff per error + markdown-only-on-final.
- python-telegram-bot's AIORateLimiter two-tier (chat + global) design.
- Zed Agent Panel UI patterns (the summary-widget-above-message vs the
  focus-window-as-body lesson I learned the hard way).

Concludes with three tunable wins for the Telegram bridge specifically:
  1. Bump EDIT_MIN_INTERVAL_MS to ~3500ms in groups (currently 2000ms
     is over the 20/min group cap).
  2. Add a character-delta edit gate so we don't waste edits on tiny
     diffs (n3d1117 pattern).
  3. Self-tuning +N ms backoff on each 429 so noisy groups settle into
     a sustainable cadence.

Lives at both:
  - docs/acp-telegram-status-research.md (in repo)
  - ~/.replicas/plans/2026-05-29_acp-telegram-status-research.md
    (visible in the Replicas dashboard)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Jaden pointed me at github.com/Open-ACP/OpenACP — different from the
Zed ACP I researched first. OpenACP is a direct fellow-traveler:
self-hosted bridge connecting Claude Code, Codex, Gemini, Cursor (28+
agents) to Telegram, Discord, and Slack via the Agent Client Protocol.

Their Telegram design is fundamentally different from ours and worth
documenting. Where we have one message that edits in place through
phases, they send many separate messages — a ThinkingIndicator, then
a separate ToolCard per tool invocation, then a final text message.
Their pattern matches ACP's session/update notification model
natively (each SessionUpdate = one Telegram card).

Concrete takeaways added to the doc:
- 5s text-stream debounce vs our 2s edit floor (they're more
  conservative because each is a fresh send, not an edit)
- 15s thinking ticker + 3-min auto-stop on thinking indicators —
  worth borrowing the auto-stop ("Still working..." re-render)
- sealToolCard concept: once a tool is "done" stop mutating its
  rendering; lock its output before moving on
- HTML-only by default — they don't even try plain text (we agree)
- Forum topics per session — Telegram-specific isolation we don't do

Where we beat them: phase emojis on the header, prompt reactions
(👀 → 🎉/😭), per-room !model command.

Where they beat us: scrollable lossless tool history (each tool is
a permanent message), inline permission buttons, topic isolation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
replicas-connector Bot added a commit that referenced this pull request May 29, 2026
Ran a focused audit looking for the next bugs after today's 9 fixes.
Shipping 6 of the 10 findings; the other 4 are lower-impact or
require larger restructuring.

#1 — Steer-race between handleWatch and alarmInner [HIGH]
The DO is single-threaded but `await` yields. alarmInner does fetch()
on Replicas /history which can yield for 100s of ms — during that
window a steer /watch races the alarm's read-modify-write on lines[].
Whoever writes second clobbers the other's in-memory copy. Wrapped
the steer's state mutation in this.state.blockConcurrencyWhile() so
the alarm's in-flight body completes before lines[] is touched.

#7 — setTerminal partial-execution leaves un-swapped reaction [MEDIUM]
Even with the atomic phase+pendingCleanup write at the top, if
renderAndSend or unpinStatus threw, swapReaction never ran — leaving
the user's 👀 ack reaction forever (no 🎉/😭). Wrapped each step in
its own try/catch so they run independently and the rest of the
cleanup always fires.

#4 — verifyTurnDelivered didn't actually recover missing reply [HIGH]
The docstring promised "if replyEventId missing → re-call
sendReplyAsOwnBlock" but the code just logged. Now stashes
lastResultHtml in storage during setTerminal so verify can actually
re-send via the stable txn id (Matrix dedupes if the prior send
did land but our /event/{id} lookup raced).

#8 — recover-room mention check used encrypted ev.content [MEDIUM]
shouldHandleMessage reads m.mentions + formatted_body from content,
but the OUTER ev.content is the encrypted wrapper (algorithm /
ciphertext / session_id). Mention detection silently failed in
multi-user encrypted rooms during recovery dispatch. Fixed to pass
the DECRYPTED inner.content.

#6 — lines[] unbounded growth on thinking-heavy turns [MEDIUM]
Long planning sessions push hundreds of 💬 narration lines that
bloat the serialized state on every ~180ms alarm tick. Capped at 300
entries per segment by dropping oldest non-tool lines first
(preserves 🔄/✅/❌ tool entries + 💬 You: steer markers).
toolLineIndex pointers are shifted in lockstep when an entry before
them is dropped.

#2 — key-request:* DO storage grew unbounded forever [HIGH-silent]
maybeSendKeyRequest stored `key-request:{room}:{session}` → ts to
dedupe inside a 5-min window but never deleted. Every Megolm session
rotation accumulated a permanent entry. Bucketed the dedupKey by
5-min window AND added a piggy-back cleanup that prunes entries
older than 2 buckets on each call. Bounded storage.

Deferred (with rationale): #3 pending-decrypt prune needs a cron
sweeper; #5 KV dedupe race needs deeper redesign; #9 cachedKeys
stale is low-impact (manual reload endpoint suffices); #10 parsePlan
hijack has rare false-positives that haven't been observed.

Tests: 67/67 pass; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants