CIRCADA IRC stack for Lilush

Overview

IRC client/server stack for the Lilush shell.

This lilpack ships three things in one package:

  1. A Lua library for IRC — circada.protocol, circada.client, circada.server, circada.store, circada.bouncer. RFC 2812 + IRCv3 (CAP, SASL PLAIN, server-time, message-tags, echo-message, batch, account-tag, away-notify), built on Lilush's async runtime (LEV) and TLS 1.3 stack (LITLS).

  2. circada — a long-running multi-network IRC bouncer daemon. Maintains your upstream connections, persists scrollback in MNEME db, exposes a circada-aware single-connection multiplexed local IRC endpoint for clients.

  3. circada shell mode — a Lilush mode that connects to the bouncer and gives you a real-time chat UI: per-channel buffers, alt-screen view, slash commands, theme-driven rendering.

This document primarily describes the bouncer and shell mode. The standalone Redis-backed IRC server (circada-server, circadactl-server, circada.server) is documented separately in SERVER.md.

Architecture

                ┌────────────────────────────┐
                │  Lilush shell              │
                │  ┌──────────────────────┐  │
                │  │ circada mode (UI)    │  │
                │  └─────────┬────────────┘  │
                │            │ TCP loopback  │
                └────────────┼───────────────┘
                             │  IRC + circada.io/multiplex CAP
                             │  PASS=<loopback_token>
                ┌────────────▼───────────────┐
                │  circada bouncer (daemon)  │
                │  ┌──────────────────────┐  │
                │  │ multiplexing router  │  │
                │  └─┬──────────┬──────┬──┘  │
                │    │          │      │     │
                │ ┌──▼──┐    ┌──▼──┐ ┌─▼──┐  │
                │ │libera◀──▶│oftc │ │…   │  │  TLS 1.3 outbound
                │ └─────┘    └─────┘ └────┘  │
                └────────────────────────────┘
                             │
                             ▼
                  ~/.local/share/lilush/circada.mneme
                  (networks, scrollback, secrets, state)

Wire-protocol detail: every cross-network frame on the loopback carries an IRCv3 message-tag +circada/net=<network_id>. Inbound it tells the mode which network the frame is from; outbound it selects the destination upstream. Untagged PRIVMSG to the pseudo-target *circada is reserved for admin commands. The loopback endpoint is for circada-capable clients only: clients must negotiate the circada.io/multiplex CAP and understand these tags.

Installation

CIRCADA is a LILPACK, so the standard pack/install flow applies.

Install the latest official version in Lilush Shell: lilpack install circada

After install, restart the shell so the new mode is loaded. To bind the mode to an F-key, edit ~/.config/lilush/modes.json:

{ "F6": "circada" }

Quick start (Libera)

  1. Point CIRCADA_ENCRYPTION_KEY at an unencrypted ed25519 SSH key

    export CIRCADA_ENCRYPTION_KEY=~/.ssh/id_ed25519

    Without this set the bouncer refuses to start.

  2. Create the bouncer config in ~/.config/circada/config.json:

    {
     "host": "127.0.0.1",
     "port": 6688,
     "networks": [
         {
             "network_id": "libera",
             "server": {
                 "host": "irc.libera.chat",
                 "port": 6697,
                 "tls": true,
                 "sasl": true
             },
             "auth": {
                 "nick": "your_nick",
                 "user": "your_nick",
                 "realname": "Your Name"
             },
             "exec_on_connect": ["JOIN #lilush"]
         }
     ]
    }

    caps is optional; omit to negotiate the built-in default (sasl, away-notify, echo-message, message-tags, server-time, account-tag, account-notify, extended-join, batch, chghost).

  3. Launch the bouncer:

    circada

    On a fresh install the bouncer comes up but skips any sasl=true network that has no stored password. You'll see a line like:

    [circada.bouncer] [libera] no stored password — skipping; set via /circada password set libera <pwd>

    That's expected. Daemonize it freely, no TTY input is ever needed.

    tls_cafile, tls_verify and other TLS knobs default to sensible values (verify on, /etc/ssl/certs/ca-certificates.crt as the trust store), so the config above is everything you need on a typical Debian/Ubuntu/Arch box.

  4. Set the password from inside the shell mode. In a Lilush shell, press F6 (or whichever F-key you mapped) to enter the mode:

    /connect
    /circada password set libera <your-nickserv-password>

    The bouncer encrypts the password into the secrets keyspace and attempts to spawn the upstream supervisor. The mode opens a *circada admin buffer with the reply:

    <*circada> password set for libera — connecting
    <*circada> if no connect appears within ~10s, restart: pkill circada; circada

    The auto-spawn path is best-effort. If you don't see libera connection events ([libera] supervisor started in the bouncer log, or JOIN events arriving in /window libera #lilush) within a few seconds, restart the bouncer — the password is already persisted in the encrypted store, so the boot-time supervisor will pick it up and connect normally.

  5. Start using IRC:

    /window libera #lilush

    You're in. ESC leaves the alt-screen view back to the prompt. ^N / ^P cycle buffers. ^O toggles the nicks pane. PgUp / PgDn scroll. TAB completes nicks (or commands when the input starts with /). /help lists slash commands.

    From this point on, restarting the bouncer (pkill circada; circada) brings libera back online automatically — the password lives encrypted in the store.

Configuration reference

~/.config/circada/config.json is read by the daemon at startup. The file is optional — networks can also be added at runtime via circadactl network add. When both sources define the same network_id, the file wins on each restart (it's upserted into the store).

Top-level fields

FieldTypeDefaultDescription
hoststring"127.0.0.1"Loopback bind address
portint6688Loopback listen port
require_authbooltrueRequire PASS=token from local clients
scrollback_replayint50Messages replayed per channel on mode connect
scrollback_keepint10000Messages kept per channel before trim
trim_intervalint300Seconds between scrollback-trim sweeps
ping_intervalint90Seconds between PINGs to idle local clients
shutdown_graceint5Seconds to wait after SIGTERM before forcing exit
store.pathstring~/.local/share/lilush/circada.mnemeMNEME database path
store.encryption_key_filestringunsetSSH ed25519 key to encrypt the DB at rest (optional, see Security below)
networksarray[]Upstream IRC network configs (see below)

Per-network fields

FieldTypeRequiredDescription
network_idstringyesStable identifier (libera, oftc, …); used in tags, CLIs, the *circada admin pseudo
server.hoststringyesUpstream hostname (resolved via system DNS)
server.portintyesUpstream port
server.tlsboolnoUse TLS 1.3 (LITLS). Default: false
server.tls_verifyboolnoVerify peer cert. Default: true when tls=true
server.tls_cafilestringnoCA bundle path. Default: /etc/ssl/certs/ca-certificates.crt
server.tls_hostnamestringnoSNI override; defaults to server.host
server.saslboolnoPerform SASL PLAIN after CAP REQ
auth.nick / auth.user / auth.realnamestringyesIdentity sent in NICK/USER. No password field.
capsarraynoOverride the default IRCv3 CAP list. Omit to use the built-in default.
exec_on_connectarraynoRaw IRC frames sent immediately after registration (e.g. autojoin)

Secrets handling

There is exactly one place SASL passwords live: the encrypted secrets keyspace inside ~/.local/share/lilush/circada.mneme. The keyspace uses MNEME's per-keyspace AEAD encryption (ChaCha20-Poly1305) keyed off the file pointed to by CIRCADA_ENCRYPTION_KEY. This is the same mechanism the Lilush shell itself uses for its own secrets — a single SSH ed25519 key unlocks every component.

Setting the key (one-time)

export CIRCADA_ENCRYPTION_KEY=~/.ssh/id_ed25519

Put it in your shell init (.profile, init.lsh, etc.) so every process that opens the circada DB sees it: the daemon, circadactl, the shell mode. Without it set, the bouncer refuses to start (encryption not configured); circadactl status and the mode also fail to load secrets.

The key file must be an unencrypted OpenSSH ed25519 private key. If yours is passphrase-protected, generate a separate one purely for Lilush data-at-rest and chmod 600 it.

Setting / changing a network's password

Always done live, from inside the shell mode, via the /circada admin command. There is no startup prompt.

First-time activation of a network (sasl=true, no stored password):

/circada password set libera mynickservpassword

The bouncer:

  1. Validates the network exists in the store (typo guard).

  2. Stores the password encrypted at secrets:sasl:libera.

  3. Attempts to spawn the upstream supervisor for libera.

  4. Replies password set for libera — connecting and a fallback hint (if no connect appears within ~10s, restart: pkill circada; circada).

The auto-spawn path is best-effort. If it doesn't bring libera online (no [libera] supervisor started log line within a few seconds), the password is still safely persisted — restart the bouncer and the boot-time supervisor will pick it up.

Rotation of an already-running network: same command. The bouncer:

  1. Updates the encrypted secret.

  2. Pokes the new password into the live client config.

  3. Force-reconnects libera (other networks unaffected).

  4. Replies password rotated for libera — reconnecting.

Clearing a password (rare; useful when migrating identities or disabling SASL):

/circada password del libera

CLI reference

circada — bouncer daemon

circada                     # uses ~/.config/circada/config.json
circada -c /path/cfg.json   # explicit config path
CIRCADA_CONFIG=/etc/circada/config.json circada

Behavior:

Set CIRCADA_DEBUG=1 to log every IRC frame in/out (verbose). Don't combine with password set — see Secrets handling caveat above.

Running as a systemd user unit

Drop the following at ~/.config/systemd/user/circada.service:

[Unit]
Description=CIRCADA IRC bouncer
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
# Resolve ~ inside the unit (systemd does NOT expand it for ExecStart).
ExecStart=%h/.local/bin/circada
Environment=CIRCADA_ENCRYPTION_KEY=%h/.ssh/id_ed25519
# Optional: explicit config path (defaults to ~/.config/circada/config.json).
# Environment=CIRCADA_CONFIG=%h/.config/circada/config.json
Restart=on-failure
RestartSec=5
# Graceful shutdown matches `shutdown_grace` in config.json (default 5s).
TimeoutStopSec=10
# Lock the bouncer's filesystem footprint down a notch — it only needs
# its own state dir, the config, and the SSH key.
ProtectSystem=full
ProtectHome=read-only
ReadWritePaths=%h/.local/share/lilush %h/.config/circada
PrivateTmp=true
NoNewPrivileges=true

[Install]
WantedBy=default.target

Then:

systemctl --user daemon-reload
systemctl --user enable --now circada.service
journalctl --user -u circada.service -f         # tail the log
systemctl --user reload circada.service          # SIGHUP — reload config
systemctl --user restart circada.service         # SIGTERM + restart

systemctl --user reload issues SIGHUP to the running bouncer (same effect as circadactl reload or kill -HUP <pid>). To survive logout on a non-graphical session, enable lingering once:

loginctl enable-linger $USER

circadactl — control CLI

circadactl status
circadactl token
circadactl networks
circadactl network show <id>
circadactl network add <id> <host> <port>
                       [--tls] [--sasl]
                       [--nick <nick>] [--user <user>]
                       [--realname <text>]
circadactl network remove <id>
circadactl reload

Read-only commands (status, token, networks, network show) and reload work while the bouncer is running. Mutating commands (network add/remove) need the writer flock, so stop the bouncer first OR use the live admin equivalents — /circada network add / /circada network remove from the mode — which talk to the running bouncer and don't fight for the flock:

pkill circada
circadactl network add hackint irc.hackint.org 6697 --tls --sasl \
    --nick mynick --user mynick
circada     # bouncer comes up; hackint is skipped (no password yet)

Then in the shell mode:

/circada password set hackint <pwd>     # spawns the supervisor; connects

circadactl reload sends SIGHUP to the running bouncer (pid is read from the store's meta keyspace) and triggers a config re-read — same effect as systemctl --user reload circada.service or /circada reload from the shell mode. New networks declared in the config file get upserted; already-connected networks are untouched.

circadactl token prints the loopback PASS token; the shell mode reads it automatically, but you can use it to plug weechat / irssi / any other IRC client into the bouncer too.

Shell mode reference

From the prompt (outside the alt-screen view)

CommandEffect
/connectOpen the loopback connection to the bouncer
/disconnect, /quitClose the loopback connection
/statusPrint connection state
/networksList networks the mode has seen activity from
/buffersList active buffers (network/target, msgs, unread)
/window <net> <target>, /w …Switch active buffer and enter the alt-screen chat view
/join <net> <chan>, /j …JOIN on the named network
/part <net> <chan> [reason]PART on the named network
/topic <net> <chan> [text]View/set channel topic
/msg <net> <target> <text>Send a PRIVMSG (any network, any target)
/whois <net> <nick>WHOIS lookup
/raw <ircline>Send an arbitrary IRC frame on the active buffer's network
/mentionsList recent own-nick mentions across networks (network/buffer/sender/text)
/search <query>BM25 search across all scrollback. Results stream into the *circada admin buffer
/identity [keyfile]Load/register your encrypted-channel identity on the active network
/verify <net> <account> <fingerprint>Mark an account fingerprint as explicitly verified in local trust store
/enc fetch <net> <chan>Request channel key wraps (CKEY FETCH) for an encrypted channel
/enc pendingList pending wrap requests waiting for your approval
/enc allow <id> / /enc deny <id>Approve/deny a queued wrap request
/circada <cmd>Send admin command to the bouncer. Supported: help, status, networks, password set/del <net> [<pwd>], network add/remove/show/edit <id> ..., search <query>, reload
/helpList commands

Tab completion

The mode prompt completes:

  • Position 1 (the command itself): every slash command above matches by prefix.

  • Position 2 (network selector for /window, /w, /join, /j, /part, /topic, /msg, /whois): networks the mode has observed events from this session.

  • Position 3:

    • /window, /w, /msg → channels and DM buffers for the network at position 2.

    • /join, /j, /part, /topic → channels only.

    • /whois → nicks observed across all known channels of that network.

  • /circada admin grammar is independent: pos 2 completes the static subcommand list (help, status, networks, password, network, search, reload); pos 3 after password completes set/del; pos 4 after password set|del completes a configured network_id.

Network IDs auto-seed on every /connect via a *circada networks round-trip, so /window <TAB> works on a fresh DB before any channels have populated buffers. Channel/DM completion at pos 3 reads the live buffer cache, so newly-joined channels and newly-seen nicks become completable as soon as their JOIN/NAMES frames arrive.

Tab completion inside the chat view

TAB extends the token under the cursor to the longest common prefix that matches:

  • a slash command from the static list, when the input starts with /;

  • a member nick of the active channel, otherwise. A nick at the start of the input gets the irssi : trailer (alice<TAB>alice: ).

No candidate list is rendered — the behaviour mirrors the lilush shell prompt. If TAB can't make progress, nothing visible happens; type more and try again.

Inside the alt-screen view (after /window)

Continuous-typing input model; everything you type becomes a PRIVMSG to the active buffer unless it starts with /.

KeyAction
ENTERSend the line (or run the slash command)
BACKSPACE / DELETEEdit input
LEFT / RIGHT / HOME / ENDCursor movement
^A / ^EJump to start / end of input
^UClear input
TABComplete a nick (or a slash command if the input starts with /)
PgUp / PgDnScroll the message log
^N / ^PCycle to next / previous buffer
^OToggle the nicks pane (requires terminal width ≥ 90 cols)
^GToggle concise event view
^LForce repaint
ESCLeave the view, return to the mode prompt

Layout

The view is a 3-zone alt-screen:

┌────────────────┬───────────────────────────────────────┬──────────────┐
│ buffer sidebar │ message log                           │ nicks pane   │
│ (active row    │ (scrollable, newest at bottom,        │ (toggle ^O,  │
│  highlighted,  │  per-nick stable colour, mentions     │  ops/voice   │
│  unread badge) │  in the mention style)                │  styled)     │
├────────────────┴───────────────────────────────────────┴──────────────┤
│ status line — scroll, unread/mention badges, conn state, flash slot   │
│ > input                                                                │
└───────────────────────────────────────────────────────────────────────┘

The sidebar collapses below ~60 cols and the nicks pane below ~90 cols; the view falls back to single-pane gracefully on small terminals. Resizing the terminal reflows the layout on the next idle tick.

The status line shows scroll position, unread counts for other buffers, mention badges (*[net/target:n] in the mention style), the connection state, and a flash slot for slash-command output. Server notices addressed to * are routed into the per-network *status buffer rather than opening a literal * buffer. ^G toggles concise event view for the current view session; in concise mode JOIN/PART/QUIT/AWAY/ACCOUNT lines are hidden from the channel log and the latest hidden event is shown transiently in the status line. Concise mode omits the keybinding hint from the status line so dynamic updates have more room.

Encrypted channels from a CIRCADA client POV

This section describes the full user workflow for server-managed encrypted channels (+E) when you use the CIRCADA shell mode through the bouncer.

What gets encrypted (and what does not)

Encrypted payload transport uses ordinary PRIVMSG with +circada/* tags and opaque body.

One-time setup per account/network

  1. Connect and wait until you're SASL-logged in.

  2. Register your encryption identity:

/identity [optional-path-to-ed25519-openssh-key]

Notes:

Enabling encryption on a channel (operator flow)

On the standalone CIRCADA server (not generic IRC networks), a channel operator enables encrypted orchestration with:

/mode <net> #chan +E

Server behavior:

Client behavior (mode):

-E is not supported (server rejects it).

Joining an encrypted channel

When another logged-in user joins +E channel:

  1. Join succeeds (subject to normal channel policy: invites/bans/etc).

  2. If the joiner has a registered identity, server requests a wrap from an eligible online member that already has the epoch key: CKEY WRAP_REQUEST #chan <joiner> ....

  3. In MVP, wrap confirmation is command-driven:

/enc pending
/enc allow <id>   # sends CKEY WRAP
/enc deny <id>    # sends nothing

If denied (or no eligible wrapper exists), joiner remains in channel but cannot decrypt encrypted payloads yet.

Fetching/restoring keys

Manual fetch:

/enc fetch <net> #chan

This requests CKEY FETCH and returns wraps for epochs available to your account. The mode attempts unwrap and caches recovered keys.

The mode also auto-requests fetch when you try to send into encrypted channel without a cached key.

Sending and receiving messages

Server-side channel gates (+n, +m, +R, +Z, bans, invites, etc.) still apply as usual.

Rotation lifecycle (on member leave)

When a member parts/is kicked/quits from initialized encrypted channel, server asks a remaining eligible member to rotate:

The shell mode now performs the staged rotation flow automatically once it has the recipient identities for the remaining members:

If no eligible rotator is online, server records audit rotation_needed_no_rotator and channel stays on old epoch until a keyed member can rotate.

Trust / fingerprint UX

The mode keeps local trust records in ~/.local/share/lilush/shell.mneme (keyspace circada_enc_trust) per (network, account):

Explicit verification:

/verify <net> <account> <fingerprint>

Nicks pane badges:

Scope boundary reminder

+E orchestration (IDENTITY/CKEY) requires CIRCADA standalone server support. Through the bouncer on regular external IRC networks, these semantics are not provided by the upstream server.

Wire protocol notes (for the curious)

The bouncer's loopback endpoint requires a single CAP that vanilla IRC servers don't:

CAP LS 302
:circada CAP * LS :circada.io/multiplex server-time message-tags echo-message batch account-tag away-notify

After registration, every routed frame is tagged:

@+circada/net=libera;time=2026-04-30T12:34:56.789Z :alice!~alice@host PRIVMSG #lilush :hello

The mode (or any circada-aware client) reads +circada/net= to know which network the frame is from. To send to a specific network, it prepends the same tag on outbound frames. The bouncer strips +circada/* tags before forwarding upstream so plain IRC servers never see them. Clients that do not negotiate circada.io/multiplex are rejected during registration.

The pseudo-target *circada accepts admin queries:

PRIVMSG *circada :status
PRIVMSG *circada :networks
PRIVMSG *circada :password set <network> <password>
PRIVMSG *circada :password del <network>
PRIVMSG *circada :network add <id> <host> <port> [--tls] [--sasl]
                              [--nick X] [--user X] [--realname X]
PRIVMSG *circada :network remove <id>
PRIVMSG *circada :network show <id>
PRIVMSG *circada :network edit <id> <field> <value>
PRIVMSG *circada :search <query>
PRIVMSG *circada :reload
PRIVMSG *circada :help

Replies arrive tagged +circada/net=*circada so the mode routes them into the dedicated (*circada, *circada) admin buffer. The mode also sniffs *circada networks replies during a 5s window after every /connect to seed the prompt-side completion source — those replies are swallowed so they don't bump unread on the admin buffer.

Security caveats

Roadmap

References

References used during implementation:

License

LicenseRef-OWL-1.0-or-later.