- TypeScript 67.9%
- JavaScript 24.5%
- Shell 7.6%
currentStreamTempId resets to null after each text_end, so on turns with multiple text segments (tool calls interleaved with narration), turn_end's guard passed and resent the whole thing as a duplicate after the last segment. Track streamedAnyThisTurn (reset on turn_start) instead. Verified with a real multi-tool-call prompt: reproduced the exact scenario and confirmed turn_end is now correctly skipped on every segment. |
||
|---|---|---|
| src | ||
| .env.example | ||
| .gitignore | ||
| build.mjs | ||
| install.sh | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
matrix-to-omp
Control omp (oh-my-pi) remotely from Element / any Matrix client, by DMing a bot account.
It's an omp extension (not a standalone process): it auto-loads inside
your normal interactive omp session, injects your Matrix messages into the
live session as if you'd typed them, and streams the assistant's replies back
to the room — including edited/streamed text and any images tool output
produces. There's no separate daemon: it only runs while omp is running.
How it works
dist/index.js— small extension loaded by omp onsession_start. Talks to a child process over stdio.src/worker.js— a plain Node.js process that owns the actual Matrix client (matrix-bot-sdk, with E2EE via the Matrix Rust crypto bindings). It runs under real Node rather than omp's bundled Bun runtime because the SDK's legacy HTTP/crypto stack isn't Bun-compatible.
Element <-- Matrix (E2EE) --> worker.js <-- stdio JSON --> dist/index.js <-- extension API --> omp session
One-time setup
1. Create a bot user on your homeserver
Any way you normally create accounts on your homeserver works. Give it a strong, unique password — you'll only use it once, to mint an access token.
2. Get the bot's access token
curl -s -XPOST https://YOUR_HOMESERVER/_matrix/client/v3/login \
-H "Content-Type: application/json" \
-d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"BOT_USERNAME"},"password":"BOT_PASSWORD"}' \
| jq -r .access_token
Treat the resulting syt_... token like a password — anyone with it can act
as the bot. It's what goes in .env, never the account password itself.
3. Install
curl -fsSL https://git.pandem.fr/outage.sh/matrix-to-omp/raw/branch/main/install.sh | bash
or manually:
git clone https://git.pandem.fr/outage.sh/matrix-to-omp.git ~/.omp/agent/extensions/matrix-bridge
cd ~/.omp/agent/extensions/matrix-bridge
npm install
npm run build
cp .env.example .env
4. Configure .env
Edit ~/.omp/agent/extensions/matrix-bridge/.env:
MATRIX_HOMESERVER_URL=https://YOUR_HOMESERVER
MATRIX_ACCESS_TOKEN=syt_... # from step 2
MATRIX_ALLOWED_USERS=@you:YOUR_HOMESERVER # your OWN Matrix ID — only you
MATRIX_ALLOWED_USERS is the important line: any message from a user on this
list gets executed by omp with tools auto-approved. Never add anyone but
yourself.
5. Invite the bot
From Element, start a DM with the bot's user ID (or invite it to a room — it auto-joins). Encrypted rooms/DMs work out of the box.
Using it
Run omp normally, in whatever project directory you'd normally work in.
Watch for a startup notification: Matrix bridge connected as @bot:....
From Element:
- Send any message — it runs in that
ompsession exactly as if typed at the terminal, and the reply streams back into the room (editing one message in place as it generates, same as watching the TUI). Replies render as real Markdown (bold, code blocks, lists) viamarkdown-it, not raw**syntax**. - Thinking/reasoning shows up as a collapsible "💭 Thinking" block (Matrix
<details><summary>) above the actual reply, tap to expand. Some providers don't expose a separate reasoning channel and instead inline<think>...</think>directly in the raw text stream — the bridge detects and splits that out itself (splitThinking()inmatrix-bridge.ts) so you never see raw<think>/</think>tags leak into a message. While a<think>block is still open mid-stream, the live-updating message just shows "💭 Thinking…" instead of the partial raw reasoning text. - Send an image — it's downloaded and attached to your next message via omp's
@fileattachment syntax, same asomp @image.png "what's this?". /model— list every available model, grouped by provider, marking the current one./model <name>— switch the session's model (fuzzy match, same as--model). Every switch is verified by reading the session's actual current model back after callingsetModeland reporting that, not just echoing what you typed — omp's extension API silently returnsfalse(no error thrown) if you pass it a plain string instead of a resolved model object, so a naive implementation would report success on every failed switch. The bridge resolves your text to a model object first (ctx.models.resolve(...)) and passes that object tosetModel./thinking— show the current thinking level and what the active model supports./thinking <level>— set thinking level (off,minimal,low,medium,high,xhigh,max,auto). Invalid level names are rejected before being sent to omp — passing garbage straight through silently corrupts the session's thinking-level state (getThinkingLevel()returnsundefinedafterward with no error), so the bridge validates against the known enum first./help— lists the above in the room, since Element's own slash-command autocomplete only ever shows Element's built-in commands — bots can't add entries to that menu.
Changing the working directory (/cwd)
A live interactive omp session's working directory is fixed to wherever the
terminal launched it — omp's extension API has no way to change it at
runtime (same underlying reason /new/newSession isn't reachable from a
background extension: that control only exists on the terminal's own
command-dispatch context, not on anything an extension gets).
Instead of faking that, /cwd gives you a real, separate mechanism:
/cwd <path>— from then on, messages you send are routed to a separate, headlessompsession (omp -p --mode=json --cwd=<path>, using--resumeacross turns to keep it a real conversation) rooted at<path>, instead of your live terminal. Streaming replies work the same way for this delegate session as for the live one.~expands to your home directory; the path must already exist./cwd(no args) — shows whether you're on the live session or a delegate, and both directories./cwd live— switch back to your live terminal session./new— while a delegate session is active, starts a fresh chat in that same directory (drops the delegate's--resumeid). Not available on the live session, for the same reason described above.
Delegate sessions use the same provider/model config as your normal omp
usage (no --profile isolation) — the very first version of this used
--profile=matrix-bot, which turned out to create a completely separate,
empty profile with no API keys configured at all, so delegate turns failed
outright. Worth knowing if you ever add a --profile flag back in: verify
omp --profile <name> models actually lists something before relying on it.
Delegate sessions are otherwise regular omp -p invocations — they don't see
your live terminal session's history, and vice versa.
The bridge lives and dies with the omp process — close the terminal, it
disconnects; your next omp reconnects automatically (auto-loads via the
omp.extensions entry in package.json).
Installing on another Mac
Run the one-liner from step 3 above on the new machine, then repeat steps 1--4 for a new bot account (don't reuse the same bot/token across machines — a second Matrix client logging in with the same access token fights over the same device's E2EE session and will throw crypto key mismatch errors).
Prerequisites: Node.js and omp itself already installed and on PATH.
Troubleshooting
M_UNKNOWN: One time key ... already exists — the bot device's local
E2EE key store fell out of sync with the server (usually from two clients
using the same access token, or a client crashing mid-handshake). Fix by
rotating to a fresh device:
cd ~/.omp/agent/extensions/matrix-bridge
source .env
curl -s -XPOST "$MATRIX_HOMESERVER_URL/_matrix/client/v3/logout" \
-H "Authorization: Bearer $MATRIX_ACCESS_TOKEN" -H "Content-Type: application/json" -d '{}'
rm -rf data/crypto data/bot-storage.json
# log in again (step 2), put the new token in .env
M_UNKNOWN_TOKEN: Invalid access token passed — the token in .env has
been invalidated (usually because you (or the crypto-mismatch fix above)
called /logout on it, or it was rotated). Log in again to get a fresh token,
and make sure data/crypto and data/bot-storage.json are cleared before
using it — a new token from /logout+/login is usually a new device, but
if you skip clearing local state and the homeserver ever hands back the same
device ID, you'll immediately hit the one-time-key mismatch above instead.
When in doubt, always pair a fresh token with a wiped data/ directory:
cd ~/.omp/agent/extensions/matrix-bridge
rm -rf data/crypto data/bot-storage.json
# then log in again (step 2) and update .env
Nothing happens when you message the bot — check the omp session's log
file (~/.omp/logs/omp.<date>.<pid>.log) for lines containing matrix-bridge.
Failed to load extension means a build/dependency problem (npm run build
again); silence with no connected notification means .env is missing or
incomplete.
Security notes
.env,data/(local crypto keys/session state), anddist/(build output) are gitignored — never commit them.- The worker auto-approves all tool calls it triggers (required for a
headless bridge). Keep
MATRIX_ALLOWED_USERSto yourself only. - The bot's access token grants full control of the
ompsession it's attached to. Rotate it (logout + fresh login) if you ever suspect it leaked.