No description
  • TypeScript 67.9%
  • JavaScript 24.5%
  • Shell 7.6%
Find a file
outage.sh 8ceccf9089 Fix duplicate messages: turn_end fallback fired again after multi-segment streaming
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.
2026-07-22 18:05:15 +02:00
src Fix duplicate messages: turn_end fallback fired again after multi-segment streaming 2026-07-22 18:05:15 +02:00
.env.example Add extension source, worker, build script, and install script 2026-07-22 16:10:41 +02:00
.gitignore Add extension source, worker, build script, and install script 2026-07-22 16:10:41 +02:00
build.mjs Add extension source, worker, build script, and install script 2026-07-22 16:10:41 +02:00
install.sh Add extension source, worker, build script, and install script 2026-07-22 16:10:41 +02:00
package-lock.json Render replies as Markdown in Element instead of raw syntax 2026-07-22 16:41:48 +02:00
package.json Render replies as Markdown in Element instead of raw syntax 2026-07-22 16:41:48 +02:00
README.md Fix raw <think> tag leakage: split into collapsible Thinking block instead 2026-07-22 18:00:34 +02:00

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 on session_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 omp session 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) via markdown-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() in matrix-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 @file attachment syntax, same as omp @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 calling setModel and reporting that, not just echoing what you typed — omp's extension API silently returns false (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 to setModel.
  • /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() returns undefined afterward 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, headless omp session (omp -p --mode=json --cwd=<path>, using --resume across 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 --resume id). 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), and dist/ (build output) are gitignored — never commit them.
  • The worker auto-approves all tool calls it triggers (required for a headless bridge). Keep MATRIX_ALLOWED_USERS to yourself only.
  • The bot's access token grants full control of the omp session it's attached to. Rotate it (logout + fresh login) if you ever suspect it leaked.