Compare commits
6 Commits
843582ec03
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fe56cd2eb | |||
| 87bb3c95ec | |||
| 0786286cde | |||
| 2bae2f3571 | |||
| 4204b82c6b | |||
| 28c974fe1d |
@@ -23,8 +23,10 @@ Matrix message in a project room
|
||||
```
|
||||
|
||||
Room determines the repo; the message text becomes the initial prompt — the v1 trigger surface.
|
||||
*Variant:* a `?`-prefixed message instead runs `ask-claude.sh` (headless `claude -p`) and posts
|
||||
the full answer back into the room (ask mode, D12).
|
||||
*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
|
||||
|
||||
@@ -61,20 +63,31 @@ the full answer back into the room (ask mode, D12).
|
||||
- **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.
|
||||
- **Deploy:** pull the bot files from the Mac (no Gitea needed) —
|
||||
- **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.
|
||||
*(Phase 3 switches this to `git pull` once the Spark's `~/matrix-bridge` becomes a Gitea clone —
|
||||
see `docs/spark-control-integration.md`; not done yet, so scp-from-Mac is still the live path.)*
|
||||
and `scp -r mac-bridge:/Users/macpro/Projects/matrix-bridge/src .`, then rebuild.)*
|
||||
|
||||
## Layout
|
||||
|
||||
- `AGENTS.md` — this file (canonical; `CLAUDE.md` is a relative symlink to it).
|
||||
- `ROADMAP.md` — Phases 1–4+ with falsifiable exits, plus deferred/future directions.
|
||||
- `README.md` — human-facing intro.
|
||||
- `docs/spark-control-integration.md` — Phase 3 spec for the Spark Control dev: the SSH
|
||||
command contract (status / restart / git-pull update) the dashboard drives, plus the one-time
|
||||
conversion of the Spark's `~/matrix-bridge` to a Gitea clone. matrix-bridge needs no code change.
|
||||
- `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
|
||||
Mac's environment).
|
||||
- `config.example.toml` — room→repo mapping template; the real `config.toml` is gitignored.
|
||||
@@ -83,9 +96,17 @@ the full answer back into the room (ask mode, D12).
|
||||
- `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); fans out for all-projects; reports failures back.
|
||||
`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).
|
||||
- `Dockerfile` · `docker-compose.yml` · `docker-entrypoint.sh` · `.dockerignore` — the Phase 1
|
||||
@@ -125,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
|
||||
LAN; transport is already private and matrix-nio E2EE adds libolm overhead. *Revisit when:*
|
||||
the bot ever handles sensitive content over untrusted transport.
|
||||
- **D10 — Spark Control manages the bot (Phase 3).** Status on the dashboard + one-click
|
||||
update/restart, the same SSH-behind-buttons pattern Spark Control uses for the Sparks today.
|
||||
- **D10 — Spark Control manages the bot (Phase 3, DONE 2026-06-16).** Status badge + Update /
|
||||
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
|
||||
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
|
||||
@@ -142,6 +166,21 @@ Condensed from the scoping workshop. Each: the call, why, what it beat.
|
||||
`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
|
||||
|
||||
@@ -176,6 +215,11 @@ once" is not done.
|
||||
- **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.)
|
||||
@@ -191,18 +235,29 @@ once" is not done.
|
||||
|
||||
## Current state
|
||||
|
||||
- **Live on the Spark (Phases 0–2 + ask mode).** matrix-nio bot in a Docker container
|
||||
(`~/matrix-bridge`, `docker compose up -d --build`): host networking, `restart: unless-stopped`,
|
||||
- **Live on the Spark; Phases 0–3 + ask mode all DONE.** matrix-nio bot in a Docker container
|
||||
(`~/matrix-bridge`, a Gitea clone tracking `master`): host networking, `restart: unless-stopped`,
|
||||
read-only mounts of `.env`/`config.toml`/SSH key. Runs as `@agent` in 11 project rooms + an
|
||||
all-projects fan-out room. Both modes proven — interactive (plain msg → phone via Remote Control)
|
||||
and ask (`?`-prefix → full answer posted back; D12).
|
||||
- **Phase 2 — DONE** (owner-confirmed N=3: routes by `room_id`, correct repo, zero wrong-dir launches).
|
||||
- **Phase 3 (Spark Control) — IN PROGRESS, awaiting the Spark Control dev.** The contract is
|
||||
`docs/spark-control-integration.md`: status/restart/git-pull-update SSH commands + a one-time
|
||||
conversion of the Spark's `~/matrix-bridge` to a Gitea clone. matrix-bridge needs no code change;
|
||||
remaining work is Spark Control-side (tile + buttons) + the one-time migration. **When live:** trim
|
||||
the spec to the lean command contract, update the Commands "Deploy" entry (scp → git pull), flip
|
||||
Phase 3 → DONE.
|
||||
- **Open / risks:** a `?`-ask in a repo `claude` has never opened may stall on the folder-trust gate
|
||||
— add a trust flag to `ask-claude.sh` if/when hit, not preemptively.
|
||||
- **Repo:** `master` == `phase-1`, clean, pushed to Gitea. No test suite (pre-existing).
|
||||
all-projects fan-out room. Interactive (plain msg → phone) and ask (`?`-prefix → answer posted
|
||||
back; D12) both proven at N=3; capture (D13) is live (see below). Phase 2: owner-confirmed routing.
|
||||
- **Phase 3 (Spark Control) shipped 2026-06-16 in v0.21.0:** status badge + Update / Restart /
|
||||
Stop-Start / Logs tile; the Spark's dir is now a Gitea clone and deploy = the Update button.
|
||||
Detail in ROADMAP + `docs/spark-control-integration.md`; no matrix-bridge code change.
|
||||
- **Capture mode (D13) LIVE 2026-06-16 — proven on 1 room, N=3 pending.** Per-room capture threads
|
||||
→ `standards/INBOX.md` via `capture-note.sh`, confirmed in-thread; all 11 rooms + all-projects
|
||||
have seeded `capture_thread` roots (IDs in the gitignored `config.toml`). Keyword type parsing
|
||||
(commit `0786286`) is **deployed and verified** (Spark updated + restarted 2026-06-16) — leading
|
||||
`bug:`/`feature:`/`chore:`/… keywords now set the inbox item type. How it works + the Element
|
||||
`/`-interception caveat: D13.
|
||||
- **Optional / triggered next moves:**
|
||||
- Badge reflects container liveness only, not Synapse connectivity — add a Docker `HEALTHCHECK`
|
||||
(bot-side liveness signal → read `{{.State.Health.Status}}`) when "running but silent" bites.
|
||||
- A `?`-ask in a repo `claude` has never opened may stall on the folder-trust gate — add a trust
|
||||
flag to `ask-claude.sh` if/when hit, not preemptively.
|
||||
- Capture priority is always `P2`; add a priority keyword/token to `capture-note.sh` if setting
|
||||
it at `/triage` gets tedious. Old `phase-0` branch still exists — delete if it bothers you.
|
||||
- Phase 4+ (intent-routing brain D8, thread continuity) — see ROADMAP; not scoped.
|
||||
- **Watch:** the Update button depends on modelo's Gitea ssh-config pin (`IdentitiesOnly yes`, see
|
||||
Infra facts) — flag it if that account is ever rebuilt.
|
||||
- **Repo:** single branch `master` (the vestigial `phase-1` was deleted 2026-06-16; capture mode was
|
||||
briefly stranded on it — see Deploy). Clean, pushed to Gitea. No test suite (pre-existing).
|
||||
|
||||
@@ -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
|
||||
is deterministic in v1 — the room you message in decides the repo (an explicit config map).
|
||||
|
||||
> Status: **scaffolded, pre–Phase 0.** No bot code yet. See `AGENTS.md` → `## Current state`
|
||||
> for the active milestone and `ROADMAP.md` for the phase plan.
|
||||
> Status: **live on the Spark — Phases 0–3 + headless "ask" mode shipped.** The bot runs in
|
||||
> 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)
|
||||
|
||||
@@ -24,8 +25,8 @@ Matrix message in a project room
|
||||
|
||||
## Setup
|
||||
|
||||
_TODO — filled in as Phase 0 is proven:_ Matrix onboarding (Element + the existing Synapse
|
||||
homeserver, a bot user), the Mac wrapper, passwordless SSH from the Spark to the Mac, and the
|
||||
first room→repo mapping. Copy `config.example.toml` to `config.toml` (gitignored) and fill in
|
||||
real room IDs and repo paths. The original scoping docs (SPEC / DECISIONS / KICKOFF) hold the
|
||||
full background.
|
||||
The bot is live; this is the shape of a fresh install: Matrix onboarding (Element + the existing
|
||||
Synapse homeserver, a bot user), the Mac wrapper, passwordless SSH from the Spark to the Mac, and
|
||||
the room→repo mapping. Copy `config.example.toml` to `config.toml` (gitignored) and fill in real
|
||||
room IDs and repo paths. Deploy and day-2 ops (status / update / restart) run from the Spark
|
||||
Control dashboard — see `docs/spark-control-integration.md` for the command contract.
|
||||
|
||||
+10
-6
@@ -32,17 +32,21 @@ after it.
|
||||
- **Exit (falsifiable):** 3 real uses across ≥2 rooms, correct repo every time, zero
|
||||
wrong-directory launches. *Met — owner-confirmed N=3 pass.*
|
||||
|
||||
## Phase 3 — Spark Control integration — SPEC DRAFTED (2026-06-15), awaiting Spark Control dev
|
||||
## Phase 3 — Spark Control integration — DONE (2026-06-16)
|
||||
|
||||
- Bot container status surfaced on the Spark Control dashboard.
|
||||
- One-click update (pull + restart) wired the same way Spark Control drives the Sparks today
|
||||
(SSH/commands behind a button).
|
||||
- **Exit (falsifiable):** bot status is visible and the bot can be updated/restarted from the
|
||||
panel.
|
||||
- **Spec:** `docs/spark-control-integration.md` — the SSH command contract + one-time Spark
|
||||
migration to a Gitea clone. Decided: update = git-pull-from-Gitea; Spark Control's existing
|
||||
SSH into `spark-32d0` carries the buttons (no new key). matrix-bridge needs no code change;
|
||||
remaining work is Spark Control-side + the one-time migration.
|
||||
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)
|
||||
|
||||
|
||||
+8
-1
@@ -16,19 +16,26 @@ user = "@matrix-bridge-bot:<your-domain>" # a dedicated bot Matrix account (not
|
||||
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.
|
||||
# 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)
|
||||
# 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_id = "!exampleRoomId:your-domain"
|
||||
repo_dir = "/Users/macpro/Projects/<your-repo>"
|
||||
label = "<project-name>"
|
||||
capture_thread = "$exampleThreadRootEventId"
|
||||
|
||||
[[room]]
|
||||
room_id = "!anotherRoomId:your-domain"
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
# Phase 3 — Spark Control integration (spec for the Spark Control dev)
|
||||
# Phase 3 — Spark Control integration (live command contract)
|
||||
|
||||
**Goal (ROADMAP Phase 3):** surface the matrix-bridge bot's container status on the Spark
|
||||
Control dashboard, and add one-click **update** (pull + rebuild + restart) and **restart**,
|
||||
wired the same SSH-behind-buttons way Spark Control already drives the Sparks.
|
||||
**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.
|
||||
|
||||
**Exit (falsifiable):** bot status is visible on the panel, and the bot can be
|
||||
updated/restarted from the panel.
|
||||
|
||||
This document is the **contract**: what to run, where, and what the output means. The
|
||||
matrix-bridge side is fixed below; map the buttons onto Spark Control's existing
|
||||
managed-service pattern however that codebase already models a Spark/service. No changes to
|
||||
matrix-bridge are required for this.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,41 +19,39 @@ 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` (`~/matrix-bridge` for 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 already SSHes into `spark-32d0`, so these ride the existing channel — **no new
|
||||
key needed.** All commands below assume they run **as `modelo`** (owner of the dir, member of
|
||||
the `docker` group). If Spark Control's channel connects as a different user, wrap each command
|
||||
in `sudo -iu modelo bash -lc '<command>'` — running `git` in modelo's repo as root trips git's
|
||||
"dubious ownership" guard, so don't skip this.
|
||||
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 (owner, not Spark Control dev)
|
||||
## One-time prerequisites — DONE
|
||||
|
||||
The bot dir on the Spark was originally populated by `scp` of loose files. To make
|
||||
git-pull-based updates work it must become a git clone of the Gitea repo **without disturbing
|
||||
the gitignored secrets** (`.env`, `config.toml`). Because those two files are gitignored,
|
||||
`git reset --hard` never touches them — so we can convert the existing dir in place.
|
||||
`~/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).
|
||||
|
||||
**0a. Confirm the Spark can reach + authenticate to Gitea (fail loud here, not at first button press):**
|
||||
**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.**
|
||||
|
||||
```sh
|
||||
git ls-remote ssh://git@immense-voyage.local:59916/grant/matrix-bridge.git >/dev/null \
|
||||
&& echo "gitea reachable" || echo "FIX gitea access first"
|
||||
```
|
||||
|
||||
The Spark is on the same LAN as the Start9 host running Gitea, so `immense-voyage.local`
|
||||
resolves directly — this should just work. If it doesn't, the only likely gap is a key
|
||||
authorized for read on the Gitea repo available to `modelo` (deploy key or existing key).
|
||||
Don't proceed until `git ls-remote` succeeds.
|
||||
|
||||
**0b. Convert `~/matrix-bridge` to a clone tracking `master` (run as `modelo`):**
|
||||
The conversion, for reference:
|
||||
|
||||
```sh
|
||||
cd /home/modelo/matrix-bridge
|
||||
@@ -66,23 +62,12 @@ git reset --hard origin/master # secrets are gitignored → untouched
|
||||
git branch --set-upstream-to=origin/master master
|
||||
```
|
||||
|
||||
Verify the secrets survived and the container still comes up clean:
|
||||
|
||||
```sh
|
||||
ls -la /home/modelo/matrix-bridge/.env /home/modelo/matrix-bridge/config.toml # both present
|
||||
git -C /home/modelo/matrix-bridge status # .env/config.toml show as ignored, tree clean
|
||||
docker compose up -d --build && docker ps --filter name=^/matrix-bridge$
|
||||
```
|
||||
|
||||
`master` is the release branch (today `master == phase-1`). Track whatever you treat as the
|
||||
release line; the commands below assume `origin/master`.
|
||||
|
||||
---
|
||||
|
||||
## The contract — commands behind each control
|
||||
|
||||
Run from `/home/modelo/matrix-bridge` as `modelo`. Each is idempotent and fail-loud
|
||||
(non-zero exit ⇒ surface it on the panel; don't swallow).
|
||||
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)
|
||||
|
||||
@@ -90,33 +75,28 @@ Run from `/home/modelo/matrix-bridge` as `modelo`. Each is idempotent and fail-l
|
||||
docker inspect -f '{{.State.Status}}|{{.State.StartedAt}}|{{.RestartCount}}' matrix-bridge
|
||||
```
|
||||
|
||||
- Output e.g. `running|2026-06-15T18:02:11.4Z|0`. Parse field 1 for the badge:
|
||||
- `running` → green/up. Field 3 (`RestartCount`) climbing while status flips to
|
||||
`restarting` ⇒ **crash loop** — show it; that's the most useful signal a dashboard gives here.
|
||||
- `exited` → stopped/crashed.
|
||||
- `restarting` → unhealthy / boot-looping.
|
||||
- **Non-zero exit** (`No such object: matrix-bridge`) ⇒ **not deployed** — distinct from
|
||||
"stopped". Show that state rather than erroring out.
|
||||
- `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.
|
||||
|
||||
Friendlier one-liner for a human-readable badge (empty string when not running):
|
||||
|
||||
```sh
|
||||
docker ps --filter name=^/matrix-bridge$ --format '{{.Status}}' # e.g. "Up 2 hours"
|
||||
```
|
||||
|
||||
### Logs (optional "view logs" action — handy for diagnosing a red badge)
|
||||
### Logs
|
||||
|
||||
```sh
|
||||
docker logs --tail 100 matrix-bridge
|
||||
```
|
||||
|
||||
### Restart (no code change)
|
||||
### Restart
|
||||
|
||||
```sh
|
||||
docker restart matrix-bridge
|
||||
```
|
||||
|
||||
### Update (pull latest code + rebuild + recreate) — the headline button
|
||||
### Update (pull + rebuild + recreate) — the headline button
|
||||
|
||||
```sh
|
||||
cd /home/modelo/matrix-bridge \
|
||||
@@ -125,15 +105,11 @@ cd /home/modelo/matrix-bridge \
|
||||
&& docker compose up -d --build
|
||||
```
|
||||
|
||||
- `git reset --hard origin/master` is the deploy-box "always match remote" semantic: never gets
|
||||
stuck on divergence, and gitignored secrets are preserved. (If you'd rather detect divergence,
|
||||
`git pull --ff-only` is the gentler alternative — but then a wedged tree needs manual help.)
|
||||
- `docker compose up -d --build` rebuilds the image and recreates the container only if the
|
||||
build changed. First build after a base-image bump is slow (minutes); subsequent builds hit
|
||||
the layer cache. **Treat update as long-running**: stream/await output, set a generous
|
||||
timeout (≥10 min), and don't block the dashboard on it.
|
||||
`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 (optional)
|
||||
### Stop / Start
|
||||
|
||||
```sh
|
||||
docker stop matrix-bridge # stop
|
||||
@@ -142,45 +118,22 @@ cd /home/modelo/matrix-bridge && docker compose up -d # start (recreates
|
||||
|
||||
---
|
||||
|
||||
## Spark Control-side wiring (for the dev)
|
||||
## Programmatic interface (LAN-only)
|
||||
|
||||
Map the above onto however Spark Control already registers a managed Spark/service:
|
||||
The same controls are reachable over HTTP if scripting is ever wanted:
|
||||
|
||||
1. **Register `matrix-bridge`** as a managed service (a tile), targeting `spark-32d0` over the
|
||||
existing SSH channel, commands run as `modelo`.
|
||||
2. **Status badge** ← poll the *Status* command on the panel's normal refresh cadence; map the
|
||||
four states above (running / exited / restarting / not-deployed) to your existing badge
|
||||
vocabulary. Surface `RestartCount` if your tile can show a secondary metric — a climbing
|
||||
count is the crash-loop tell.
|
||||
3. **Buttons:** `Update`, `Restart` (required for the exit criterion); `Logs`, `Stop`/`Start`
|
||||
(optional, nice-to-have).
|
||||
4. **Fail-loud, surfaced.** Every command's non-zero exit + stderr must reach the panel, not a
|
||||
silent failure — this mirrors matrix-bridge's own discipline (a bad launch reports back into
|
||||
the room rather than hanging). Especially: a failed `git fetch` (Gitea unreachable) or a
|
||||
failed build should show the error, not a stuck spinner.
|
||||
5. **`Update` is long-running** — see the timeout/streaming note above.
|
||||
|
||||
What I deliberately left generic: the tile's exact place in Spark Control's code, its UI, and
|
||||
its config schema — that's yours to fit to the existing pattern. If a precise drop-in matters,
|
||||
share how a Spark is currently registered (config entry + the command-runner seam) and I'll
|
||||
tailor steps 1–5 to it.
|
||||
- `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`
|
||||
|
||||
---
|
||||
|
||||
## Acceptance (maps to the ROADMAP exit)
|
||||
## Future enhancement — truer status (not required; matrix-bridge-side)
|
||||
|
||||
- [ ] Status tile shows the bot's live state and flips correctly across a manual
|
||||
`docker stop` / `docker start` on the Spark.
|
||||
- [ ] `Restart` from the panel cycles the container (status returns to `running`).
|
||||
- [ ] `Update` from the panel pulls a new commit, rebuilds, and recreates the container — and
|
||||
surfaces a clear error if Gitea is unreachable or the build fails.
|
||||
|
||||
---
|
||||
|
||||
## Note — optional future enhancement (not required for Phase 3)
|
||||
|
||||
The *Status* command reports container liveness (process up), not Matrix connectivity — the bot
|
||||
can be `running` yet disconnected from Synapse. A truer signal would need 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, out of scope here — flag it if/when "running but silent" actually bites.
|
||||
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.
|
||||
|
||||
Executable
+64
@@ -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"
|
||||
Executable
+116
@@ -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()
|
||||
+94
-13
@@ -24,7 +24,9 @@ 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):
|
||||
@@ -64,6 +66,14 @@ def split_message(text, limit=MAX_MSG_CHARS):
|
||||
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"))
|
||||
@@ -74,10 +84,12 @@ async def main():
|
||||
device_id = env.get("MATRIX_DEVICE_ID", "matrix-bridge-bot")
|
||||
|
||||
rooms = {r["room_id"]: r for r in cfg.get("room", [])}
|
||||
all_projects_room = cfg.get("all_projects", {}).get("room_id")
|
||||
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)
|
||||
@@ -121,10 +133,16 @@ async def main():
|
||||
out.decode(errors="replace").strip(),
|
||||
err.decode(errors="replace").strip())
|
||||
|
||||
async def say(room_id, text):
|
||||
await client.room_send(
|
||||
room_id, "m.room.message", {"msgtype": "m.text", "body": text}
|
||||
)
|
||||
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)
|
||||
@@ -154,6 +172,46 @@ async def main():
|
||||
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
|
||||
@@ -161,23 +219,46 @@ async def main():
|
||||
if not prompt:
|
||||
return
|
||||
|
||||
if room.room_id == all_projects_room: # fan-out room always launches, never asks
|
||||
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(room.room_id, r, prompt, f"{r['label']} - {date}")
|
||||
launch_one(rid, r, prompt, f"{r['label']} - {date}")
|
||||
for r in rooms.values()
|
||||
])
|
||||
await say(room.room_id,
|
||||
await say(rid,
|
||||
f"matrix-bridge: launched {sum(results)}/{len(rooms)} sessions ({date}).")
|
||||
elif room.room_id in rooms:
|
||||
r = rooms[room.room_id]
|
||||
else: # a mapped project room
|
||||
r = rooms[rid]
|
||||
if prompt.startswith("?"): # headless ask mode
|
||||
ask_prompt = prompt[1:].strip()
|
||||
if ask_prompt:
|
||||
await ask(room.room_id, r, ask_prompt)
|
||||
elif await launch_one(room.room_id, r, prompt):
|
||||
await say(room.room_id,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user