QUEUE row 17, MVP iteration 2. scripts/discord-service.sh renders and installs mosaic-discord@<binding> from packages/discord/systemd/. The unit's main process is `run --supervised`, which applies the new recover policy first: a lock whose owner is gone is cleared and only the STOP written for that is removed; an operator STOP or a held binding refuses with exit 3, which RestartPreventExitStatus never retries. `recover` is also a CLI verb. First cut used ExecStartPre and looped live, since systemd honours the never-retry status only from the main process; replaced and re-verified before any message traffic. Suite 40/40, 95 node tests. Co-Authored-By: Claude Fable 5.1 <[email protected]>
11 KiB
discord
The Discord connector: one seat's conversation reachable from listed
channels of one Discord server, chat only. Issue #1509, brief
docs/plans/2026-09-13_discord-connector-pilot.md. Plain ESM, no
dependencies, Node 24 or newer, built-in WebSocket and fetch.
A Discord channel is one more interface onto a seat's conversation, the same
class of thing as the terminal and the WebUI session chat. The connector owns
a pi --mode rpc engine today; that module is the one to swap when the
CHAT-03 conversation controller exists.
Commands
scripts/discord.sh check <binding>
scripts/discord.sh run <binding> [--supervised]
scripts/discord.sh stop <binding>
scripts/discord.sh unlock <binding>
scripts/discord.sh recover <binding>
scripts/discord-service.sh render | install | uninstall | status <binding>
<binding> names <dataRoot>/discord/<binding>.json. The wrapper passes
--repo for you; the CLI also takes --config PATH.
checkvalidates the binding, the token file (0600, regular, not a symlink), the context files and the pi install; reads the bot, the guild and every listed channel over REST; opens one gateway connection, waits for READY, closes it. Nothing is sent to a channel. Close code 4014 is reported as the message-content intent not being granted in the developer portal.runrefuses whenSTOPexists or an unresolved delivery cannot be reconciled. Otherwise it starts pi, connects, and serves turns until SIGTERM, SIGINT orstop. Run it under the service unit below, or by hand in a tmux window.stopwritesSTOPand sends SIGTERM to the process inrun.lock, only when that pid is alive and both its start time and the boot id match the recorded ones; a reused pid, a pid from a previous boot, or a pid whose identity cannot be read right now is never signaled. The current turn finishes or times out, then the process exits. RemoveSTOPto run again.unlockwritesSTOP, then removes arun.lockwhose owner is gone (crash, reboot, a start interrupted before it published its record). It refuses while the owner is running; usestopfor that. It also refuses, removing nothing, when the pid is alive and its identity cannot be established: the record predates the boot id (an upgrade over a running connector), a recorded value is not a start tick or a boot id, or /proc cannot be read right now. Once that pid is dead,unlockclears it. A record file that exists but cannot be parsed is never removed or claimed over; inspect it by hand.STOPis the gate that serializes cleanup with starts: arunre-checksSTOPafter publishing its record and releases itself if it is there, so nothing that starts during an unlock can hold the binding.runnever reclaims a stale lock on its own; it refuses and names this command. RemoveSTOPto run again.recoveris the supervised pre-start;run --supervisedperforms it first, in the same process, and that is the form the service unit uses, because systemd honours a never-retry exit status only from the main process. It refuses, with exit 3 and touching nothing, whileSTOPis present or the binding is held by a live process, an alive pid whose identity cannot be verified, or an unreadable record. A lock whose owner is gone, or that has no record, it clears the wayunlockdoes, then it removes theSTOPit wrote for that so the run that follows can claim. It removes only aSTOPthat consists of the one line it wrote itself; a brake an operator wrote at any point, even during the recovery, stays and the start is refused. Nothing automatic ever removes an operator'sSTOP.
Exit codes: 0 ok, 1 operation failed, 2 invalid data or configuration, 3
refused by a brake (STOP present or the binding held; a supervisor must
not retry), 4 usage.
Service unit
scripts/discord-service.sh install renders
packages/discord/systemd/[email protected] with the repository
path and the directory of node, writes it to
~/.config/systemd/user/[email protected] (temp file, then rename;
--dir DIR for another place, --no-reload to skip daemon-reload) and
prints the commands that follow. One instance per binding:
systemctl --user enable --now mosaic-discord@<binding> start now and at login
systemctl --user status mosaic-discord@<binding>
journalctl --user -u mosaic-discord@<binding> -f the log (stderr of `run`)
systemctl --user stop mosaic-discord@<binding> SIGTERM; the turn in flight finishes; restartable
scripts/discord.sh stop <binding> the brake: writes STOP; the unit stays down until STOP is removed
scripts/discord-service.sh status <binding> unit state, STOP, run.lock
What the unit does: ExecStart runs run --supervised,
Restart=on-failure with 15 seconds between tries and at most five in ten
minutes, and RestartPreventExitStatus=3 so a brake is never retried. A
crash (any other non-zero exit, a signal, an engine that died) restarts,
and the supervised run clears the dead lock on its way in. systemctl --user stop
sends SIGTERM only; the connector exits 0 and no STOP is written, so the
next start needs no hand. The stop timeout is 3700 seconds, the largest turn
timeout a binding may set plus margin; a normal stop takes as long as the
turn in flight. The unit never reads the binding or the token; run does,
at runtime, as before. Surviving logout and reboot needs
loginctl enable-linger. render prints the unit without writing it;
uninstall refuses while an instance is active. The log goes to journald:
run writes no message text to stderr, only ids, counts and state.
The binding
Deployment policy for one seat on one server. It carries Discord ids of real
people, so it lives under the data root at mode 0600 and is never committed.
fixtures/binding.example.json is the shape with placeholder ids; the schema
is src/binding.mjs.
| Field | Meaning |
|---|---|
bindingVersion |
1 |
name, seat |
binding name (matches the file name) and the seat it serves |
guildId, guildName, botUserId |
the one server and the bot identity check confirms |
tokenFile |
absolute path to the bot token, 0600, read into memory at start, never printed or journaled |
channels[] |
{id, name, mode}; open answers every message, mention only when the bot is mentioned; threads inherit the parent's mode |
users[] |
{id, name}; the only authors that get a turn |
engine |
provider, model, thinking for pi |
limits |
turnsPerDay (200), turnTimeoutSeconds (180), replyChunkChars (1900), inboundMaxChars (4000) |
context.files[] |
files appended to pi's system prompt in order, repository-relative and inside the repository (no absolute paths, .. or symlinks); the Discord block is added after them |
Unknown keys, missing fields, wrong types, empty allowlists and a bot listed as a user all refuse with exit 2.
What happens to a message
- The gateway delivers
MESSAGE_CREATE.authorizedrops it unless the guild matches, the author is listed and is not a bot, webhook or the bot itself, the channel or the thread's parent is listed, and inmentionmode the bot is inmentions(@everyonedoes not count). A drop is one line indrops.jsonland no reply. - The message id is appended to
inbox.jsonlbefore anything else. On start the inbox is read back; a replayed id is dropped asduplicate. That is the restart guard. STOP, an oversize message and the daily ceiling are checked next. Over size gets one fixed line. Over the ceiling gets one fixed line per UTC day, then silence until midnight UTC; the process stays up.- The prompt is an envelope, one bracketed line naming server, channel, thread, author id and message id, then the text. The system prompt says that text is data. A message that arrives during a turn is queued in pi as a follow-up, so it is neither lost nor run concurrently. As soon as the turn is admitted the connector reacts to the inbound message with eyes as a read receipt; a typing indicator follows every 8 seconds while the turn runs. A reaction Discord refuses is logged and recorded in the turn; it never fails the turn.
- The reply is split at 1900 characters on paragraph boundaries. Each chunk
is posted with
nonceandenforce_nonce: true,allowed_mentionsempty, and the first chunk as a reply to the inbound message. An intent line goes tooutbox.jsonlbefore the POST and aconfirmed,refusedorunknownline after it. A chunk that is not confirmed stops the rest of that reply. - One write-once record per turn lands in
turns/<message id>.json: ids, timing, usage, read-receipt outcome, delivery outcome, and the error on a failed turn. A failed turn posts one fixed line, never model output.
On start, every intent or unknown delivery is reconciled by sending the
same nonce again; Discord returns the existing message instead of posting
twice. An intent older than five minutes is outside Discord's dedupe window
and is marked refused rather than re-sent, because a re-send could post a
second reply. If anything is still unknown after that, run refuses to
start and names the nonces.
Runtime data
<dataRoot>/discord/<binding>.json the binding, 0600
<dataRoot>/discord/<binding>/inbox.jsonl accepted message ids
<dataRoot>/discord/<binding>/outbox.jsonl delivery intent and receipts, by nonce
<dataRoot>/discord/<binding>/drops.jsonl one line per dropped or refused message
<dataRoot>/discord/<binding>/admissions.jsonl one line per admitted turn, before the engine runs
<dataRoot>/discord/<binding>/turns/<id>.json write-once turn records
<dataRoot>/discord/<binding>/launches/ context snapshot and sha256 per run
<dataRoot>/discord/<binding>/STOP stop switch
<dataRoot>/discord/<binding>/notices.jsonl once-per-day fixed lines already attempted
<dataRoot>/discord/<binding>/run.lock/ ownership directory (atomic mkdir) with owner.json {pid, start, boot}; stale ones need `unlock`
<dataRoot>/sessions/discord-<binding>/ the pi session, continued across runs
Directories are 0700, files 0600. Logs are append-only; turn records are
written with O_EXCL and never rewritten.
Tests
scripts/test-discord.sh or node --test packages/discord/tests/. All
offline: fake WebSocket and timers for the gateway, fake fetch for REST, a
scripted stand-in for pi over stdio, a disposable data root. Groups: binding,
authorization table, gateway (hello, identify, heartbeat, missed ack, op 7,
op 9, close 4014), delivery and reconcile, engine (follow-up, timeout,
malformed line), restart replay, stop and ceiling.
Not in this piece
Tools, repository writes, announcements, attachments, slash commands, DMs, per-thread sessions, more than one server or seat, a control-board row. Section 8 of the brief keeps the list.