You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Started as two diagnostic endpoints. Surveying joined_rooms vs KV mappings then pulling room timelines surfaced the actual root cause of many group chats are stuck: two silent deleteAll paths in the poller. Now this PR also patches that.
Root cause of "stuck" rooms
ReplicaPoller.alarmInner had two paths that wiped DO state without first landing a terminal frame on the user-facing status pane. Whatever frame had last rendered (e.g. Editing · step 9 · 1:15) stayed pinned forever, looking stuck:
MAX_WATCH_DURATION_MS (30 min) — long-running watches got their DO state wiped while the pane stayed on the last Planning/Editing frame. Fix: lands a Failed frame "Watch timed out — agent ran longer than 30 min." then deletes.
/history 404/410 — when the upstream Replicas-side replica vanished (TTL expiry, manual delete, transient error), the poller silently wiped state. The user saw a frozen pane with no signal to resend. Fix: lands a Failed frame "Replica gone (upstream returned ). Send a new message to start a fresh session." then deletes.
Verified the symptom on three Beeper rooms (Jessica Butler, Travel Manager, Adult Admin) whose last bot-rendered frame was mid-progress from a watch that hit one of these paths.
Diagnostic endpoints (used to find the bug)
GET /debug/joined-rooms — bot's full joined room set, surfaces rooms-not-in-KV.
GET /debug/room-timeline/:roomId?limit=N — recent timeline events for a room.
GET /debug/roomname/:roomId — resolves a Matrix room id to its display name.
POST /admin/watcher/:replicaId/cancel — proxies to the watcher DO's /cancel, terminates the replica and lands a "Cancelled by user" frame. Used today to unstick the runaway Project Nomad Office session.
Test plan
Typecheck clean
bun test src/render.test.ts — 48/48 pass
Deployed (Worker version 63e47db6-55fd-4891-84b3-ace8ccca3de6)
Verified /debug/joined-rooms returns 16 rooms vs 8 in KV → identified the gap
Verified POST /admin/watcher/.../cancel on Project Nomad Office → pendingCleanup set, terminal frame rendered
Currently-stuck panes (3 rooms) will resolve on next user message via the new terminal-frame paths; could backfill with a one-shot cleanup script if desired
…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>
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>
When an m.room.encrypted event arrives, look up an exported Megolm
session key by (room_id, session_id) and decrypt with Web Crypto. The
decrypted inner event is treated identically to an unencrypted
m.room.message, so the rest of the bridge (spawn, follow-up, steering,
cancel) keeps working without changes.
The session keys come from an Element key export — decrypted out of
band and provided as the MATRIX_MEGOLM_KEYS_JSON secret. No WASM
bundle, no sidecar, no Pantalaimon: ~190 lines of Web Crypto wire up
HKDF-SHA256 + AES-256-CBC + HMAC-SHA256 + the libolm ratchet advance
algorithm.
Worked through one false-start: the Megolm spec says "AES" without
specifying mode and my first attempt assumed CTR; libolm in fact uses
CBC with PKCS#7 padding. Validated bit-for-bit against python-olm on
three real ciphertexts from the user's E2EE DM before deploying. Live
test confirmed end-to-end: encrypted message "what tools do you have"
decrypted, replica spawned, agent replied in the room within 17s.
Caveats noted in src/megolm.ts: we currently only have inbound
decrypt + we send outbound messages plaintext (clients flag them as
"unencrypted in encrypted room" but they still post). Bidirectional
E2EE + live /sendToDevice key sharing for new Megolm sessions are the
follow-ups.
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>
…ader
Mirrors the OpenACP UX Jaden shared screenshots of (see
docs/acp-telegram-status-research.md Detailed Deep-Dive § 2 + § 5).
Each tool line now ticks live: 🔄 while running, ✅ on success, ❌ on
error. A "📋 Tools (M/N) ✅" header sits above the rolling log so the
user can see how many tools have completed at a glance.
How it works:
- ContentBlock gains `id` (Anthropic-assigned `toolu_…`) and
`tool_use_id` (the back-pointer carried by tool_result blocks).
- The poller maintains a `toolLineIndex: Record<string, number>` in DO
storage that maps tool_use.id → the row index in `lines` where its
rendered call lives. Persisted in the writes batch; wiped on /watch
fresh-spawn alongside `lines`/`plan` so a new turn starts clean.
- On tool_use projection: line is pushed as `🔄 ${formatToolUseLine}`
and the row index is recorded under the block's id.
- On tool_result projection: the original row's leading `🔄 ` is
mutated in place to `✅ ` (success) or `❌ ` (error). The
toolLineIndex entry is then deleted — one-shot, so a malformed
duplicate result can't keep flipping the row.
- render.ts gains `renderToolsHeader(lines)`: scans for 🔄/✅/❌
prefixes, returns "📋 Tools (M/N)" — with a trailing ✅ when
M === N. Wired into both renderActive and renderTerminal above the
rolling log.
Tests: 5 new for renderToolsHeader covering empty input, mixed
done/running states, all-complete suffix, and ignoring non-tool
lines (narration, outputs, user steers). 48/48 pass.
Telegram bridge gets the same treatment in a follow-up — needs the
focus-window revert first (still on commit 8728c39 per the handoff)
to slot in cleanly. Matrix-only for this PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Layered on PR #14's per-tool lifecycle icons. Single PR bundles all
visibility/awareness improvements from the design discussion.
Shipping:
1. Thinking "Still planning…" header transition at 60s + 3-min hard stop
on the ticker when stuck in STARTING/PLANNING with zero steps.
2. Per-tool elapsed time appended on completion ((1.2s) / 12s / 1:23).
Tracked via toolStartedAt map keyed by tool_use.id.
3. Diff stats on Edit / Write / NotebookEdit from input args
(+12 −3) computed from line-delta of old_string vs new_string.
4. Compact Done for text-only turns — when the turn used no tools and
has no plan, skip the rolling log entirely and render just the
subtitle + thinking placeholder above the agent reply.
6. Stop-reason accent on Done header (max_tokens → ⚠️ cut off,
tool_use → ⏸ stopped at tool_use, pause_turn → ⏸ paused, etc.)
7. RATE_LIMITED phase from claude-rate_limit_event (⏸ Rate-limited),
prior phase stashed and auto-reverted on next claude-assistant.
8. Permission-denials badge in resultMeta (🔐 N blocked).
9. Files-touched footer rendered as deduped basenames with overflow
tail (📂 Files: index.ts, render.ts (+3 more)).
10. Context-usage visual bar in subtitle (📊 ctx ▰▰▰▱▱▱▱▱▱▱ 32%),
bumps to ⚠️ at >=85%.
11. Idle-since indicator on the Thinking line when no new event in 5s
(· idle 8s) — surfaces stalls.
12. Bold Task lines for the Task subagent tool (🧰 Task: description).
14. Cumulative session totals persisted to KV (session:<roomId>) and
rendered when turns > 1 (🧮 session: $0.12 · 8 steps · 2 turns).
Deferrals from the 14-item list:
- #5 (Plan progress checkmarks) — pending TodoWrite ingest design.
- #12 full subagent nesting — only the minimal Task-line bolding lands here.
- #13 (Audio/desktop notification routing) — out of scope for status pane.
Test fixture in render.test.ts updated to use lifecycle-prefixed lines
so the compact-Done detector keeps the rolling log when tools ran.
48/48 tests pass. typecheck clean.
Deployed: Cloudflare Worker version 351dc633-442c-4e9c-8a75-c1e617b4ffec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Two reusable diagnostic endpoints for unsticking and identifying rooms
when a watcher gets pinned in a long-running loop:
- POST /admin/watcher/:replicaId/cancel — proxies to the watcher DO's
internal /cancel, terminates the Replicas-side replica, sets the
status pane to Cancelled by user, and triggers cleanup. Previously
the only way to unstick a runaway poller was waiting for the
30-minute MAX_WATCH_DURATION_MS or flushing storage directly.
- GET /debug/roomname/:roomId — resolves a Matrix room id to its
display name using the bridge's existing MATRIX_ACCESS_TOKEN.
Surfaces which room is which without needing a separate Matrix
client when triaging multi-room reports like "X room isn't
answering" against a flat list of opaque !abc:beeper.com ids.
Used both today to identify and cancel a runaway Project Nomad Office
session that had spent 18 minutes hammering Lark via e2b with empty
result loops.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
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.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Missing Content-Type: application/json header on Matrix bridge Replicas API POST calls
The replicasHeaders() function in replicas-matrix-bridge/src/dispatch.ts:221-226 does not include "Content-Type": "application/json", but it is used as the headers for the POST /replica call (dispatch.ts:176-179) and the POST /replica/{id}/messages call (dispatch.ts:147-151), both of which send JSON.stringify()'d bodies. Without the explicit Content-Type, Cloudflare Workers defaults the header to text/plain;charset=UTF-8 for string bodies, which may cause the Replicas API to misparse or reject the request.
The sibling Telegram bridge correctly includes the header in its equivalent function (replicas-telegram-bridge/src/index.ts:339-344). This inconsistency means the Matrix bridge is sending JSON payloads with the wrong Content-Type for replica creation and follow-up messages.
Suggested change
functionreplicasHeaders(env: Env): HeadersInit{
return{
Authorization: `Bearer ${env.REPLICAS_API_KEY}`,
"Replicas-Org-Id": env.REPLICAS_ORG_ID,
};
}
functionreplicasHeaders(env: Env): HeadersInit{
return{
Authorization: `Bearer ${env.REPLICAS_API_KEY}`,
"Replicas-Org-Id": env.REPLICAS_ORG_ID,
"Content-Type": "application/json",
};
}
Was this helpful? React with 👍 or 👎 to provide feedback.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Missing Content-Type: application/json header on Matrix bridge Replicas API POST calls
The replicasHeaders() function in replicas-matrix-bridge/src/dispatch.ts:221-226 does not include "Content-Type": "application/json", but it is used as the headers for the POST /replica call (dispatch.ts:176-179) and the POST /replica/{id}/messages call (dispatch.ts:147-151), both of which send JSON.stringify()'d bodies. Without the explicit Content-Type, Cloudflare Workers defaults the header to text/plain;charset=UTF-8 for string bodies, which may cause the Replicas API to misparse or reject the request.
The sibling Telegram bridge correctly includes the header in its equivalent function (replicas-telegram-bridge/src/index.ts:339-344). This inconsistency means the Matrix bridge is sending JSON payloads with the wrong Content-Type for replica creation and follow-up messages.
Suggested change
functionreplicasHeaders(env: Env): HeadersInit{
return{
Authorization: `Bearer ${env.REPLICAS_API_KEY}`,
"Replicas-Org-Id": env.REPLICAS_ORG_ID,
};
}
functionreplicasHeaders(env: Env): HeadersInit{
return{
Authorization: `Bearer ${env.REPLICAS_API_KEY}`,
"Replicas-Org-Id": env.REPLICAS_ORG_ID,
"Content-Type": "application/json",
};
}
Was this helpful? React with 👍 or 👎 to provide feedback.
@devin-ai-integration[bot] This repository is not connected to a Replicas organization.
Root cause of "this room is stuck" reports: two paths in
ReplicaPoller.alarmInner ran `state.storage.deleteAll()` without first
updating the user-facing status pane to a terminal frame, leaving it
frozen mid-progress (e.g. "Editing · step 9 · 1:15") indefinitely.
The two paths:
1. MAX_WATCH_DURATION_MS (30 min) — long-running watches got their
DO state wiped while the pane stayed on the last Planning/Editing
frame. Now lands a Failed frame: "Watch timed out — agent ran
longer than 30 min." then deletes.
2. /history 404/410 — when the upstream Replicas-side replica
vanished (TTL expiry, manual delete, transient error), the poller
silently wiped DO state. The user saw a frozen status pane and
had no way to tell that they needed to resend. Now lands a Failed
frame: "Replica gone (upstream returned <status>). Send a new
message to start a fresh session." then deletes.
Identified by surveying joined_rooms vs KV mappings + room timelines:
3 Beeper rooms (Jessica Butler, Travel Manager, Adult Admin) showed
frozen mid-progress panes from prior watches that hit one of these
silent-cleanup paths. With this fix, future occurrences self-resolve
to a clean Failed frame instead of looking stuck forever.
Also adds two diagnostic endpoints used to find these:
- GET /debug/joined-rooms — bot's full joined room set
- GET /debug/room-timeline/:rid — recent timeline events for a room
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
Note: Each repository can only be connected to one Replicas organization.
replicas-connectorBot
changed the title
feat(matrix-bridge): admin watcher cancel + debug room-name endpoints
fix(matrix-bridge): land terminal frame before silent deleteAll + admin/debug endpoints
May 29, 2026
…in-room
Three more diagnostic + recovery endpoints for the same triage workflow:
- GET /debug/invites — pending room invites the bot was sent
but has not yet accepted, with the room
name from invite_state when available.
Surfaces auto-join misses.
- GET /debug/joined-rooms — bot's full joined room set. Compare
against KV `room:` mappings to find
rooms with no watcher.
- POST /admin/join-room/:rid — accepts a pending invite by room id,
when the listener's auto-join loop
silently failed for that room.
Used today to recover the *Jett Butler* room: the listener never joined
despite an `@jadagarza:matrix.org` invite sitting for ~14h, so two
prior user prompts went unprocessed. Hitting /admin/join-room then
/dispatch with the original event id brought it back to life — bot is
now actively responding inside it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Started as two diagnostic endpoints. Surveying
joined_roomsvs KV mappings then pulling room timelines surfaced the actual root cause of many group chats are stuck: two silentdeleteAllpaths in the poller. Now this PR also patches that.Root cause of "stuck" rooms
ReplicaPoller.alarmInnerhad two paths that wiped DO state without first landing a terminal frame on the user-facing status pane. Whatever frame had last rendered (e.g. Editing · step 9 · 1:15) stayed pinned forever, looking stuck:MAX_WATCH_DURATION_MS (30 min) — long-running watches got their DO state wiped while the pane stayed on the last Planning/Editing frame. Fix: lands a Failed frame "Watch timed out — agent ran longer than 30 min." then deletes.
/history404/410 — when the upstream Replicas-side replica vanished (TTL expiry, manual delete, transient error), the poller silently wiped state. The user saw a frozen pane with no signal to resend. Fix: lands a Failed frame "Replica gone (upstream returned ). Send a new message to start a fresh session." then deletes.Verified the symptom on three Beeper rooms (Jessica Butler, Travel Manager, Adult Admin) whose last bot-rendered frame was mid-progress from a watch that hit one of these paths.
Diagnostic endpoints (used to find the bug)
GET /debug/joined-rooms— bot's full joined room set, surfaces rooms-not-in-KV.GET /debug/room-timeline/:roomId?limit=N— recent timeline events for a room.GET /debug/roomname/:roomId— resolves a Matrix room id to its display name.POST /admin/watcher/:replicaId/cancel— proxies to the watcher DO's/cancel, terminates the replica and lands a "Cancelled by user" frame. Used today to unstick the runaway Project Nomad Office session.Test plan
bun test src/render.test.ts— 48/48 pass63e47db6-55fd-4891-84b3-ace8ccca3de6)/debug/joined-roomsreturns 16 rooms vs 8 in KV → identified the gapPOST /admin/watcher/.../cancelon Project Nomad Office → pendingCleanup set, terminal frame renderedWorkspace · Slack Thread