Imported from zeroaltitude/theseus (
crates/theseus-discord/AGENTS.md). Install upstream withnpx skills add zeroaltitude/theseus --skill theseus-discord. Copyright stays with the author.
theseus-discord
The Discord binding (spec P5, M3): one guild's text channels and direct messages, in the daemon's process. Read by theseusd.
Key modules: runtime.rs, courier.rs, render.rs. Read by: theseusd.
What's here
src/runtime.rs: the gateway loop and the places (a text channel or a DM, each backed by one session), with the slash commands and the confirm buttons.Routes::resolvefinds a message's or an interaction's place.src/courier.rs: durable delivery, the binding's side: one lane per place, and one for the operator's notices.src/render.rs: a session's events as Discord messages. Pure: events in, messages out.src/bindings.rs(the bindings file;bindings.example.tomlis its format),src/files.rs(attachments),src/viewers.rs(who can view a channel), andsrc/rpc_client.rs(the in-process protocol connection).- Places (the place rule, theseus-nbsh): a
[[channel]]withprivate = trueis a private place (its session gets everything); any other guild channel is shared (the public tools alone). The binding tells the core its places as it starts (Core::bind_places), before it reads a message, and reads each private channel's viewers once, after the gateway connects (check_private), so health warns when anyone besides the owner can view it. It reads no viewers before a turn or a post, and loops stream everywhere. One walk of a channel's viewers (runtime/audience.rs,view) serves that read and the approval check (theseus-sgh)./publish(runtime/publish.rs) goes to the core'splace.publishas the presser, which the core judges: only the owner, from a private place.
Invariants
- What a person does goes through the protocol (a message is
turn.submit, a pressaction.confirm,/stopexecution.stop,/trustpolicy.trust), so the core judges Discord as it judges the CLI and the web UI. What the binding delivers and reports, it reads and writes in the core directly: the outbox, the cards' questions, its ledger rows (Item 30). - What must be seen is an outbox post, written by the core when it happens: a reply, a card, a card's settle, a failed turn. Live progress (streamed text, tool lines, typing) is best effort and never replayed (Item 6).
- One lane per place is the only writer of its messages: posts first, in order, then live progress. A create
carries a nonce from its message's key, with
enforce_nonce, so a retry after a crash returns the first message. - A place answers only where its bindings file binds it. An interaction in an unbound place gets no answer, so daemons on one bot token with disjoint bindings each answer their own places (Item 11). A card in a guild channel mentions exactly its answerers, and nothing else mentions anyone (Item 15).
- Slash commands are bare names (
/new,/stop,/trust, …), and each control has one effect (Item 9).
Tests
src/tests_outbox.rsdrives the binding againsttheseus_sim::fake_discord(REST: it honours a nonce as Discord does, and can be down, hang creates, or fail). Point a daemon at it with[discord] rest_proxyandgateway_proxy.src/tests_gateway.rsdrives it through the stand-in's gateway too (theseus-6g62):FakeDiscord::saytypes a message as a user, andpresspresses a button the binding posted, each sent as Discord sends it;replies()is what the binding answered each press, and each message keeps every version (theseus-qifw). A guild set on the fake (set_guild) answers the viewer check, so a card in a trusted channel is tested end to end (theseus-ck0k). Its core starts the continuation driver, as the daemon does, or an approved call never runs.theseus-sim discord proof --theseusd <bin>runs a typed message, a card, a refused press, and an Approve against a real daemon on the stand-ins in about 3 s; theseusd'stests/discord_proof.rsruns it in the gate. A step that changes the binding uses it as its live check. Slash commands, select menus, attachments, and a dropped gateway are not driven through the stand-in yet: tests drive those throughon_interactionandplace_for_tests.split_text(src/render.rs) has property tests: a message split past Discord's 2,000-character limit must never loop or panic.
Traps
- Never bind the operator's places from a second daemon. A scratch daemon on Discord binds only a test channel, runs on a fresh state dir (never a copy of the operator's store, theseus-c3e), and carries no copy of the operator's bindings file.
- Registering commands is global to the bot: a scratch daemon's command list replaces the installed one's until the operator's daemon next starts.
- A test that reads the channel waits for every message it reads: the outbox draining doesn't mean the live tool line has landed.
- A ledger row names a Discord id as a string. Clippy's
cmp_ownedturnsrow["author_id"] == ID.to_string()into== ID, a JSON string against a number that never matches: bind the string first.
