ADR 0005 supersedes ADR 0002. The "sidecar vs app" split was load-bearing on Go-vs-Dart being a meaningful boundary. Going all-Dart collapses that — the daemon becomes clide --daemon mode of the same AOT binary the CLI lives in, both sharing lib/ with the Flutter app. The one native gap (Dart's multi-threaded VM can't safely fork+exec) is filled by a small C supporter tool — ptyc, Project Terminal Controller, peer of pql — rather than introducing a second core language. ADR 0006 defines the CLI/event surface on top of that Dart core: subsystem list (pane/tab/editor/panel/tree/git/pql/canvas/graph/ theme/settings/project), command shape, versioned JSON event schema, pql-parity exit codes, and command-event duality as the operational form of user/Claude parity. Deleted: sidecar/cmd, sidecar/go.mod, every sidecar/internal package. Rewritten: Makefile (dart compile exe, flutter analyze/format/test, build-linux/build-macos, ptyc-build), ci/*.sh, .githooks/pre-push (no more GOBIN PATH dance), .gitignore (Flutter/Dart at repo root, ptyc section), project.yaml (drop module: and go_version:), CLAUDE.md (guardrails, dependencies, commands refreshed). Co-Authored-By: Claude <noreply@anthropic.com>
8.3 KiB
ADR 0006 — CLI and event surface contract
Status: accepted Date: 2026-04-20
Context
ADR 0001 established that Claude drives Clide via a Bash CLI, not MCP. That decided the channel. It did not define the surface — which subsystems exist, how commands relate to events, what the JSON looks like on the wire, how subscribers discover state changes.
CLAUDE.md states the rule colloquially: "every CLI subcommand has a UI affordance in the app, and every UI action has a CLI. If you add one side without the other, the feature is incomplete." This ADR restates that as an implementable contract.
The shape needs to satisfy three things at once:
- User/Claude parity. Anything the user can do with a mouse,
Claude can do with
clide <...>. Anything the user can observe in the UI, Claude can observe via events. - Daemon as authoritative state. The app and the CLI are both
clients. State lives in the
clide --daemonprocess; commands mutate it; events broadcast changes to all subscribers. - pql-style ergonomics. One tool-use pattern for Claude across pql and clide — same exit codes, same JSON-on-stdout habit, same stderr-for-diagnostics rule.
Decision
The CLI is organised into subsystems. Each subsystem owns a noun, a set of verbs, and a set of events. The set is closed at any point in time (documented); growth is additive (new verbs, new events — never renaming existing ones without a version bump).
Subsystem list (initial, by tier)
| Subsystem | Tier | Nouns | Representative verbs | Representative events |
|---|---|---|---|---|
pane |
1 | terminal pane | spawn, list, focus, close, write, resize, tail | pane.spawned, pane.output, pane.exit, pane.resized |
tab |
2 | workspace tab | new, switch, close, list | tab.opened, tab.switched, tab.closed |
open |
2 | editor shortcut | (verb-only: clide open <path>) |
— (emits editor.opened) |
editor |
2 | active editor buffer | goto, highlight, insert, replace-selection, save | editor.opened, editor.selection_changed, editor.saved |
panel |
2 | sidebar/context panels | show, hide, toggle, list | panel.visibility_changed |
tree |
2 | file tree | reveal, refresh | tree.node_expanded, file.changed |
git |
3 | working tree | status, stage, unstage, stage-hunk, commit, stash, pull, push | git.status_changed, git.branch_changed |
pql |
4 | queries | run, tags, backlinks | pql.result |
canvas |
5 | canvas surface | open, node add/move/connect, save | canvas.node_added, canvas.node_moved, canvas.connection_added |
graph |
5 | graph view | open, focus, filter | graph.focused, graph.filter_changed |
theme |
6 | theme/palette | set, list, get | theme.changed |
settings |
6 | settings store | get, set, list | settings.changed |
project |
— | whole-workspace | status, reload, events | project.ready, project.reloaded |
Two umbrella entry points sit outside any subsystem:
clide tail --events [--filter <subsystem>[:<id>]]— subscribe to the event stream. Baretail --eventsgets everything; filtered forms narrow by subsystem or by subsystem+id (e.g.--filter pane:p_7,--filter git).clide status— one-shot daemon snapshot: connected clients, live panes, open tabs, workspace root, daemon version, uptime.
Command shape
clide <subsystem> <verb> [<positional>...] [--flag ...] [-- argv...]
- Positionals are nouns/ids; flags are modifiers.
--separates Clide's args from an inner argv passed through (e.g.clide pane spawn --cwd X -- tmux new-session -A -s foo).- Verbs are imperative (
spawn, notcreate-pane). - Where it reads naturally, single-word shortcuts exist for the
hottest paths (
clide open <path>→clide editor open <path>). Shortcuts alias; they do not fork.
Exit-code contract (parity with pql)
| Code | Meaning |
|---|---|
0 |
Success |
1 |
User error (bad args, unknown id, precondition failed) |
2 |
Tool error (daemon unreachable, IPC failure, internal panic) |
3 |
Not-found (id or path doesn't resolve) |
4 |
Conflict (state busy, already-running, concurrent-modify) |
64–78 |
Reserved, per sysexits.h, for future specific cases |
Diagnostic JSON on stderr (not stdout) on any non-zero exit:
{"code":1,"kind":"user_error","subsystem":"pane","message":"pane id p_99 not found","hint":"clide pane list"}
Stdout stays machine-parseable on success. This matches pql.
Event schema
Events are JSON objects, one per line, on the --events stream.
Every event:
{
"v": 1,
"ts": "2026-04-20T21:00:00.123Z",
"type": "pane.output",
"subsystem": "pane",
"id": "p_7",
"payload": { "bytes_b64": "…", "seq": 412 }
}
v: schema version. Bumped only for breaking changes. Old subscribers pinv.ts: ISO-8601 UTC with millisecond precision.type:<subsystem>.<verb_past|noun_changed>. Past-tense for things that happened;_changedsuffix for state transitions.subsystem+id: redundant withtype, but makes filtering cheap and future-flexible.payload: subsystem-defined; documented per subsystem.
Binary payloads (PTY output, file contents) are base64. The ergonomic cost is worth the "entire stream is line-delimited JSON" invariant.
Command ↔ event duality
Every state-changing command emits at least one event. Subscribers
see the same mutation whether they triggered it or not, and the
issuing client gets the event back (so clide pane spawn followed
by a tail --events subscription sees pane.spawned regardless of
subscribe order, via a short replay buffer per subsystem).
Read-only commands (list, get, status) emit nothing.
User/Claude parity as a check
Every merge to main that adds a UI affordance must either:
- add the matching CLI verb, or
- include a linked follow-up task naming the verb to add next.
Every merge that adds a CLI verb must either:
- surface it in the UI, or
- document why the verb is Claude-only (rare; mostly diagnostics
like
clide status).
Events have the symmetric rule: any UI surface that reacts to state
must react to the corresponding event; any new event must be
consumable both by the UI and by clide tail --events.
Consequences
- Surface is enumerable. Adding a subsystem means adding a row
to the table above and specifying its verbs + events in a short
doc under
docs/cli/. The daemon registers it; the CLI dispatcher picks it up;clide --helpandclide <subsystem> --helpstay accurate by construction. - Wire schema is versioned.
v: 1is the starting point. Compatibility breaks bump the major and land alongside aproject.yamlschema_version:bump. - Replay buffer per subsystem. Cheap (most subsystems emit seldom); needed so a subscriber that connects after a command still sees that command's effect. Buffer depth per subsystem is a tuning parameter; defaults to 16 events.
- Events are the only UI→app state channel. The Flutter app
does not poll; it subscribes. Panels render from the last event
for their subsystem + current snapshot from
project status. - pql events fit naturally. Long-running
pqlqueries stream rows aspql.resultevents keyed by a query id, letting the Query panel render incrementally. - Testability. Every subsystem can be integration-tested by:
start a daemon → open a
tail --eventssubscriber → issue commands over the CLI → assert events. No UI needed for protocol-level coverage. - Extension API (Tier 6) inherits this. A Dart extension publishes a subsystem; the same registration pipeline exposes it to Claude via the CLI. Extensions don't get a second-class channel.
Open questions
- Authorisation granularity. The daemon's token auth is coarse
(allow all / deny all). Later, per-subsystem grants may matter
(e.g. restrict
git push). Out of scope here. - Back-pressure on event streams. A subscriber that falls behind
on
pane.output(a firehose) needs a policy: drop oldest, block producer, coalesce, or kill subscriber. Defer until Tier 1 is in real use. - Event persistence. Events are in-memory only in v1. If a future need (audit log, undo) wants persistence, it becomes a subsystem that subscribes and writes — not a property of the bus.