Compare commits

14 Commits

Author SHA1 Message Date
Keysat 8fe56cd2eb Mark capture keyword parsing deployed and verified 2026-06-16 21:36:06 -05:00
Keysat 87bb3c95ec Handoff: trim capture Current-state bullet, note keyword parsing pending Update 2026-06-16 16:38:49 -05:00
Keysat 0786286cde Capture: parse leading type keyword; record master-deploy + Element lessons
capture-note.sh reads an optional leading bug:/feature:/chore:/idea: keyword
as the inbox type (default idea, always P2). AGENTS.md: capture mode marked
live; two durable lessons — the Update button deploys origin/master so commits
must land there, and Element intercepts /capture (the thread is the trigger).
2026-06-16 16:10:07 -05:00
Keysat 2bae2f3571 Add capture mode: /capture + per-room capture threads → cross-project inbox
A capture-thread message (or /capture <text> in any room) logs to standards/
INBOX.md via capture-note.sh — deterministic, no claude call — and confirms
in-thread; /triage stays the gate into each repo (D13). Thread roots seeded by
seed-capture-threads.py.
2026-06-16 14:59:38 -05:00
Keysat 4204b82c6b Prune Current state to a lean post-Phase-3 snapshot 2026-06-15 23:21:32 -05:00
Keysat 28c974fe1d Mark Phase 3 (Spark Control) done; trim spec to live command contract
Shipped in Spark Control v0.21.0: status badge + Update/Restart/Stop-Start/Logs
tile. All three exit criteria confirmed. matrix-bridge needed no code change.

- AGENTS.md: Current state + ROADMAP Phase 3 -> DONE; Deploy switched scp -> git
  pull (Update button); D10 stamped; new Infra fact for the Spark->Gitea path and
  the load-bearing IdentitiesOnly ssh-config pin the Update button depends on.
- spark-control-integration.md: trimmed from dev spec to live contract (dropped
  sudo -iu fallback and dev-side scaffolding; folded in direct-as-modelo, the
  Gitea key gotcha, restart cadence, and the LAN-only HTTP API).
- README: dropped stale "pre-Phase 0" status; Setup reframed for a fresh install.

Deferred follow-up: badge reflects container liveness only, not Matrix
connectivity; HEALTHCHECK + {{.State.Health.Status}} is the matrix-bridge-side fix.
2026-06-15 23:19:30 -05:00
Keysat 843582ec03 Tidy AGENTS.md for handoff: lean Current state, Phase 3 deploy forward-pointer 2026-06-15 22:55:25 -05:00
Keysat ff0dada0d5 Refresh Current-state repo line after committing the Phase 3 spec 2026-06-15 20:56:47 -05:00
Keysat e5a751d4f4 Add Phase 3 Spark Control integration spec; mark Phase 2 done
docs/spark-control-integration.md: the SSH command contract (status via
docker inspect; restart via docker restart; update via git fetch + reset
--hard origin/master + docker compose up -d --build) plus the one-time
conversion of the Spark's ~/matrix-bridge to a Gitea clone. No bot code
change. Update source = git-pull-from-Gitea; rides Spark Control's existing
SSH into spark-32d0 (no new key). Corrected the infra note: Spark is on the
LAN with the Start9/Gitea host, so Spark->Gitea resolves directly.
2026-06-15 20:48:18 -05:00
Keysat ee8408d182 Handoff: consolidate infra facts into a reference section, prune Current state to a lean snapshot
Lift the load-bearing connectivity/identity/env facts out of the Phase 0/1 narrative
into a stable "Infra facts" section, rewrite Current state as a ~15-line snapshot, and
correct the Core flow diagram (gui-launch.sh / ask-claude.sh, not launch-claude.sh
directly). No operational context dropped — verified by a fresh-eyes doc review.
2026-06-15 20:09:21 -05:00
Keysat 8ad1cd8465 Add headless "ask" mode: ?-prefixed message runs claude -p, answer posted back
A message starting with `?` in a mapped room runs `claude -p` one-shot in that
repo on the Mac and posts the full answer back into the room — Matrix as a
request/response interface, not just a trigger. Non-`?` messages keep launching
interactive sessions as before.

New scripts/ask-claude.sh is a login-shell wrapper (so ~/.zprofile puts claude on
PATH) that exports CLAUDE_CODE_OAUTH_TOKEN from the Mac's .env and runs
`claude -p "$prompt" < /dev/null`, printing the answer to stdout. The bot adds a
`?`-dispatch with run_ask/ask: SSH stdout captured, 300s timeout, fail-loud, output
chunked under Matrix's event cap (no truncation).

Headless claude -p needs the long-lived token because a non-GUI SSH session can't
reach the login Keychain (reports "Not logged in") — the deliberate Approach A that
the interactive GUI-Terminal path (D11) avoided. Token is kept Mac-side only; the
Spark never runs claude. Sovereignty unchanged: claude -p uses the subscription, no
frontier API touches message payloads.

Proven live on the Spark; fresh-eyes reviewed before commit.
2026-06-15 19:50:36 -05:00
Keysat a7529eb0b7 Containerize Phase 1 bot: Docker deployment on the Spark
Add Dockerfile, docker-compose.yml, docker-entrypoint.sh, and .dockerignore
so the bot runs detached and survives reboots, replacing the foreground venv run.

The image is generic (no secrets/deployment specifics baked in): host networking
reaches both Synapse and the Mac; .env, config.toml, and the SSH key are mounted
read-only. The entrypoint is the container's environment seam (D4 analog of
launch-claude.sh) — it generates ~/.ssh/config for the mac-bridge alias from
config.toml [mac] (new hostname/user fields) so the bot's `ssh mac-bridge` stays
unchanged. SSH key mounted not baked; first connect uses accept-new host trust.

Proven live on the Spark: container connects to Synapse and real messages launched
drivable sessions on the phone across 2 rooms via the full chain.
2026-06-15 18:40:05 -05:00
Keysat 7a39fec229 Update docs: Phase 1 bot status, run/deploy commands, headless-ask roadmap
- AGENTS.md: Commands now has the bot run/deploy (venv + scp from Mac); Layout lists
  src/bot.py, gui-launch.sh, requirements.txt, .env.example; Current state refreshed to
  Phase 1 (sub-steps 1-3 proven on the Spark; next = containerize).
- ROADMAP.md: log headless "ask" mode (claude -p -> output back into the room).
2026-06-15 14:52:34 -05:00
Keysat 76d8a001b1 Add Phase 1 matrix-nio bot (listener + launch + fail-loud)
- src/bot.py: log in as the bot user with the stored token, prime past history,
  and on a new message in a mapped room run `ssh -> gui-launch.sh` (built with
  shlex.quote). The all-projects room fans out to every repo, each session named
  "<repo> - <date>". Launch failures are reported back into the room.
- scripts/gui-launch.sh: propagate MB_SESSION_NAME into the launched session.
- requirements.txt: matrix-nio.

Connectivity (sub-step 1) verified on the Mac; launch (sub-step 2/3) to be tested
on the Spark, where the SSH alias resolves.
2026-06-15 14:34:15 -05:00
16 changed files with 951 additions and 74 deletions
+21
View File
@@ -0,0 +1,21 @@
# Keep the build context minimal and the image generic/secret-free.
# .env, config.toml, and the SSH key arrive via read-only mounts at runtime — never baked in.
.env
.env.*
!.env.example
config.toml
.git
.venv/
venv/
__pycache__/
*.py[cod]
*.egg-info/
# Mac-side launch scripts run on the Mac, not in this container.
scripts/
# Docs / OS cruft — not needed in the image.
*.md
.claude/
.DS_Store
+6
View File
@@ -8,3 +8,9 @@ MATRIX_ACCESS_TOKEN=
# Optional — kept for recovery / re-minting a token. The bot authenticates with the access token, # Optional — kept for recovery / re-minting a token. The bot authenticates with the access token,
# not the password (logging in every start would spawn a new device each time). # not the password (logging in every start would spawn a new device each time).
MATRIX_PASSWORD= MATRIX_PASSWORD=
# Headless "ask" mode (the `?`-prefix path). Used MAC-SIDE by scripts/ask-claude.sh, NOT by the
# bot — a non-GUI SSH session can't reach the login Keychain, so `claude -p` needs this token to
# authenticate. Mint once on the Mac: `claude setup-token` (requires a Claude subscription), then
# paste the value here. Lives on the Mac; the Spark never runs claude, so it needs no copy.
CLAUDE_CODE_OAUTH_TOKEN=
+137 -60
View File
@@ -16,14 +16,17 @@ phone to drive interactively. Single user, private home network, no multi-user/p
Matrix message in a project room Matrix message in a project room
→ bot (matrix-nio, on the DGX Spark) receives it → bot (matrix-nio, on the DGX Spark) receives it
→ looks up which repo that room maps to (explicit config — no classification) → looks up which repo that room maps to (explicit config — no classification)
→ SSHes to the Mac and runs scripts/launch-claude.sh with (repo_dir, message_text) → SSHes to the Mac and runs scripts/gui-launch.sh → launch-claude.sh (repo_dir, message_text)
→ wrapper cd's into the repo and launches `claude` with the message as the prompt → wrapper cd's into the repo, opens a desktop Terminal, and launches `claude` on the message
→ Claude Code Remote Control (auto-enabled) pushes a notification to the phone → Claude Code Remote Control (auto-enabled) pushes a notification to the phone
→ tap in and drive the session from the Claude app → tap in and drive the session from the Claude app
``` ```
Room determines the repo; the message text becomes the initial prompt. That is the entire Room determines the repo; the message text becomes the initial prompt — the v1 trigger surface.
v1 decision surface. *Variants:* a `?`-prefixed message instead runs `ask-claude.sh` (headless `claude -p`) and posts
the full answer back into the room (ask mode, D12). A message in a room's **capture thread** (or a
`/capture <text>` message in any room) is logged to the cross-project inbox instead of launching —
deterministic, no Claude call (capture mode, D13).
## Stack ## Stack
@@ -51,19 +54,66 @@ v1 decision surface.
- `scripts/launch-claude.sh <repo_dir> <prompt>` — the Mac wrapper (Phase 0 deliverable; - `scripts/launch-claude.sh <repo_dir> <prompt>` — the Mac wrapper (Phase 0 deliverable;
validate by hand before any bot code). validate by hand before any bot code).
- _TODO (Phase 1+):_ bot build/run (`docker build` / `docker compose up` on the Spark) once - **Bot (Phase 1), containerized on the Spark — preferred:** from `~/matrix-bridge`,
`src/` exists. `docker compose up -d --build` (host networking, `restart: unless-stopped` so it survives
reboots; read-only mounts of `.env`/`config.toml`/SSH key). Logs: `docker compose logs -f`.
The entrypoint generates `~/.ssh/config` for the `mac-bridge` alias from `config.toml [mac]`
(`hostname`/`user`), so the alias resolves inside the container. Override the host key path with
`MB_SSH_KEY_HOST` if it isn't `/home/modelo/.ssh/id_ed25519`.
- **Bot — venv (dev/fallback):** `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`,
then `.venv/bin/python src/bot.py` — uses modelo's host `~/.ssh/config` for the alias.
`MB_SSH_ALIAS` overrides the SSH target for testing.
- **Seed capture threads:** `python3 scripts/seed-capture-threads.py` (reads `.env` + `config.toml`,
needs only Python stdlib; run anywhere the homeserver is reachable). Posts each room's capture-thread
root and prints the `capture_thread` event IDs to paste into `config.toml`. Skips rooms already set;
pass labels or `--force` to reseed, `--dry-run` to preview.
- **Deploy:** the Spark's `~/matrix-bridge` is a Gitea clone tracking `master`, so deploy =
`git fetch origin && git reset --hard origin/master && docker compose up -d --build` (run as
`modelo` from `~/matrix-bridge`). You normally don't run this by hand — the **Update** button on
the Spark Control dashboard (Phase 3) runs exactly this and streams the output: push to Gitea,
then click Update. **Commit to `master`, not a side branch** — Update pulls `origin/master`, so a
commit only on another branch deploys *stale* code with no error (cost a debugging round on
2026-06-16: capture mode was pushed to `phase-1` while Update kept pulling the old `master`).
Also: `config.toml` is **gitignored**, so Update does *not* carry config changes — refresh it on
the Spark separately (`scp mac-bridge:…/config.toml ~/matrix-bridge/config.toml`) before Update. *(Fallback if Gitea is ever unreachable: scp the files from the Mac —
`scp mac-bridge:/Users/macpro/Projects/matrix-bridge/{Dockerfile,docker-compose.yml,docker-entrypoint.sh,requirements.txt,config.toml,.env} .`
and `scp -r mac-bridge:/Users/macpro/Projects/matrix-bridge/src .`, then rebuild.)*
## Layout ## Layout
- `AGENTS.md` — this file (canonical; `CLAUDE.md` is a relative symlink to it). - `AGENTS.md` — this file (canonical; `CLAUDE.md` is a relative symlink to it).
- `ROADMAP.md` — Phases 14+ with falsifiable exits, plus deferred/future directions. - `ROADMAP.md` — Phases 14+ with falsifiable exits, plus deferred/future directions.
- `README.md` — human-facing intro. - `README.md` — human-facing intro.
- `docs/spark-control-integration.md` — the live Phase 3 command contract: the SSH commands
(status / restart / git-pull update / logs) behind the Spark Control tile, plus the now-done
one-time conversion of the Spark's `~/matrix-bridge` to a Gitea clone. matrix-bridge needs no
code change. (Shipped in Spark Control v0.21.0; see Current state.)
- `scripts/launch-claude.sh` — the Mac-side launch wrapper (the only seam that knows the - `scripts/launch-claude.sh` — the Mac-side launch wrapper (the only seam that knows the
Mac's environment). Mac's environment).
- `config.example.toml` — room→repo mapping template; the real `config.toml` is gitignored. - `config.example.toml` — room→repo mapping template; the real `config.toml` is gitignored.
- `scripts/gui-launch.sh` — opens the desktop Terminal via `osascript` (Approach B, D11); calls
`launch-claude.sh` inside it. The bot invokes this over SSH.
- `scripts/ask-claude.sh` — headless `?`-ask wrapper (`#!/bin/zsh -l`): runs `claude -p` in the repo
and prints the answer to stdout for the bot to capture and post back. Uses `CLAUDE_CODE_OAUTH_TOKEN`
(Mac-side `.env`) because a non-GUI SSH session can't reach the login Keychain (D12).
- `scripts/capture-note.sh` — capture wrapper (`#!/bin/zsh -l`): appends one `/capture`-format line
to `~/Projects/standards/INBOX.md`, commits, best-effort pushes, and echoes the line back.
Deterministic — no `claude`, no token, no frontier call (D13).
- `scripts/seed-capture-threads.py` — one-time (re-runnable) helper that posts each room's
capture-thread root message and prints the resulting `capture_thread` event IDs to paste into
`config.toml`. Skips rooms already configured; run after adding a project.
- `src/bot.py` — the matrix-nio bot (Phase 1): listens in mapped rooms; a plain message runs
`ssh mac-bridge gui-launch.sh` (interactive, to the phone), a `?`-prefixed message runs
`ask-claude.sh` (headless, answer posted back), and a `/capture`/capture-thread message runs
`capture-note.sh` (logs to the inbox, confirms in-thread); fans out for all-projects; reports
failures back.
- `requirements.txt` (matrix-nio) · `.env.example` (credential schema; real `.env` gitignored).
- `.claude/` — Claude wiring (dir only for now). - `.claude/` — Claude wiring (dir only for now).
- _Future:_ `src/` (the matrix-nio bot), `Dockerfile`, dependency manifest — Phase 1. - `Dockerfile` · `docker-compose.yml` · `docker-entrypoint.sh` · `.dockerignore` — the Phase 1
container (Spark). Generic image (no secrets/deployment specifics baked in); host networking;
read-only mounts of `.env`/`config.toml`/SSH key. The entrypoint generates `~/.ssh/config` for
the `mac-bridge` alias from `config.toml [mac]` — the container's environment seam (D4 analog
of `launch-claude.sh`).
## Decisions (already made — don't relitigate without new information) ## Decisions (already made — don't relitigate without new information)
@@ -96,8 +146,11 @@ Condensed from the scoping workshop. Each: the call, why, what it beat.
- **D9 — E2EE deferred (documented tradeoff).** Single-user bot over WireGuard on a private - **D9 — E2EE deferred (documented tradeoff).** Single-user bot over WireGuard on a private
LAN; transport is already private and matrix-nio E2EE adds libolm overhead. *Revisit when:* LAN; transport is already private and matrix-nio E2EE adds libolm overhead. *Revisit when:*
the bot ever handles sensitive content over untrusted transport. the bot ever handles sensitive content over untrusted transport.
- **D10 — Spark Control manages the bot (Phase 3).** Status on the dashboard + one-click - **D10 — Spark Control manages the bot (Phase 3, DONE 2026-06-16).** Status badge + Update /
update/restart, the same SSH-behind-buttons pattern Spark Control uses for the Sparks today. Restart / Stop-Start / Logs buttons on the dashboard, the same SSH-behind-buttons pattern Spark
Control uses for the Sparks. Shipped in Spark Control v0.21.0; connects directly as `modelo` (no
`sudo` wrap — this Spark has no passwordless sudo, so the spec's different-user branch never
applies). Badge reflects container liveness, not Matrix connectivity (see Current state / spec).
- **D11 — Launch into a desktop Terminal, not a headless token (Phase 0).** The SSH session - **D11 — Launch into a desktop Terminal, not a headless token (Phase 0).** The SSH session
can't reach the GUI login Keychain, so a plain `ssh … claude` reports "Not logged in." Rather can't reach the GUI login Keychain, so a plain `ssh … claude` reports "Not logged in." Rather
than mint a long-lived `claude setup-token`, the launcher (`scripts/gui-launch.sh`) uses than mint a long-lived `claude setup-token`, the launcher (`scripts/gui-launch.sh`) uses
@@ -106,6 +159,28 @@ Condensed from the scoping workshop. Each: the call, why, what it beat.
and is fully unattended, but adds a credential to manage; kept as the documented fallback if the and is fully unattended, but adds a credential to manage; kept as the documented fallback if the
Mac is ever driven headless (logged out). *Cost:* requires the Mac logged in + a one-time Mac is ever driven headless (logged out). *Cost:* requires the Mac logged in + a one-time
Terminal Automation grant. Terminal Automation grant.
- **D12 — Headless "ask" mode uses the long-lived token; interactive stays GUI-Terminal (2026-06-16).**
A `?`-prefixed message runs `claude -p` headlessly over plain SSH and posts the answer back, so its
stdout must be captured over the SSH pipe — which rules out the GUI-Terminal path (D11), and a
non-GUI session reports "Not logged in." Ask mode therefore deliberately adopts the long-lived
`claude setup-token` (`CLAUDE_CODE_OAUTH_TOKEN`) that D11 deferred — kept **Mac-side only** (in
`.env`; the Spark never runs claude). Interactive launches keep the token-free GUI-Terminal path.
*Sovereignty unchanged:* `claude -p` uses the subscription, no frontier API touches message payloads.
- **D13 — Capture mode → central inbox + `/triage` gate, via a deterministic script (2026-06-16).**
A message in a room's **capture thread** (detected by its `m.relates_to` thread root, configured
per room as `capture_thread`), or a `/capture <text>` message in any room, is logged to
`~/Projects/standards/INBOX.md` tagged for that room's project — then the existing `/triage`
lands it in the repo. *Beat (deliberately rejected):* writing straight into a repo's
`AGENTS.md`/`ROADMAP.md` unattended — keeps the human approval gate, and the Current-state-vs-
ROADMAP call, where they belong (and AGENTS.md is load-bearing — "propose, don't silently
rewrite"). *Beat:* `claude -p /capture` for the write — a one-line append needs no model, so
`capture-note.sh` does it deterministically: no token, nothing leaves the Mac but the git push,
and message text never reaches a frontier model (upholds the sovereignty constraint / D8). The
bot confirms in-thread with the exact inbox line. The item type comes from an optional leading
keyword the user types (`bug:` / `feature:` / `chore:` / …; default `idea`, always `P2`). Thread
roots are minted by `seed-capture-threads.py`. *In practice the thread is the only good trigger:*
Element intercepts any `/`-prefixed message as a client command, so the `/capture <text>` fallback
needs a "Send as message" / `//capture` dance — fine as a code path, not the daily UX (2026-06-16).
## Sovereignty constraint ## Sovereignty constraint
@@ -131,56 +206,58 @@ Substance threshold **N = 3** real uses, defined per phase in `ROADMAP.md`. "Don
falsifiable, scaled substance (it worked 3 real times), never a checkbox. A phase that "works falsifiable, scaled substance (it worked 3 real times), never a checkbox. A phase that "works
once" is not done. once" is not done.
## Infra facts (proven — stable reference)
- **WireGuard (`starttunnel`) for Mac↔Spark:** Mac `10.59.211.5`; Spark (`spark-32d0`, user `modelo`)
`10.59.211.6`. The Mac↔Spark seam runs over WireGuard (not the Mac's LAN subnet). The Spark *is*
on the LAN, same as the Start9 host (`immense-voyage`) — so Spark→Gitea (`immense-voyage.local:59916`)
resolves and works directly.
- **Spark → Mac:** SSH alias `mac-bridge` → the Mac as user `macpro`, dedicated key
(`~/.ssh/id_ed25519` on the Spark, in the Mac's `authorized_keys`). The Spark host's `~/.ssh/config` needs `IdentitiesOnly yes` because a
`Host *` rule shadows the default key; the container regenerates a clean config from `config.toml [mac]`.
- **Spark → Gitea (deploy/update path):** `~/matrix-bridge` is a git clone tracking `origin/master`
(`ssh://git@immense-voyage.local:59916/grant/matrix-bridge.git`). modelo's `~/.ssh/config` pins the
deploy key for the Gitea host with `IdentitiesOnly yes` — without it git offered the wrong key first
and Gitea returned `Permission denied (publickey)`. **The Spark Control Update button depends on that
ssh-config block; flag it if modelo's account is ever rebuilt.**
- **Mac → Spark:** no authorized key — direct Mac-initiated Spark ops stay owner-run. (This is *not*
what Phase 3 closes: Spark Control already has its own SSH channel into `spark-32d0`, so its
status/update/restart buttons ride that, not a Mac→Spark key.)
- **Matrix:** homeserver `https://matrix.gilliam.ai` (StartOS Synapse), bot `@agent:matrix.gilliam.ai`,
device `matrix-bridge-bot`. The bot reuses the stored access token (`.env`) — never re-logs in
(avoids device churn). No E2EE (D9); bot↔Synapse is clearnet TLS, softening D9's WireGuard-only rationale.
- **Mac env:** `claude` lives in `~/.local/bin`, on PATH only via `~/.zprofile` — so every wrapper is
`#!/bin/zsh -l` (a non-login SSH shell loads neither `.zprofile` nor `.zshrc`).
- **Interactive-launch prereqs:** Mac logged into its desktop + a one-time Terminal Automation grant
(TCC). If the grant resets, a launch stalls — the bot reports it fail-loud rather than hanging.
- **Folder-trust gate:** the first `claude` run in a repo it has never been opened in stalls on the
trust prompt; already-used repos are trusted. Affects unattended interactive launches and ask mode.
## Current state ## Current state
- **Scaffolded 2026-06-15** from a prior scoping package (SPEC/DECISIONS/CLAUDE/KICKOFF), - **Live on the Spark; Phases 03 + ask mode all DONE.** matrix-nio bot in a Docker container
folded into this AGENTS.md (decisions + placement), `ROADMAP.md` (phases), and the wrapper + (`~/matrix-bridge`, a Gitea clone tracking `master`): host networking, `restart: unless-stopped`,
config skeleton. No bot code yet — by design. read-only mounts of `.env`/`config.toml`/SSH key. Runs as `@agent` in 11 project rooms + an
- **Phase 0 — SSH leg proven (2026-06-15).** Mac Remote Login is on. The Spark `spark-32d0` all-projects fan-out room. Interactive (plain msg → phone) and ask (`?`-prefix → answer posted
(user `modelo`) reaches the Mac over `starttunnel`/WireGuard at `10.59.211.5`*not* the back; D12) both proven at N=3; capture (D13) is live (see below). Phase 2: owner-confirmed routing.
LAN (the Spark isn't on the Mac's LAN subnet). A dedicated per-machine key - **Phase 3 (Spark Control) shipped 2026-06-16 in v0.21.0:** status badge + Update / Restart /
(`spark-control@spark-32d0` = `~/.ssh/id_ed25519` on the Spark) is in the Mac's Stop-Start / Logs tile; the Spark's dir is now a Gitea clone and deploy = the Update button.
`authorized_keys`. SSH alias **`mac-bridge`** in the Spark's `~/.ssh/config` selects that key Detail in ROADMAP + `docs/spark-control-integration.md`; no matrix-bridge code change.
(`IdentityFile ~/.ssh/id_ed25519` + `IdentitiesOnly yes`) — required because the pre-existing - **Capture mode (D13) LIVE 2026-06-16 — proven on 1 room, N=3 pending.** Per-room capture threads
`Host * → id_ed25519_shared` rule otherwise shadows the default key. The bot's entire Mac hop `standards/INBOX.md` via `capture-note.sh`, confirmed in-thread; all 11 rooms + all-projects
is therefore `ssh mac-bridge '<command>'`. *Phase 1:* bake the dedicated key + an equivalent have seeded `capture_thread` roots (IDs in the gitignored `config.toml`). Keyword type parsing
alias/config into the bot's Docker image (modelo's `~/.ssh/config` won't exist in the (commit `0786286`) is **deployed and verified** (Spark updated + restarted 2026-06-16) — leading
container). `bug:`/`feature:`/`chore:`/… keywords now set the inbox item type. How it works + the Element
- **Phase 0 — launch chain proven end-to-end (2026-06-15).** `ssh mac-bridge → gui-launch.sh `/`-interception caveat: D13.
→ launch-claude.sh → authenticated claude → phone via Remote Control` works against a real - **Optional / triggered next moves:**
repo (`premier-gunner`). Chose **Approach B (desktop Terminal)** over a headless token — see - Badge reflects container liveness only, not Synapse connectivity — add a Docker `HEALTHCHECK`
**D11**. Two pieces it took: (1) `~/.local/bin` (where `claude` lives) had to be added to (bot-side liveness signal → read `{{.State.Health.Status}}`) when "running but silent" bites.
`~/.zprofile`, because a non-interactive login shell skips `.zshrc`; (2) `scripts/gui-launch.sh` - A `?`-ask in a repo `claude` has never opened may stall on the folder-trust gate — add a trust
opens a Terminal.app window via `osascript` so `claude` runs inside the GUI session (login flag to `ask-claude.sh` if/when hit, not preemptively.
Keychain + real TTY) — needed a one-time "Allow ssh to control Terminal" Automation grant. - Capture priority is always `P2`; add a priority keyword/token to `capture-note.sh` if setting
*Known caveats for the bot:* (a) a never-trusted repo stalls at Claude's first-run folder-trust it at `/triage` gets tedious. Old `phase-0` branch still exists — delete if it bothers you.
gate — unattended launches must target already-trusted repos or pass a skip flag; (b) if the - Phase 4+ (intent-routing brain D8, thread continuity) — see ROADMAP; not scoped.
TCC Automation grant ever resets, a launch stalls until someone clicks Allow — the bot should - **Watch:** the Update button depends on modelo's Gitea ssh-config pin (`IdentitiesOnly yes`, see
detect a failed launch and report it back to the room, not hang. Infra facts) — flag it if that account is ever rebuilt.
- **Phase 0 — Matrix bot user live (2026-06-15).** Homeserver is the StartOS Synapse exposed on - **Repo:** single branch `master` (the vestigial `phase-1` was deleted 2026-06-16; capture mode was
**clearnet at `https://matrix.gilliam.ai`** (`server_name` = `matrix.gilliam.ai`, Synapse briefly stranded on it — see Deploy). Clean, pushed to Gitea. No test suite (pre-existing).
1.154.0) — *not* the stale `@gilliam:<onion>` account found in Element. Created a dedicated
non-admin bot **`@agent:matrix.gilliam.ai`** (type `bot`) via the Synapse Admin Dashboard
(StartOS "Create Bot User" is appservice-only/greyed out). Minted a long-lived access token
(fixed `device_id` `matrix-bridge-bot`), verified via `whoami`, and stored
homeserver/user/token/device_id (+ password for recovery) in the gitignored **`.env`** (chmod
600). `config.toml` holds homeserver+user; `.env.example` documents the schema. Bot reuses the
stored token — never re-login per start (avoids device churn); no E2EE (D9). *Note:* the
bot↔Synapse hop is now public-internet TLS, which softens D9's "transport already WireGuard-
private" rationale (still TLS to the user's own server, single-user content) — revisit if it matters.
- **Phase 0 — rooms mapped (2026-06-15).** 9 project rooms in `config.toml` (premier-gunner,
recap, recap-relay, spark-control, ten31-transcripts, ten31-signal-engine, keysat, proof-of-work,
ten31-database), each `room_id → /Users/macpro/Projects/<repo>`. `@agent` is **joined to all 9**
(via its token), so the Phase-1 bot will see messages in each. *Manual by-hand launches must keep
message text free of `'`/`"`* — the typed SSH command line breaks on them (PS2 `>` hang); the
Phase-1 bot avoids this via `shlex.quote`.
- **Phase 0 — PROVEN / DONE (2026-06-15).** N=3 by-hand runs succeeded across multiple rooms
(recap, spark-control, premier-gunner): each opened a Terminal in the right repo, started `claude`
on the message, and pushed a drivable session to the phone. The deterministic core holds.
Added session naming: `launch-claude.sh` now runs `claude -n "<repo> - <topic>"` (topic from the
message, overridable via `$MB_SESSION_NAME`) so Remote Control's phone index is readable —
confirmed `-n` drives the phone app's conversation label.
- **Next: Phase 1 — the matrix-nio bot.** Container on the Spark, logged in as `@agent` (token in
`.env`), listening in the 9 mapped rooms; on a message it runs `ssh mac-bridge gui-launch.sh
<repo_dir> <message>` (built with `shlex.quote`) and reports failures back to the room. See
ROADMAP Phase 1 (also: bake key+config into the image, curated `$MB_SESSION_NAME` topic, fail-loud).
+27
View File
@@ -0,0 +1,27 @@
# matrix-bridge bot — Phase 1 container.
#
# Runs on the Spark (always-on Linux + Docker). docker-compose uses host networking so the
# bot reaches BOTH Synapse (clearnet TLS) and the Mac (WireGuard, via the `mac-bridge` SSH alias).
#
# The image is GENERIC: no deployment specifics and no secrets are baked in. At runtime
# docker-compose mounts .env, config.toml, and the SSH key (all read-only); the entrypoint
# generates ~/.ssh/config for the alias from config.toml's [mac] section before launching.
FROM python:3.12-slim
# openssh-client: the bot shells out to `ssh mac-bridge ...` (the proven Phase 0 seam).
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssh-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# .env and config.toml arrive via read-only mounts at runtime (never baked).
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["python", "-u", "src/bot.py"]
+8 -7
View File
@@ -9,8 +9,9 @@ Runs as a small **matrix-nio** bot in a Docker container on a DGX Spark; a zsh w
Mac (`scripts/launch-claude.sh`) is the only piece that knows the Mac's environment. Routing Mac (`scripts/launch-claude.sh`) is the only piece that knows the Mac's environment. Routing
is deterministic in v1 — the room you message in decides the repo (an explicit config map). is deterministic in v1 — the room you message in decides the repo (an explicit config map).
> Status: **scaffolded, prePhase 0.** No bot code yet. See `AGENTS.md` → `## Current state` > Status: **live on the Spark — Phases 03 + headless "ask" mode shipped.** The bot runs in
> for the active milestone and `ROADMAP.md` for the phase plan. > 11 project rooms + an all-projects room, and is managed from the Spark Control dashboard. See
> `AGENTS.md` → `## Current state` for details and `ROADMAP.md` for the phase plan.
## How it works (v1) ## How it works (v1)
@@ -24,8 +25,8 @@ Matrix message in a project room
## Setup ## Setup
_TODO — filled in as Phase 0 is proven:_ Matrix onboarding (Element + the existing Synapse The bot is live; this is the shape of a fresh install: Matrix onboarding (Element + the existing
homeserver, a bot user), the Mac wrapper, passwordless SSH from the Spark to the Mac, and the Synapse homeserver, a bot user), the Mac wrapper, passwordless SSH from the Spark to the Mac, and
first room→repo mapping. Copy `config.example.toml` to `config.toml` (gitignored) and fill in the room→repo mapping. Copy `config.example.toml` to `config.toml` (gitignored) and fill in real
real room IDs and repo paths. The original scoping docs (SPEC / DECISIONS / KICKOFF) hold the room IDs and repo paths. Deploy and day-2 ops (status / update / restart) run from the Spark
full background. Control dashboard — see `docs/spark-control-integration.md` for the command contract.
+22 -4
View File
@@ -26,19 +26,27 @@ after it.
- **Exit (falsifiable):** 3 consecutive real messages each correctly launch a drivable - **Exit (falsifiable):** 3 consecutive real messages each correctly launch a drivable
session on the phone. session on the phone.
## Phase 2 — Multi-room routing ## Phase 2 — Multi-room routing — DONE (2026-06-15)
- Room → repo mapping table; the bot routes by `room_id` (config over code). - Room → repo mapping table; the bot routes by `room_id` (config over code).
- **Exit (falsifiable):** 3 real uses across ≥2 rooms, correct repo every time, zero - **Exit (falsifiable):** 3 real uses across ≥2 rooms, correct repo every time, zero
wrong-directory launches. wrong-directory launches. *Met — owner-confirmed N=3 pass.*
## Phase 3 — Spark Control integration ## Phase 3 — Spark Control integration — DONE (2026-06-16)
- Bot container status surfaced on the Spark Control dashboard. - Bot container status surfaced on the Spark Control dashboard.
- One-click update (pull + restart) wired the same way Spark Control drives the Sparks today - One-click update (pull + restart) wired the same way Spark Control drives the Sparks today
(SSH/commands behind a button). (SSH/commands behind a button).
- **Exit (falsifiable):** bot status is visible and the bot can be updated/restarted from the - **Exit (falsifiable):** bot status is visible and the bot can be updated/restarted from the
panel. panel. *Met — shipped in Spark Control v0.21.0; all three controls confirmed working.*
- **Shipped:** matrix-bridge tile (status badge + Update / Restart / Stop-Start / Logs) running
the spec's SSH commands; the Spark's `~/matrix-bridge` is now a Gitea clone tracking `master`.
matrix-bridge needed no code change. Deviation: Spark Control connects directly as `modelo` (no
`sudo` wrap — no passwordless sudo on this Spark). Live command contract + the Gitea key pin the
Update button depends on: `docs/spark-control-integration.md`.
- **Deferred follow-up:** badge reflects container liveness only, not Matrix connectivity — a
Docker `HEALTHCHECK` (bot-side liveness signal) would let the tile read `{{.State.Health.Status}}`.
matrix-bridge-side change; do it if/when "running but silent" bites.
## Phase 4+ — Future direction (documented, not yet scoped to build) ## Phase 4+ — Future direction (documented, not yet scoped to build)
@@ -54,3 +62,13 @@ after it.
is actually in use. is actually in use.
- **E2EE (D9).** Add matrix-nio end-to-end encryption (libolm) if the bot ever handles - **E2EE (D9).** Add matrix-nio end-to-end encryption (libolm) if the bot ever handles
sensitive content over untrusted transport. Low priority while everything is WireGuard-local. sensitive content over untrusted transport. Low priority while everything is WireGuard-local.
- **Headless "ask" mode — SHIPPED 2026-06-16.** A `?`-prefixed message runs `claude -p "<rest>"`
one-shot in the room's repo and posts the **full** answer back into the room — Matrix as a
request/response interface, not just a trigger. Built via `scripts/ask-claude.sh` (login-shell
wrapper) + the bot's `?`-dispatch (`run_ask`/`ask`). Resolved design choices: selector = `?` prefix
(per-message; the room still picks the repo); output posted in full, chunked under Matrix's event
cap (no truncation — chosen explicitly); auth = the long-lived `claude setup-token`
(`CLAUDE_CODE_OAUTH_TOKEN`, Approach A / D12) because a non-GUI SSH session can't reach the
Keychain; sovereignty unchanged (`claude -p` uses the subscription, no frontier API on payloads).
*Remaining open Qs:* very-long-output handling beyond chunking (thread / attach file); the
first-run folder-trust gate for a repo `claude` has never been opened in.
+19 -1
View File
@@ -10,14 +10,32 @@ user = "@matrix-bridge-bot:<your-domain>" # a dedicated bot Matrix account (not
# Credentials (access token or password) come from the environment or a gitignored secret — # Credentials (access token or password) come from the environment or a gitignored secret —
# never commit them. The bot reads the homeserver URL + bot creds at startup. # never commit them. The bot reads the homeserver URL + bot creds at startup.
# How the bot reaches the Mac (the proven Phase 0 seam). The bot runs on the Spark,
# where `ssh_alias` resolves; `launcher` is the absolute path to gui-launch.sh on the Mac.
[mac]
ssh_alias = "mac-bridge"
launcher = "/Users/macpro/Projects/<your-repo>/scripts/gui-launch.sh"
ask_launcher = "/Users/macpro/Projects/<your-repo>/scripts/ask-claude.sh" # headless `?`-prefix ask mode
capture_launcher = "/Users/macpro/Projects/<your-repo>/scripts/capture-note.sh" # `/capture` + capture-thread → standards/INBOX.md
# Container only: docker-entrypoint.sh generates ~/.ssh/config for `ssh_alias` from these.
# (On a host with `ssh_alias` already in ~/.ssh/config these are ignored.)
hostname = "10.0.0.0" # the Mac's address reachable from the Spark (e.g. WireGuard IP)
user = "<mac-username>"
# One [[room]] block per project. # One [[room]] block per project.
# room_id — the internal Matrix room ID (starts with '!'), NOT the human alias (#name:domain) # room_id — the internal Matrix room ID (starts with '!'), NOT the alias (#name:domain)
# repo_dir — an absolute path on the Mac (note: ~/Projects uses a capital P) # repo_dir — an absolute path on the Mac (note: ~/Projects uses a capital P)
# label — human-readable name, for logs and error messages # label — human-readable name, for logs and error messages
# capture_thread — (optional) root event ID ('$...') of this room's capture thread; a message
# in that thread is logged to standards/INBOX.md (tagged by `label`) instead of
# launching Claude. Seed these with: python3 scripts/seed-capture-threads.py
# capture_project — (optional) inbox tag for captures from this room; defaults to `label`.
# `/capture <text>` works in any room with no config; the thread just lets you skip the prefix.
[[room]] [[room]]
room_id = "!exampleRoomId:your-domain" room_id = "!exampleRoomId:your-domain"
repo_dir = "/Users/macpro/Projects/<your-repo>" repo_dir = "/Users/macpro/Projects/<your-repo>"
label = "<project-name>" label = "<project-name>"
capture_thread = "$exampleThreadRootEventId"
[[room]] [[room]]
room_id = "!anotherRoomId:your-domain" room_id = "!anotherRoomId:your-domain"
+19
View File
@@ -0,0 +1,19 @@
# matrix-bridge bot — Phase 1 deployment on the Spark.
#
# `docker compose up -d` runs the bot detached; `restart: unless-stopped` brings it back after
# a Spark reboot. Host networking lets it reach BOTH Synapse (clearnet TLS) and the Mac
# (WireGuard, via the mac-bridge alias the entrypoint generates). The image stays generic — all
# deployment specifics and secrets arrive through the read-only mounts below.
services:
bot:
build: .
image: matrix-bridge-bot
container_name: matrix-bridge
network_mode: host
restart: unless-stopped
volumes:
- ./.env:/app/.env:ro
- ./config.toml:/app/config.toml:ro
# Dedicated Phase 0 key (spark-control@spark-32d0). Must be chmod 600 on the host.
# Override the host path with MB_SSH_KEY_HOST if the key lives elsewhere.
- ${MB_SSH_KEY_HOST:-/home/modelo/.ssh/id_ed25519}:/root/.ssh/id_ed25519:ro
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
# matrix-bridge container entrypoint — the container's "environment seam".
#
# Generates ~/.ssh/config for the `mac-bridge` alias from config.toml's [mac] section, then
# execs the bot. This mirrors the Mac side, where launch-claude.sh owns environment setup and
# the bot stays dumb (AGENTS.md D4): SSH-client wiring lives here, not in bot.py. On the Spark
# HOST the bot uses modelo's existing ~/.ssh/config; in the container we recreate just the one
# alias we need, pointing at the mounted key.
set -e
SSH_DIR="$HOME/.ssh"
mkdir -p "$SSH_DIR"
chmod 700 "$SSH_DIR"
# Write ~/.ssh/config straight from config.toml [mac] (no eval; values never hit a shell).
# IdentityFile is the in-container mount target (a container constant, see docker-compose.yml).
# StrictHostKeyChecking=accept-new auto-trusts the Mac's host key on first connect — acceptable
# on the private WireGuard network (same transport-trust reasoning as D9) and avoids an
# interactive prompt that would otherwise hang the bot.
MB_SSH_KEY="${MB_SSH_KEY:-$SSH_DIR/id_ed25519}" \
SSH_CONFIG="$SSH_DIR/config" \
KNOWN_HOSTS="$SSH_DIR/known_hosts" \
python - <<'PY'
import os, tomllib
with open("/app/config.toml", "rb") as f:
mac = tomllib.load(f)["mac"]
config = f"""Host {mac.get('ssh_alias', 'mac-bridge')}
HostName {mac['hostname']}
User {mac['user']}
IdentityFile {os.environ['MB_SSH_KEY']}
IdentitiesOnly yes
StrictHostKeyChecking accept-new
UserKnownHostsFile {os.environ['KNOWN_HOSTS']}
"""
with open(os.environ['SSH_CONFIG'], "w") as f:
f.write(config)
PY
chmod 600 "$SSH_DIR/config"
exec "$@"
+139
View File
@@ -0,0 +1,139 @@
# Phase 3 — Spark Control integration (live command contract)
**Status: DONE (2026-06-16), shipped in Spark Control v0.21.0.** The matrix-bridge bot has a
tile on the Spark Control dashboard under "Always-on services" — a live status badge plus
**Update**, **Restart**, **Stop/Start**, and **View logs** buttons. All three ROADMAP Phase 3
exit criteria are met (status visible + reflects the container; update works; restart works).
matrix-bridge needed no code change.
This document is the **contract**: what each control runs on the Spark, and what the output
means. Kept as the reference for what the buttons actually do — and to reproduce by hand if the
dashboard is ever unavailable.
---
## What the bot is
A single Docker container on the DGX Spark.
| Fact | Value |
|---|---|
| Host | `spark-32d0` (`10.59.211.6` on WireGuard), user **`modelo`** |
| Project dir | `/home/modelo/matrix-bridge` — a **Gitea clone tracking `master`** |
| Compose service | `bot` |
| Container name | `matrix-bridge` (fixed via `container_name:`) |
| Image | `matrix-bridge-bot` |
| Lifecycle | host networking, `restart: unless-stopped` (survives Spark reboot) |
| Secrets | `.env`, `config.toml`**gitignored**, live only on the Spark, never in git |
Spark Control SSHes into `spark-32d0` as **`modelo`** (the same login it already uses for Spark 2),
so these ride the existing channel — no new key, and **no `sudo` wrap**: this Spark has no
passwordless sudo, and since the channel is already `modelo` (owner of the dir, member of the
`docker` group) every command runs as the right user directly. (The original spec's
`sudo -iu modelo` different-user fallback therefore never applies here.)
Registration on the Spark Control side: the bot's SSH user is a config field (set to `modelo`),
the host reuses the existing Spark 2 connection, and container / dir / branch use the defaults
(`matrix-bridge` / `~/matrix-bridge` / `master`). The tile auto-hides when that user is blank or
the container is absent, so it stays out of the way on installs that don't run the bot.
---
## One-time prerequisites — DONE
`~/matrix-bridge` was originally loose files from `scp`; it's now a git clone of the Gitea repo,
converted in place (the gitignored `.env`/`config.toml` were untouched, because `git reset --hard`
ignores them).
**Load-bearing gotcha that's now fixed:** on the Spark, git offered the wrong SSH key first and
Gitea rejected it (`Permission denied (publickey)`) even though the deploy key was correctly
registered. Fixed by pinning it in modelo's `~/.ssh/config` with `IdentitiesOnly yes` for the
Gitea host. **The Update button depends on that block staying in place — flag it if modelo's
account is ever rebuilt.**
The conversion, for reference:
```sh
cd /home/modelo/matrix-bridge
git init -b master
git remote add origin ssh://git@immense-voyage.local:59916/grant/matrix-bridge.git
git fetch origin
git reset --hard origin/master # secrets are gitignored → untouched
git branch --set-upstream-to=origin/master master
```
---
## The contract — commands behind each control
Run from `/home/modelo/matrix-bridge` as `modelo`. Each is idempotent and fail-loud: non-zero
exit + stderr is surfaced on the panel, not swallowed.
### Status (poll for the badge)
```sh
docker inspect -f '{{.State.Status}}|{{.State.StartedAt}}|{{.RestartCount}}' matrix-bridge
```
- `running` → up · `exited` → stopped/crashed · `restarting` → unhealthy/boot-looping ·
non-zero exit (`No such object: matrix-bridge`) → **not deployed** (tile hides). A climbing
`RestartCount` while status flips to `restarting` is the crash-loop tell.
- **Badge = container liveness only, not Matrix connectivity** — a bot that's `running` but
disconnected from Synapse still shows Healthy. See the HEALTHCHECK note below.
- *Cadence note:* a fast `docker restart` won't visibly flip the badge red — the panel re-checks
status only after the command returns, by which point the container is already back up. A full
`docker stop` turns it red within ~5s. Polling cadence, not a bug.
### Logs
```sh
docker logs --tail 100 matrix-bridge
```
### Restart
```sh
docker restart matrix-bridge
```
### Update (pull + rebuild + recreate) — the headline button
```sh
cd /home/modelo/matrix-bridge \
&& git fetch origin \
&& git reset --hard origin/master \
&& docker compose up -d --build
```
`git reset --hard origin/master` is the deploy-box "always match remote" semantic: never stuck on
divergence, and gitignored secrets are preserved. Streamed live on the panel with a ~25-min
ceiling; non-zero exit + stderr surfaced. **Workflow: push to Gitea, then click Update.**
### Stop / Start
```sh
docker stop matrix-bridge # stop
cd /home/modelo/matrix-bridge && docker compose up -d # start (recreates if needed)
```
---
## Programmatic interface (LAN-only)
The same controls are reachable over HTTP if scripting is ever wanted:
- `POST /api/matrix-bridge/update` → returns an id; `GET .../update/{id}` and
`.../update/{id}/stream` (SSE) for progress.
- `GET /api/matrix-bridge/logs?tail=N`
- status via `GET /api/services`
---
## Future enhancement — truer status (not required; matrix-bridge-side)
Status reports container liveness, not Matrix connectivity — the bot can be `running` yet
disconnected from Synapse. A truer signal needs a Docker `HEALTHCHECK` backed by a bot-side
liveness signal (e.g. the bot touches a file or exposes a tiny endpoint on each successful sync
loop), after which Status could read `{{.State.Health.Status}}`. That's a matrix-bridge-side
change — do it if/when "running but silent" actually bites, then tell the Spark Control dev to
read the health field.
+2
View File
@@ -0,0 +1,2 @@
matrix-nio>=0.24
tomli>=2.0; python_version < "3.11"
+45
View File
@@ -0,0 +1,45 @@
#!/bin/zsh -l
# ask-claude.sh — matrix-bridge headless "ask" wrapper.
#
# Invoked over SSH by the bot: ask-claude.sh <repo_dir> <prompt...>
# Runs `claude -p` one-shot in the repo and prints the answer to STDOUT, which the bot
# captures over the SSH pipe and posts back into the Matrix room. Unlike launch-claude.sh /
# gui-launch.sh (interactive, surfaced to the phone), this NEVER opens a GUI Terminal.
#
# Two seams it owns, both proven the hard way in Phase 0:
# - LOGIN shell (-l): a non-login SSH shell loads neither ~/.zprofile nor ~/.zshrc, so
# ~/.local/bin isn't on PATH and `claude` isn't found. Same reason as launch-claude.sh.
# - Headless auth via CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`, stored in ../.env):
# a non-GUI SSH session can't reach the login Keychain, so plain `claude -p` reports
# "Not logged in" (D11 / Approach A). We export the token to bypass the Keychain.
set -e
script_dir="${0:A:h}"
# Pull just the token out of ../.env (don't `source` the whole file — other values, e.g. a
# password, may not be shell-safe). Absent token => claude reports "Not logged in", reported
# back to the room by the bot.
env_file="$script_dir/../.env"
if [[ -f "$env_file" ]]; then
token_line="$(grep -E '^CLAUDE_CODE_OAUTH_TOKEN=' "$env_file" | head -1)"
token="${token_line#*=}"
token="${token#\"}" # strip one surrounding quote pair if present (KEY="value")
token="${token%\"}"
export CLAUDE_CODE_OAUTH_TOKEN="$token"
fi
repo_dir="$1"
shift
prompt="$*"
if [[ -z "$repo_dir" || -z "$prompt" ]]; then
print -u2 "usage: ask-claude.sh <repo_dir> <prompt>"
exit 2
fi
# Fail loud on a bad directory — never run Claude in the wrong place.
cd "$repo_dir" || { print -u2 "ask-claude: no such repo dir: $repo_dir"; exit 1; }
# < /dev/null: print mode reads stdin by default and otherwise stalls ~3s waiting for it.
exec claude -p "$prompt" < /dev/null
+64
View File
@@ -0,0 +1,64 @@
#!/bin/zsh -l
# capture-note.sh — matrix-bridge capture wrapper.
#
# Invoked over SSH by the bot: capture-note.sh <project> <text...>
# Appends ONE line to the cross-project inbox (standards/INBOX.md) in the exact format the
# /capture skill uses, commits it, and best-effort pushes — so /triage (run later inside the
# target repo) drains it like any other captured item.
#
# Deterministic on purpose: no LLM, no token, nothing leaves the Mac except the git push to
# Gitea. The "smarts" (priority, repo-routing, phrasing) stay at /triage, where the human is —
# routing message text through a frontier model would break the sovereignty boundary (D13/D8).
# Type comes from an optional leading keyword the user types ("bug: ...", "feature: ...");
# anything without one defaults to a raw [idea]. Priority is always P2; /triage sets the real one.
#
# Why a login shell (-l): git config / PATH live in ~/.zprofile, which a non-login SSH shell
# skips — the same seam as launch-claude.sh / ask-claude.sh.
set -e
setopt extended_glob # for whitespace-run trimming below
standards="$HOME/Projects/standards"
inbox="$standards/INBOX.md"
project="$1"
shift || true
note="$*"
if [[ -z "$project" || -z "$note" ]]; then
print -u2 "usage: capture-note.sh <project> <text>"
exit 2
fi
if [[ ! -f "$inbox" ]]; then
print -u2 "capture-note: inbox not found: $inbox"
exit 1
fi
# Optional leading type keyword ("bug: ...", "feature: ...", etc.) → that type; default idea.
# Only fires when the text before the first colon is exactly one known keyword, so a note that
# merely contains a colon ("ratio is 3:1") is left untouched. Types: standards/guides/capture.md.
type="idea"
if [[ "$note" == *:* ]]; then
cand="${(L)note%%:*}" # lowercase the text before the first colon
cand="${cand##[[:space:]]#}" # trim surrounding whitespace
cand="${cand%%[[:space:]]#}"
case "$cand" in
bug|feature|idea|chore|skill|agent|project)
type="$cand"
note="${note#*:}" # drop "keyword:"
note="${note##[[:space:]]#}" # trim leading whitespace
;;
esac
fi
line="- [ ] ($project) [$type][P2] $note — via matrix, $(date +%F)"
print -r -- "$line" >> "$inbox"
git -C "$standards" add INBOX.md
git -C "$standards" commit -q -m "Capture: ${note[1,60]} (via matrix)" -- INBOX.md
# Push is best-effort — a captured line committed locally is not lost if Gitea is unreachable.
git -C "$standards" push -q 2>/dev/null || print -u2 "capture-note: push skipped/failed (committed locally)"
# Echo the exact line so the bot can post it back into the thread as confirmation.
print -r -- "$line"
+2
View File
@@ -36,6 +36,8 @@ fi
launch_script="$(mktemp -t mb-launch)" launch_script="$(mktemp -t mb-launch)"
{ {
print -r -- '#!/bin/zsh -l' print -r -- '#!/bin/zsh -l'
# Propagate a caller-supplied session name (the bot sets this for all-projects launches).
[[ -n "$MB_SESSION_NAME" ]] && printf 'export MB_SESSION_NAME=%q\n' "$MB_SESSION_NAME"
printf 'exec %q %q %q\n' "$inner" "$repo_dir" "$prompt" printf 'exec %q %q %q\n' "$inner" "$repo_dir" "$prompt"
} >| "$launch_script" } >| "$launch_script"
chmod +x "$launch_script" chmod +x "$launch_script"
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""seed-capture-threads.py — create each room's capture-thread root, print the event IDs.
Posts one "capture thread" root message into every room in config.toml and prints the resulting
event_id per room. Paste each into its [[room]] block as `capture_thread = "$..."` — the bot then
treats any reply in that thread as a capture (no /capture prefix needed in-thread).
Idempotent-ish: by default it skips rooms that already have `capture_thread` set in config, so a
re-run after adding a project only seeds the new room. Use --force to reseed everything, pass room
labels to seed only those, or --dry-run to preview without posting.
Reads ../.env (MATRIX_ACCESS_TOKEN) and ../config.toml (homeserver URL + rooms). Talks to the
homeserver over plain HTTPS, so run it anywhere the homeserver is reachable (Mac or Spark).
python3 scripts/seed-capture-threads.py [--force] [--dry-run] [label ...]
"""
import json
import os
import sys
import tomllib
import urllib.request
import urllib.error
import uuid
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
def load_env(path):
env = {}
with open(path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
env[k] = v.strip().strip('"')
return env
def root_body(project):
who = "any project" if project == "?" else f"({project})"
tag = "?" if project == "?" else project
return (
f"📥 Capture thread — reply in this thread to log an idea, feature, or bug for {who}. "
f"It goes straight to the cross-project inbox tagged ({tag}) (no /capture prefix needed "
f"in-thread); run /triage in the repo to land it."
)
def send_root(url, token, room_id, project):
txn = uuid.uuid4().hex
endpoint = (
f"{url.rstrip('/')}/_matrix/client/v3/rooms/"
f"{urllib.parse.quote(room_id, safe='')}/send/m.room.message/{txn}"
)
body = json.dumps({"msgtype": "m.text", "body": root_body(project)}).encode()
req = urllib.request.Request(
endpoint, data=body, method="PUT",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.load(resp)["event_id"]
def main():
args = [a for a in sys.argv[1:]]
force = "--force" in args
dry_run = "--dry-run" in args
only = [a for a in args if not a.startswith("--")]
env = load_env(os.path.join(ROOT, ".env"))
with open(os.path.join(ROOT, "config.toml"), "rb") as f:
cfg = tomllib.load(f)
url = cfg["homeserver"]["url"]
token = env.get("MATRIX_ACCESS_TOKEN") or env.get("MATRIX_ACCESS_TOKEN".lower())
if not token:
sys.exit("no MATRIX_ACCESS_TOKEN in .env")
# Build the seed list: every project room, plus the all-projects fan-out room as a `?` catch-all.
targets = []
for r in cfg.get("room", []):
targets.append((r["label"], r["room_id"], r.get("capture_project", r["label"]),
r.get("capture_thread")))
ap = cfg.get("all_projects", {})
if ap.get("room_id"):
targets.append(("all-projects", ap["room_id"], ap.get("capture_project", "?"),
ap.get("capture_thread")))
if only:
targets = [t for t in targets if t[0] in only]
seeded = []
for label, room_id, project, existing in targets:
if existing and not force:
print(f"SKIP {label:<20} already has capture_thread={existing}")
continue
if dry_run:
print(f"DRYRUN {label:<20} {room_id} (project {project})")
continue
try:
event_id = send_root(url, token, room_id, project)
seeded.append((label, room_id, project, event_id))
print(f"SEEDED {label:<20} {room_id} -> {event_id}")
except urllib.error.HTTPError as e:
print(f"ERROR {label:<20} {room_id} HTTP {e.code}: {e.read().decode()[:200]}")
except Exception as e: # noqa: BLE001 — surface any transport failure per room, keep going
print(f"ERROR {label:<20} {room_id} {e}")
if seeded:
print("\n# Paste each line into the matching [[room]] block (or [all_projects]):")
for label, _room_id, _project, event_id in seeded:
print(f'{label}: capture_thread = "{event_id}"')
if __name__ == "__main__":
main()
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""matrix-bridge bot — Phase 1.
A text message in a mapped room launches a Claude Code session in that repo on the Mac
(ssh -> gui-launch.sh -> launch-claude.sh -> claude), surfaced to the phone by Remote
Control. A message in the all-projects room fans out to every mapped repo (each session
named "<repo> - <date>"). Launch failures are reported back into the room (fail loud).
Runs on the Spark, where the SSH alias resolves. Config: ../config.toml Creds: ../.env
"""
import asyncio
import datetime
import os
import shlex
try:
import tomllib # py >= 3.11
except ModuleNotFoundError:
import tomli as tomllib # py < 3.11
from nio import AsyncClient, MatrixRoom, RoomMessageText
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Headless "ask" mode tunables.
ASK_TIMEOUT = 300 # seconds to wait for `claude -p` before giving up
CAPTURE_TIMEOUT = 60 # seconds to wait for capture-note.sh (inbox append + git push)
MAX_MSG_CHARS = 30000 # split answers into chunks well under Matrix's ~64KB event cap
CAPTURE_PREFIX = "/capture" # zero-config capture trigger, valid in any mapped room
def load_env(path):
env = {}
with open(path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
env[k] = v
return env
def load_config(path):
with open(path, "rb") as f:
return tomllib.load(f)
def split_message(text, limit=MAX_MSG_CHARS):
"""Split text into <=limit-char chunks on newline boundaries (no truncation)."""
if len(text) <= limit:
return [text]
chunks, buf = [], ""
for line in text.splitlines(keepends=True):
while len(line) > limit: # one oversized line: hard-split it
if buf:
chunks.append(buf)
buf = ""
chunks.append(line[:limit])
line = line[limit:]
if len(buf) + len(line) > limit:
chunks.append(buf)
buf = ""
buf += line
if buf:
chunks.append(buf)
return chunks
def thread_root_of(event):
"""Return the thread root event_id if this message is a threaded reply, else None."""
relates = (event.source or {}).get("content", {}).get("m.relates_to") or {}
if relates.get("rel_type") == "m.thread":
return relates.get("event_id")
return None
async def main():
env = load_env(os.path.join(REPO_ROOT, ".env"))
cfg = load_config(os.path.join(REPO_ROOT, "config.toml"))
homeserver = env["MATRIX_HOMESERVER"]
user_id = env["MATRIX_USER"]
token = env["MATRIX_ACCESS_TOKEN"]
device_id = env.get("MATRIX_DEVICE_ID", "matrix-bridge-bot")
rooms = {r["room_id"]: r for r in cfg.get("room", [])}
all_projects_cfg = cfg.get("all_projects", {})
all_projects_room = all_projects_cfg.get("room_id")
ssh_alias = os.environ.get("MB_SSH_ALIAS") or cfg["mac"]["ssh_alias"]
launcher = cfg["mac"]["launcher"]
ask_launcher = cfg["mac"].get("ask_launcher")
capture_launcher = cfg["mac"].get("capture_launcher")
client = AsyncClient(homeserver, user_id)
client.restore_login(user_id=user_id, device_id=device_id, access_token=token)
async def launch(repo_dir, prompt, session_name=None):
"""Run gui-launch.sh on the Mac over SSH. Returns (returncode, combined_output).
All user text is passed through shlex.quote so it survives the remote shell —
this is where the cross-shell quoting footgun is actually solved.
"""
remote = f"{shlex.quote(launcher)} {shlex.quote(repo_dir)} {shlex.quote(prompt)}"
if session_name:
remote = f"MB_SESSION_NAME={shlex.quote(session_name)} " + remote
proc = await asyncio.create_subprocess_exec(
"ssh", ssh_alias, remote,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
out, _ = await proc.communicate()
return proc.returncode, out.decode(errors="replace").strip()
async def run_ask(repo_dir, prompt):
"""Run ask-claude.sh on the Mac over SSH; return (rc, stdout, stderr).
Headless `claude -p`: its stdout is the answer (captured here), stderr is diagnostics.
This path never opens a GUI Terminal and is not surfaced to the phone.
"""
remote = f"{shlex.quote(ask_launcher)} {shlex.quote(repo_dir)} {shlex.quote(prompt)}"
proc = await asyncio.create_subprocess_exec(
"ssh", ssh_alias, remote,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout=ASK_TIMEOUT)
except asyncio.TimeoutError:
proc.kill()
await proc.wait() # reap the killed ssh client (no zombie)
return None, "", f"timed out after {ASK_TIMEOUT}s"
return (proc.returncode,
out.decode(errors="replace").strip(),
err.decode(errors="replace").strip())
async def say(room_id, text, thread_root=None):
content = {"msgtype": "m.text", "body": text}
if thread_root: # keep confirmations inside the capture thread
content["m.relates_to"] = {
"rel_type": "m.thread",
"event_id": thread_root,
"is_falling_back": True,
"m.in_reply_to": {"event_id": thread_root},
}
await client.room_send(room_id, "m.room.message", content)
async def launch_one(report_room, repo, prompt, session_name=None):
rc, out = await launch(repo["repo_dir"], prompt, session_name)
if rc == 0:
print(f"launched {repo['label']} -> {repo['repo_dir']}", flush=True)
return True
print(f"FAILED {repo['label']}: rc={rc} {out[:300]}", flush=True)
await say(report_room, f"⚠️ matrix-bridge: failed to launch {repo['label']} "
f"(rc={rc}): {out[:300] or 'no output'}")
return False
async def ask(report_room, repo, prompt):
"""Headless ask: run `claude -p` in the repo and post the full answer back."""
if not ask_launcher:
await say(report_room,
"⚠️ matrix-bridge: ask mode not configured ([mac].ask_launcher missing).")
return
await say(report_room, f"🤔 asking claude in {repo['label']}")
rc, out, err = await run_ask(repo["repo_dir"], prompt)
if rc == 0: # success — even an empty answer is not a failure
print(f"ask {repo['label']}: {len(out)} chars", flush=True)
for chunk in split_message(out or "(claude returned no output)"):
await say(report_room, chunk)
return
detail = err or out or "no output"
print(f"ASK FAILED {repo['label']}: rc={rc} {detail[:300]}", flush=True)
await say(report_room, f"⚠️ matrix-bridge: ask failed in {repo['label']} "
f"(rc={rc}): {detail[:500]}")
async def run_capture(project, text):
"""Run capture-note.sh on the Mac over SSH; return (rc, stdout, stderr).
Deterministic: appends one line to standards/INBOX.md and commits/pushes. stdout is the
exact inbox line written, echoed back into the thread as confirmation.
"""
remote = (f"{shlex.quote(capture_launcher)} "
f"{shlex.quote(project)} {shlex.quote(text)}")
proc = await asyncio.create_subprocess_exec(
"ssh", ssh_alias, remote,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout=CAPTURE_TIMEOUT)
except asyncio.TimeoutError:
proc.kill()
await proc.wait() # reap the killed ssh client (no zombie)
return None, "", f"timed out after {CAPTURE_TIMEOUT}s"
return (proc.returncode,
out.decode(errors="replace").strip(),
err.decode(errors="replace").strip())
async def capture(report_room, project, text, thread_root=None):
"""Log a message to the cross-project inbox (standards/INBOX.md) tagged for `project`."""
if not capture_launcher:
await say(report_room,
"⚠️ matrix-bridge: capture mode not configured ([mac].capture_launcher missing).",
thread_root)
return
rc, out, err = await run_capture(project, text)
if rc == 0:
print(f"capture ({project}): {out}", flush=True)
await say(report_room, f"📥 captured → {out}", thread_root)
return
detail = err or out or "no output"
print(f"CAPTURE FAILED ({project}): rc={rc} {detail[:300]}", flush=True)
await say(report_room, f"⚠️ matrix-bridge: capture failed (rc={rc}): {detail[:500]}",
thread_root)
async def on_message(room: MatrixRoom, event: RoomMessageText):
if event.sender == user_id:
return # never react to our own messages
prompt = event.body.strip()
if not prompt:
return
rid = room.room_id
if rid == all_projects_room:
cap_thread = all_projects_cfg.get("capture_thread")
cap_project = all_projects_cfg.get("capture_project", "?")
elif rid in rooms:
cap_thread = rooms[rid].get("capture_thread")
cap_project = rooms[rid].get("capture_project", rooms[rid]["label"])
else:
return # message in an unmapped room
# Capture is checked first: an explicit `/capture <text>` in any mapped room, or any
# message in this room's configured capture thread → logged to the inbox, not launched.
thread_root = thread_root_of(event)
head, _, rest = prompt.partition(" ")
if head == CAPTURE_PREFIX:
note = rest.strip()
if note:
await capture(rid, cap_project, note, thread_root)
return
if cap_thread and thread_root == cap_thread:
await capture(rid, cap_project, prompt, thread_root)
return
if rid == all_projects_room: # fan-out room: launch everywhere, never asks
date = datetime.date.today().isoformat()
print(f"[all-projects] fan-out to {len(rooms)} repos: {prompt!r}", flush=True)
results = await asyncio.gather(*[
launch_one(rid, r, prompt, f"{r['label']} - {date}")
for r in rooms.values()
])
await say(rid,
f"matrix-bridge: launched {sum(results)}/{len(rooms)} sessions ({date}).")
else: # a mapped project room
r = rooms[rid]
if prompt.startswith("?"): # headless ask mode
ask_prompt = prompt[1:].strip()
if ask_prompt:
await ask(rid, r, ask_prompt)
elif await launch_one(rid, r, prompt):
await say(rid,
f"matrix-bridge: launched {r['label']} — drive it on your phone.")
# Prime the sync token past existing history, THEN register the callback, so the bot
# only reacts to messages that arrive after startup (no backlog replay).
print("priming sync (skipping backlog)...", flush=True)
await client.sync(timeout=30000, full_state=False)
client.add_event_callback(on_message, RoomMessageText)
who = await client.whoami()
print(f"listening as {who.user_id}; {len(rooms)} rooms + all-projects={all_projects_room}",
flush=True)
try:
await client.sync_forever(timeout=30000)
finally:
await client.close()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass