██████╗ █████╗ ██████╗ █████╗ ██╗ ██╗ █████╗ ███╗ ██╗
██╔════╝██╔══██╗██╔══██╗██╔══██╗██║ ██║██╔══██╗████╗ ██║
██║ ███████║██████╔╝███████║██║ ██║███████║██╔██╗ ██║
██║ ██╔══██║██╔══██╗██╔══██║╚██╗ ██╔╝██╔══██║██║╚██╗██║
╚██████╗██║ ██║██║ ██║██║ ██║ ╚████╔╝ ██║ ██║██║ ╚████║
╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝ ╚═══╝
A native macOS IRC client loosely inspired by the good ol' days of mIRC.
mIRC got a great deal right in 1995 and most of it is still right. A tree of windows you can actually navigate. A status window that shows the raw protocol when something breaks. Scriptable everything. A client that treats IRC as a protocol rather than hiding it behind a chat metaphor.
This is that client, rebuilt natively for macOS: Swift 6 with strict concurrency, a SwiftUI shell with AppKit where AppKit is genuinely better, and zero external dependencies.
Note
Early days. The protocol layer is written and tested; the app currently launches to an empty window. Everything below marks what exists and what does not — see Progress.
- The protocol is not hidden. A status window shows raw traffic both directions,
/debugstreams it to a window or a file, and every unrecognized line still lands somewhere visible instead of being silently dropped. - Bouncer-first. soju is a primary target, not an afterthought:
bouncer-networksandchathistoryland in stage 2, not stage 3. - Credentials are handled like credentials. Redaction happens on insert into the trace buffer, never at export, so a password is never resident anywhere it could be scraped later. Secrets live in the macOS Keychain, never in a config file.
- Nothing is written to the source tree. Settings, logs and caches follow XDG paths — see Where your data lives.
- No dependency chain. One SwiftPM package, four modules, nothing vendored but test fixtures.
After Planet Caravan, Black Sabbath, 1970. The app is not themed after the band and will not be; the name simply fit. There may be an easter egg or two.
Not a Slack replacement, not a "modern reimagining" of chat, not a bridge to five other protocols. It is an IRC client.
Where this is going — a mIRC-style layout with a network tree, header bar, nick list, and a scrollback view that holds a hundred thousand lines without complaint.
┌─ Caravan ─────────────────────────────────────────────────────────────────────┐
│ │ #caravan - a native macOS IRC client │
│ ├──────────────────────────────────────────────┬──────┤
│ * Libera.Chat │ [12:04:17] *** Joins: alice (~a@example.net) │ @ops │
│ |- #caravan * │ [12:04:22] <bob> parser passes the corpus │ @bob │
│ |- #swift │ [12:04:31] <alice> all 66 cases? │ +eve │
│ `- >NickServ │ [12:04:36] * bob nods │ ann │
│ │ [12:04:41] -NickServ- You are now identified │ joe │
│ * soju (bouncer) │ [12:05:02] *** eve is now known as evelyn │ │
│ |- #ops o │ │ │
│ `- #dev │ │ │
├─────────────────────────┴──────────────────────────────────────────────┴──────┤
│ [#caravan] > /msg alice thanks! │
└───────────────────────────────────────────────────────────────────────────────┘
* highlight o activity
A mockup, not a screenshot. The window is currently empty — see Progress.
Four modules, one app. Dependencies point one way only.
┌───────────────────────────┐
│ App │ SwiftUI shell
│ NSTextView scrollback │ AppKit where it counts
└─────────────┬─────────────┘
│
┌─────────────▼─────────────┐
│ IRCSession │ registration, ISUPPORT,
│ actor - event stream │ state machine, events
└─────────────┬─────────────┘
│
┌─────────────▼─────────────┐
│ IRCTransport │ NWConnection, TLS,
│ actor - line framing │ send queue, reconnect
└─────────────┬─────────────┘
│
┌─────────────────┴───────────────────────┐
│ │
┌─────────▼─────────┐ ┌───────────▼───────────┐
│ Diagnostics │ │ IRCProtocol │
│ os.Logger │ │ parse - serialize │
│ Redactor │ │ IRCv3 tags - masks │
│ TraceBuffer │ │ casemapping │
│ Signposts │ │ │
└───────────────────┘ └───────────────────────┘
Darwin-only pure · builds on Linux
IRCProtocol has no I/O, no Foundation, no Darwin APIs — nothing but the standard
library. CI builds and runs its test suite on Linux, so the moment someone reaches
for AppKit or os.Logger in there, a job fails. The rule is mechanical, not a
comment in a file.
Seventeen prompts. STAGE2-PROMPTS.md is authoritative; every
roadmap item below is attached to one of them.
| # | Prompt | Status |
|---|---|---|
| 1 | Formatting codes: rendering | ✅ done |
| 2 | The input field grows up | ✅ done |
| 3 | Capabilities and authentication | ✅ done |
| 4 | Multi-network, and the bouncer | ✅ done |
| 5 | Queries and CTCP | ✅ done |
| 6 | Activity and navigation at scale | ✅ done |
| 7 | Windows and chrome | ✅ done |
| 8 | Commands and modes | ✅ done |
| 9 | Things you can do to what is in the buffer | ✅ done |
| 10 | Options | ✅ done |
| 11 | The Dashboard and the server list | ✅ done |
| 12 | Logging | ✅ done |
| 13a | What deserves none | ✅ done |
| 13b | What deserves your attention | ✅ done |
| 14 | Presence | ✅ done |
| 15 | Channel list | ✅ done |
| 16 | Flood protection | ✅ done |
| 17 | Buffer utilities | ✅ done |
Stage 2 is done when you would use this instead of your current client.
You can idle in a channel on Libera and hold a conversation.
STAGE1-PROMPTS.md is the finished queue, kept for the record.
Eleven prompts, all landed
| # | Prompt | Status |
|---|---|---|
| 1 | Scaffold — package, Xcode project, CI | ✅ done |
| 2 | Diagnostics — logging, redaction, wire tracing | ✅ done |
| 3 | Message parser — IRCv3 tags, casemapping, masks | ✅ done |
| 4 | Transport — line framing, TLS, NWConnection |
✅ done |
| 5 | Registration and connection state machine | ✅ done |
| 6 | Typed event model | ✅ done |
| 7 | Minimal UI and the scrollback view | ✅ done |
| 8 | Channel and user state | ✅ done |
| 9 | Command line | ✅ done |
| 10 | Status window, timestamps, line rendering | ✅ done |
| 11 | Debug & Settings canvas | ✅ done |
Full detail in PLAN.md.
Stage 1 — Basic · connect, join, chat
- TLS connection, registration,
ISUPPORT,PING/PONG, reconnect with backoff - Full IRCv3 message parsing: tags with escaping, sources, numerics
- Casemapping (
ascii/rfc1459/strict-rfc1459) and wildcard mask matching - Channel and user state, nick list ordered by
PREFIXrank /join /part /msg /me /nick /quit /raw, unknown commands passed straight through- Status window with raw traffic, timestamps, mIRC-style event lines
- Debug & Settings canvas —
/debugand the live wire trace, not shaped like a chat window - Settings in a plain-text config you can edit by hand without the app clobbering it
Stage 2 — Intermediate · a daily driver
- Formatting — bold, italic, underline, strikethrough, monospace, reverse, and the
full 99-colour
^Cpalette including the extended 16–98 range - Multi-window — four activity states and highlight badges in the tree, next-unread and next-highlight keys, MRU Ctrl+Tab, a ⌘K quick-switcher, ⌘1–9 bindings, detachable windows
- Multi-network — direct connections and soju's
bouncer-networks, behind one sidebar model that does not care which is in play - Bouncer support —
chathistorybackfill, de-duplicated against local logs - Queries and CTCP (
VERSIONPINGTIMEUSERINFOCLIENTINFOFINGERACTION) - Full command set —
/whois /whowas /who /mode /op /kick /ban /kickban /topic /invite /notice /away /list /names /ignore /oper /amsg /ame /ctcp /clear - Tab completion, mIRC-style cycling with a configurable suffix
- Mode tracking, ban/quiet/invex list dialogs, channel modes sheet
- Nick-list and channel context menus
- Options dialog — Connect, IRC, Display, Colors, Sounds, Logging, Mouse
- Server list with groups, autojoin, perform-on-connect, connect-on-startup
- Logging in mIRC's layout, log viewer, reload-last-N-lines on join
- Highlights, keyword and regex lists, per-event sounds, notifications, Dock badge
- Ignore list with wildcard masks and mIRC-style level flags
- Notify list via
MONITOR, withISONpolling as fallback /listchannel browser with filters, URL catcher, away system, flood protection- SASL —
PLAIN,EXTERNAL(CertFP),SCRAM-SHA-256; NickServ fallback - IRCv3 —
cap-notifymulti-prefixaway-notifyaccount-notifyextended-joinuserhost-in-namesserver-timemessage-tagsecho-messagebatchchghostinvite-notifysetnamestandard-replieslabeled-response
Stage 3 — Advanced · mIRC parity
- DCC — CHAT, SEND, GET with resume, passive/reverse DCC for NAT, a transfer manager, drag-and-drop onto a nick to send
- Scripting in JavaScript, not mIRC's language. Declarative aliases and popups for
the one-liner cases, JavaScriptCore for real logic. A bare
JSContexthas norequire,fetchor filesystem at all, so capabilities are granted rather than restricted — and Safari Web Inspector attaches for actual breakpoints. - Identd, SOCKS5 and HTTP proxies, Tor
- Themes over stage 1's one format-table seam, F-key bindings, arbitrary shortcuts
- User levels and access lists, paste protection, spell check
- soju extras —
filehost,metadata,search,webpush; ZNC compatibility quirks - Full-text search across all logged history
Stage 4 — Polish and release
- Accessibility, localization, performance work on the scrollback pipeline
- Distribution as a Homebrew cask from our own tap; Developer ID signing and notarization
- mIRC settings importer — read
mirc.ini,servers.ini,remote.ini - Optional iCloud sync, optional iOS companion sharing the core modules
Requires Xcode 26+ on macOS 15+.
git clone https://github.com/Lacuna-Research/caravan.git
cd caravan
make hooks # install the pre-commit hook — once, after cloning
make all # build, test, lint, docs check, app| Target | What it does |
|---|---|
make build |
Compile the package |
make test |
Run the test suite |
make app |
Build the macOS app with xcodebuild (Debug) |
make install |
Build Release and put it in /Applications |
make fmt |
Format with the toolchain's swift format |
make lint |
Format check, strict |
make check |
Documentation discipline |
make hooks |
Install the pre-commit hook |
make install is the one to use if you actually want to run Caravan: it builds Release,
where make app builds Debug for acceptance runs, and it derives the built product's path
rather than assuming one, so it installs the checkout you ran it from.
swift-testing ships with Xcode rather than Command Line Tools, so make test and
make app need full Xcode. make build, make lint and make check work with CLT
alone.
Nothing is ever written inside the source tree — not even under a gitignored path.
| What | Where |
|---|---|
| Settings | caravan.conf in $XDG_CONFIG_HOME/caravan/, default ~/.config/caravan/ |
| Logs, scrollback | $XDG_DATA_HOME/caravan/, default ~/.local/share/caravan/ |
| Caches | $XDG_CACHE_HOME/caravan/, default ~/.cache/caravan/ |
| Passwords, client certs | macOS Keychain — never a file |
Config files are plain text and user-editable; treat their paths as public API. caravan.conf
is key = value, one per line, # for a comment. Your comments and any keys the app does
not recognise survive being written back, and a key that is not there takes its default —
so the file only ever holds what you actually changed.
One prompt per branch, one PR, squash-merged once CI is green. main is protected:
four required checks, no force-pushes, admins not exempt.
The interesting part is that the project's own rules are machine-checked.
Scripts/check-docs.sh runs as a pre-commit hook and in CI, and fails on:
CLAUDE.mdover 100 lines — the cap forces pruning instead of accretion- any edit to an existing
BUILD-LOG.mdline — it is append-only - a
Sources/change with no build-log entry - a missing or malformed status line, or a progress badge that disagrees with it
- carry-forward notes that outlived the prompt they were addressed to
- undeclared SwiftPM dependencies
Two git hooks and a Stop hook cover what a diff can't see: no commits to main, no
pushing a worktree-* branch before renaming it, and no worktree left behind after
its PR merged.
| File | Purpose |
|---|---|
PLAN.md |
Living roadmap across four stages |
STAGE1-PROMPTS.md |
The ten stage-1 work units, authoritative |
BUILD-LOG.md |
Append-only: decisions, deviations, surprises, measurements |
CLAUDE.md |
Build standards and working method |
BUILD-LOG.md is the one worth reading. It records what was decided and what was
rejected and why — including the mistakes, which stay in the log alongside their
corrections rather than being quietly edited away.
BSD 3-Clause. Copyright (c) 2026, Lacuna Research.
The vendored ircdocs/parser-tests corpus is CC0-1.0 and imposes no conditions.