Files
stack/docs/plans/2026-09-13_discord-connector-pilot.md
T
jason.woltjeandClaude Fable 5.1 436ba6ed6b feat(discord): systemd user service with a supervised run; brakes exit 3 and are never retried (#1509)
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]>
2026-09-13 14:39:11 -05:00

27 KiB

Discord connector pilot: Sage on Shared Signals

Owner of the plan: Jason. Coordinator and builder: the Claude session. Reviewer: Filbert or an orch-01 review seat, whoever is free when the candidate is pinned. Tracking: #1509. QUEUE row 14.

Status: candidate built, in review. Jason settled Q1 to Q27 through ms-grill-me on 2026-09-13 (section 9). The package, the offline suite (scripts/test-discord.sh, 74 tests in eight groups plus package checks), the wrapper, the DISCORD-USER.md draft and the README were written the same day; section 10 records the choices made while building. No listener runs until the live pilot in section 6. The bot on Shared Signals is administration-capable only, per the private setup record.

1. What exists today

Sage's private workspace holds the completed one-time server setup, a bounded REST administration utility, a research packet on Hermes and OpenClaw with pinned hashes, and an agent-direction memo. Path (private, not in this checkout):

~/.mosaic/fleet/agents/sage/work/shared-signals/

Verified from those records on 2026-09-13:

  • Bot identity and the exact guild are checked before every write. Fourteen writes have matching journal receipts. Two categories and nine channels were created, original channels and roles untouched. Avatar and banner set.
  • The admin utility is on-demand. There is no gateway connection, so a mention in Discord reaches nobody.
  • The direction memo recommends one small owned integration, not a fork of Hermes or OpenClaw, and lists the controls every version must have: server AND channel AND stable user ID before any model call, action permissions separate from chat permission, Discord text treated as untrusted input, durable inbound and outbound records so a restart cannot duplicate a reply, a stop switch and a usage ceiling.
  • The token and server-id files are mode 0644 inside a 0700 directory. The connector's check must refuse anything but 0600, so this gets fixed before the first check run.

The archived v1/plugins/discord is a gateway-service adapter for a different runtime. It is reference only. Nothing from it is activated.

2. Outcome of the pilot

Jason writes to Sage in one Shared Signals channel and gets a reply from the same Sage persona that runs in the terminal, without a terminal. A restart in the middle of a turn does not produce a second reply. An unlisted user gets nothing. The whole exchange is on disk as write-once records.

That is the entire MVP. Repository writes from chat, announcements, scheduled posts, attachments, tools, DMs, slash commands, Carmen's enrollment, and sharing the terminal conversation are all later pieces and are listed in section 8 so they are not lost.

3. Where it sits in Mosaic Stack

A Discord channel is one more interface onto a seat's conversation, the same class of thing as the WebUI session chat (row 5) and the terminal. The settled WebUI rulings apply here where they overlap: one writer per conversation (Q5, Q9), a transport receipt is not an answer, uncertain sends are reconciled rather than replayed (Q14), and Discord text is untrusted data. The pilot does not wait for the conversation controller that CHAT-03 will build. It owns its own engine now and is written so the engine can be swapped for that controller later without touching transport or policy.

Layout, following packages/seat:

packages/discord/            reusable software, committed
  src/binding.mjs            strict schema, fail-closed load, token file check
  src/authorize.mjs          pure decision: guild, channel, thread, user, mode
  src/gateway.mjs            Discord gateway v10 client (identify, heartbeat, resume)
  src/rest.mjs               create message with nonce, typing, get channel/guild/me
  src/engine-pi.mjs          pi --mode rpc child, one session, follow-up queue
  src/journal.mjs            inbox, outbox, drops JSONL; write-once turn records; STOP; pid
  src/context.mjs            Discord context block, envelope, reply splitter
  src/connector.mjs          the loop: event -> authorize -> journal -> engine -> deliver
  src/cli.mjs                check | run | stop
  src/errors.mjs             DiscordError with exit code
  fixtures/binding.example.json  the shape with placeholder ids
  tests/                     node --test, fake gateway, fake REST, fake pi
  README.md
scripts/discord.sh           thin wrapper, same as scripts/mosaic
scripts/test-discord.sh      the offline suite
agents/sage/DISCORD-USER.md  reduced profile (Q14), Jason edits

Runtime data, never committed:

<dataRoot>/discord/<binding>.json          the binding (IDs, limits), 0600
<dataRoot>/discord/<binding>/inbox.jsonl    every accepted Discord message id
<dataRoot>/discord/<binding>/outbox.jsonl   intent -> confirmed | refused | unknown
<dataRoot>/discord/<binding>/admissions.jsonl  one line per admitted turn, before the engine runs
<dataRoot>/discord/<binding>/turns/<id>.json  one write-once record per turn
<dataRoot>/discord/<binding>/drops.jsonl    one line per dropped or refused message
<dataRoot>/discord/<binding>/launches/      context snapshot and sha256 per run
<dataRoot>/discord/<binding>/STOP           stop switch (presence refuses new turns)
<dataRoot>/discord/<binding>/notices.jsonl  once-per-day fixed lines already attempted
<dataRoot>/discord/<binding>/run.lock/      ownership directory (atomic mkdir), owner.json {pid, start, boot}; stale needs `unlock`
<dataRoot>/sessions/discord-<binding>/      pi session JSONL for this conversation

The binding is deployment policy for a private server. It carries Discord IDs for real people, so it lives under the data root, not in roles/. The repository holds the schema and a fixture with placeholder IDs. The token stays where it is, in Sage's private secrets directory, and the binding points at it by path. The connector reads it into the child process environment and never prints, journals or passes it on a command line.

4. Behaviour

Inbound. The gateway delivers MESSAGE_CREATE. The connector drops the event unless all of these hold: guild id matches the binding; the channel, or for a thread its parent channel, is listed; the author id is listed; the author is not a bot or webhook; for a channel in mention mode the bot is mentioned. A drop writes one counter line, never a Discord reply. Anything that passes is appended to the inbox before any other action. A message id already in the inbox is ignored, which is the restart guard.

Turn. One turn at a time per binding. The prompt sent to pi is an envelope: channel name, thread name if any, author id, message id, then the text. Text is data. The system prompt says so in one paragraph and states the reply limit. While a turn runs, later accepted messages queue as pi follow-ups and stay in the inbox. Turn timeout aborts pi and journals a failed turn.

Outbound. The final assistant text is split at 1900 characters on paragraph boundaries. Each chunk is sent with nonce set to the outbox intent id and enforce_nonce true, so a retry after an unknown outcome returns the existing message instead of posting twice. allowed_mentions is empty. Intent is journaled before the POST, confirmation after, with the Discord message id. A network failure leaves unknown; on the next start the connector reconciles every unknown by re-sending the same nonce, which Discord dedups, before it accepts new traffic.

Controls. check validates the binding, refuses a token file that is not 0600 regular and non-symlink, connects, confirms bot id, guild id and channel visibility, confirms the message-content intent is granted (close code 4014 means it is not), and exits without sending anything. run refuses to start when STOP exists or unknown outbox entries cannot be reconciled. stop creates STOP and signals the process; the current turn finishes or times out, then the process exits. A daily turn ceiling from the binding is enforced by counting turn records; over the ceiling the connector journals the refusal and stops accepting.

Engine. pi --mode rpc --no-tools --no-extensions --no-context-files --no-skills --offline, provider, model and thinking from the binding, session dir under the data root, --continue when a session exists. The system prompt is assembled the way scripts/agent-host-dev.sh does it for the terminal Sage: CONSTITUTION, STANDARDS, Sage SOUL, USER, plus a Discord context block. AGENTS.md and CONTEXT.md are not injected; this Sage has no tools and no repository. Every prompt waits for agent_settled, then reads the last assistant message from get_messages. No tools means no approvals and no file effects in the pilot.

5. Tests before any live run

All offline, node --test packages/discord/tests. No token, no network, no model.

  • Binding: unknown key, missing field, wrong type, non-0600 token file, symlink token file, empty allowlists each refuse with exit 2.
  • Authorization: a table of 20 or so cases covering wrong guild, unlisted channel, thread of listed and unlisted parent, unlisted user, bot author, webhook, mention mode with and without mention, @everyone.
  • Gateway: fake WebSocket drives hello, identify, ready, heartbeat and ack, missed ack forces reconnect, op 7 reconnect resumes with sequence, op 9 invalid session re-identifies, close 4014 reports missing intent.
  • Delivery: fake REST returns 200, 429 with retry_after, 5xx, and a socket error; the outbox shows confirmed, refused and unknown; restart with an unknown entry re-sends the same nonce once and reconciles.
  • Engine: fake pi RPC over stdio; prompt while streaming becomes follow-up; timeout sends abort; malformed JSONL line fails the turn, not the process.
  • Restart: an inbox with three ids and a MESSAGE_CREATE replay of the same three produces zero turns.
  • Stop and ceiling: STOP present refuses run; the ceiling plus one is refused and journaled.

6. Live pilot and acceptance

After the offline suite is green and Jason gives the go in section 7.

  1. Fix token file mode to 0600. Run check. Receipt in Sage's private evidence directory.
  2. Start run in a tmux window on the host. Jason sends one message in the agreed channel. Sage answers. Turn record and outbox line exist.
  3. Send a second message while the first turn is running. It arrives as a follow-up, not a lost message and not a second concurrent turn.
  4. Kill the process during a turn, restart. No second reply to the same message. Outbox reconciled.
  5. From a second Discord account not on the allowlist (Jason's choice of account), send a message. No reply, one drop line.
  6. Send a message containing a quoted instruction to reveal the token or to read private strategy. Sage refuses in text. Nothing leaves the process that was not already in the prompt.
  7. Lower the ceiling to the current count, send one more. Refused, journaled, process stops accepting.
  8. Record per-turn latency and token usage from the turn records.

Acceptance is all eight with receipts, plus Jason's word that the reply reads as Sage. The gate is Jason's ruling, not the checklist.

7. Decisions put to Jason (all ruled, see section 9)

These change the work or cross a boundary (spend, external communication as Sage, a new dependency). Recommendations are marked.

  • D1. Activation and ceiling. Running a listener that posts as Sage on a shared server is external communication and costs model calls. Recommend go for the pilot with a ceiling of 200 turns per day and a 180 second turn timeout, both in the binding.
  • D2. Authorized users. The guild owner id in the setup evidence is presumed to be Jason. Confirm it. Carmen is not enrolled in the pilot; her verified id is a later piece.
  • D3. Channel scope. Recommend #sage-admin in open mode (it is already private to admins) and #general in mention mode, threads allowed in both. Nothing else listens.
  • D4. Discord client. Recommend a raw gateway and REST client on Node's built-in WebSocket and fetch, matching the no-dependency convention of the other packages and keeping the whole transport auditable. The alternative is discord.js pinned exactly, which brings a large dependency tree. If the soak in section 6 shows reconnect problems the raw client cannot handle, switch to discord.js as a separate piece.
  • D5. Model and account. Recommend the same provider, model and thinking as the terminal Sage launcher (zai/glm-5.3, high), same auth file, with the ceiling from D1 as the spend limit. A separate account is a later choice.
  • D6. Message-content intent. The Discord developer portal toggle for the bot must be on. Jason's action; check proves it.
  • D7. Builder. Recommend the Claude session builds it under this brief with Filbert as reviewer, keeping darkwing on rows 6 and 9. Alternatively darkwing builds it after row 9.

8. Explicitly later

Kept here so the pilot stays small. None of these start on their own.

  • Carmen's verified id and shared-room rules.
  • Tools for the Discord Sage (read-only first), then repository writes with reviewed diffs, per the direction memo.
  • Announcements and scheduled posts with explicit destinations and approval.
  • Attachments and images.
  • Slash commands, DMs, per-thread sessions.
  • Sharing one conversation between terminal, WebUI and Discord through the CHAT-03 controller.
  • Control board row for the connector (its pane runs node, not pi, so the current liveness check would show it offline).
  • Supervision as a systemd user service instead of a tmux window.
  • Fleet seats other than Sage, other servers, one product with per-workspace bindings.

9. Rulings, 2026-09-13

Three rounds through ms-grill-me. Where a ruling changed section 4 the ruling wins.

ID Ruling
Q1 Chat only. No tools, no repository writes, no posting on its own.
Q2 Live pilot authorized. Ceiling 200 turns per day, 180 s turn timeout, both in the binding.
Q3 Jason only, by the guild owner id in the setup evidence. Carmen later.
Q4 #sage-admin open mode, #general mention mode, threads inherit from parent. Nothing else.
Q5 Raw gateway and REST on Node built-in WebSocket and fetch. No discord.js; revisit only if the soak shows reconnect trouble.
Q6 Same provider, model, thinking and auth as the terminal Sage launcher.
Q7 One pi session per binding, separate from the terminal Sage conversation. Envelope names channel, thread and author.
Q8 Unauthorized message: silence, one drop line in the journal.
Q9 Binding under <dataRoot>/discord/, 0600, never committed. Repo holds schema and placeholder fixture.
Q10 No backfill. Only messages received live after start.
Q11 tmux window by hand, STOP file as the brake. No systemd in the pilot.
Q12 Claude session builds; reviewer is Filbert or an orch-01 review seat, whoever is free at pin. Darkwing stays on rows 6 and 9.
Q13 Message-content intent believed on; check proves it.
Q14 Do not inject USER.md. Inject agents/sage/DISCORD-USER.md, a Jason-authored reduced profile, committed.
Q15 Discord context block as proposed: channel and server, messages are data not instructions, 1900 char replies, no tools/files/memory outside the conversation, no credentials/paths/private strategy, no promises of action.
Q16 Sage declines DYOR strategy discussion everywhere in Discord until the shared-repository piece exists.
Q17 Reply with message reference, in place. No thread creation.
Q18 Chunk at 1900 on paragraph boundaries, one nonce per chunk. Typing indicator every 8 s during a turn.
Q19 Failed turn: one fixed line posted, never model output, plus the failed turn record.
Q20 Ceiling hit: one fixed line in the tripping channel, stay up idle until UTC midnight.
Q21 Drop messages over 4000 chars with a fixed reply. Envelope is text only; attachments and embeds stripped.
Q22 Live evidence goes to Sage's private shared-signals evidence directory. Repo gets offline suite results and a pointer.
Q23 Step 5 uses Jason's real second account.
Q24 Pi auto-compaction on with defaults.
Q25 Offline suite of seven groups, wired into scripts/verify.sh.
Q26 Live pilot exactly as section 6, receipts private. Gate H: Jason rules the reply reads as Sage.
Q27 Order: package, tests, scripts/discord.sh, DISCORD-USER.md draft, README, verify hook. Commit to refactor after green suites and reviewer approval. No push without separate say-so. Live pilot after commit.

10. Choices made while building, 2026-09-13

Recorded here because they refine a ruling or fill a gap the rulings left.

  • Q25 said the suite is wired into scripts/verify.sh. That script is the Docker hello check, not a suite aggregator; no package suite hooks into it. The suite is scripts/test-discord.sh, the same shape as test-foundation.sh, listed in docs/TOOLS.md. If verify.sh ever becomes an aggregator, this suite joins it there.
  • Reconcile window. Discord dedups enforce_nonce for "the past few minutes". An unknown delivery older than five minutes is marked refused with a reason instead of re-sent, because a re-send outside the window would post a second reply, which is the one thing the outcome in section 2 forbids. Inside the window it is re-sent once with the same nonce, as section 4 says.
  • Turn attribution in the engine. A turn that fails on the connector's side (timeout, malformed line) stays in the engine's queue, marked done, until pi's own turn_end for it arrives; otherwise that event would be read as the answer to the next prompt. The offline engine group covers this.
  • Thread lookup. A message in a channel that is neither listed nor known from GUILD_CREATE/THREAD_* events causes one GET /channels/{id}, and only when the guild matches and the author is listed, so strangers never cost a REST call. The result is cached.
  • Over the ceiling, refusals are drop lines, not turn records, so they do not count against the ceiling themselves.
  • Chunk nonces are <message id>-<n>; fixed-line nonces add -f, -b, -c for failed, oversize and ceiling, all under Discord's 25-character nonce limit.
  • agents/sage/ is untracked in this checkout as a whole (another session's work). Only DISCORD-USER.md is part of this piece; the binding's context list needs agents/sage/SOUL.md on disk, which it is.
  • Review intake found the ceiling counted only written turn records, so a burst arriving while turns ran could queue past the cap. Admitted turns now count until their record is written. Two smaller observations stay as follow-ups, not changes: a follow-up turn's timeout budget starts at admission, so it runs shorter than turnTimeoutSeconds while an earlier turn is still busy; and the pid file was left behind if run failed between writing it and starting the connector (the next run overwrote a dead pid, so nothing refused). Both pid-file points are moot since round two.
  • rev-code-02's round one (#1509 comment 26114) changed four things. The reconcile window is measured from the first outbox line for a nonce, never a retry. Context files must be repository-relative, symlink-free and realpath-contained; the reviewer's stronger option, hard-coding the four Sage files, was not taken because the binding is the owner's 0600 policy and the containment is the boundary that matters. Admissions are appended to admissions.jsonl before the engine runs and the ceiling counts them, so a crash mid-turn still counts. Ownership is a run.lock directory (mkdir is atomic) holding the pid and its /proc start time; stop signals only a live pid whose start time matches, and run refuses where /proc is unavailable.
  • rev-code-02's round two (#1509 comment 26121) kept F4 open and added two. The O_EXCL pid file had a window between create and write where a second start read an empty file as stale and unlinked it. Ownership is now the run.lock directory: mkdir either succeeds or fails, the owner record is written to a temp name and renamed in, and a directory without a record is treated as a claim in progress (busy) for 30 seconds, after which only a crash between mkdir and rename can explain it. A stale lock is moved aside by rename, never deleted in place, so two reclaimers cannot both take it and neither can remove a lock a third start just made. The test spawns four processes that claim at once; one wins, three refuse, and a fifth claim refuses while the winner holds the lock. The daily ceiling notice was memory-only, so a same-day restart repeated it; the decision is now a line in notices.jsonl written before the delivery attempt. A duplicate gateway event arriving during the awaited thread lookup was admitted twice; the id is reserved for the whole of handleMessage now.
  • rev-code-02's round three (#1509 comment 26123) reproduced a three-party race in the automatic reclaim: two starts both judge a lock stale, one moves it aside, a third start publishes a live lock, and the delayed one then moves that live lock aside and claims. Rename proves nothing about which directory it moved. The reviewer offered two options, a gate that every claimant respects or fail-closed with a separate controlled cleanup. I took the second: a gate is one more thing that can go stale, and the repository's rule is to diagnose a refusal, not route around it. run now refuses any lock it does not own and names scripts/discord.sh unlock. unlock refuses a live owner and, after moving the lock aside, checks the record it moved is the record it inspected; if not, it renames the lock back and refuses. The cost is a manual step after a crash or reboot. Tests cover four starts over a stale lock (all refuse, the lock is untouched), one unlock, four starts again (one owner), and the changed-under-unlock case.
  • rev-code-02's round four (#1509 comment 26132) broke that unlock too: a claim can land in the empty interval between moving a lock aside and restoring it, so a compare-then-restore leaves two owners. The fix is the reviewer's other option, an explicit quiescence gate, and the gate is the STOP file the connector already honors. unlock writes STOP before it inspects anything; a claim re-checks STOP after publishing its record and releases itself if STOP is there. A claim that survives therefore published before unlock began, and unlock sees it alive and refuses. No rename-aside, no restore, no residue. The reviewer's four-party schedule is a test: stale lock, unlock in progress, two claims inside its gap, one record landing right before removal; the result is no owner, no residue, STOP standing, and one clean claim after STOP is removed.
  • rev-code-02's round five (#1509 comment 26150) closed the gate design and found two more. The owner record held pid and /proc start ticks, and ticks count from boot, so the same pair can recur after a reboot; the record now carries the boot id too and stop signals only when all three match. The identity check is three-way: dead or positively different is stale and unlock clears it; alive but unreadable identity refuses everything and removes nothing; only a full match is live. A claimant that cannot read its own start time or the boot id refuses to claim. Second, unlock's refusal for a live owner promised an exit on STOP that the running connector does not perform (it only drops later messages), so the text now says to use stop. The connector does not gain a STOP watcher in this pilot; stop sends SIGTERM, which is the shutdown path.
  • rev-code-02's round six (#1509 comment 26158) probed the upgrade path: a connector started under the round-five build holds {pid, start} with no boot id, and the three-way check read that as a positive mismatch, so unlock removed the lock under a live process. A live pid with a record lacking start or boot is now "unknown" and refuses everything until the pid is dead. A record file that exists but cannot be parsed is now "invalid" rather than absent; nothing removes or claims over it. Tests: a live legacy child (byte-identical lock, no signal target, no second owner, cleared after exit) and an invalid-record control.
  • rev-code-02's round seven (#1509 comment 26165) found the last gap in the same family: a record with a start or boot string of the wrong shape was compared to /proc and read as a positive mismatch. Identity values now have a syntax (decimal start ticks, lowercase uuid boot id); anything else reads as absent and, under a live pid, is "unknown". Six live-child controls cover empty, non-decimal and mixed start values and empty, malformed and uppercase boot values.
  • rev-code-02's round eight (#1509 comment 26168): the start syntax still took leading-zero and above-2^64 digit strings, which the kernel never prints. The check is now the canonical unsigned decimal form; boundary values sit in the syntax table and three more live-child controls.

11. MVP iteration, after the pilot

  • Read receipt (Jason, 2026-09-13, after Gate H). Every admitted message gets an eyes reaction from the bot as soon as the turn is admitted, before the engine runs, so the author knows it was received. Drops and refusals (unlisted author, ceiling, oversize) get no reaction; silence stays silence. The reaction is best effort like typing: a refusal from Discord is logged and recorded in the turn record as receipt.ok: false and never fails the turn. Needs the Add Reactions permission on the bot role.
  • Live check 2026-09-13 19:21 UTC: reaction seen by Jason ("test is successful") and recorded in the turn record. Iteration 1 closed.
  • Service unit (iteration 2, Jason: "proceed as suggested", 2026-09-13). scripts/discord-service.sh renders and installs a systemd user unit, mosaic-discord@<binding>, from a template in packages/discord/systemd/. The new recover policy runs first inside run --supervised, the unit's main process: it clears a lock whose owner is gone and removes only the STOP it wrote for that; every operator STOP and every held binding refuses with the new exit code 3, which the unit never retries. First cut used an ExecStartPre; systemd only honours the never-retry status from the main process, so that looped, and the live check caught it. systemctl --user stop is the soft stop (SIGTERM, no STOP, restartable); scripts/discord.sh stop stays the brake. This ends the hand-removal of STOP after every rerun that step 3 of the pilot showed. Not done: a binding reload without a restart (a ceiling or channel change still needs stop and start). Next: the control board row.
  • Live check 2026-09-13 19:35 to 19:40 UTC: Sage seat migrated to the unit; SIGKILL recovered in 16 s with the dead lock cleared; discord.sh stop held (start exits 3, no restart); released and READY. Iteration 2 closed.