Layers
Provider adapters
Provider adapters translate a task into an executable plus arguments. The built-ins are Claude and Codex; custom provider specs cover other agent CLIs. Adapters deliberately do not own process or terminal behavior.
The CLI adapters currently add provider-native lifecycle callbacks:
- Codex: per-launch prompt and stop hooks report state, passed as a
-cconfig override, over the external completion notifier. The notifier alone carried only turn ends, so a turn begun in the agent's own terminal was invisible; it is retained because Codex holds injected hooks inert behind a one-time trust review, and an agent reporting nothing would sit atworkinguntil its process exited. The notifier has no trust gate, so it is the floor and the hooks are the ceiling. Both surfaces report a turn end once trusted; the two events carry identical state, so applying both is idempotent. - Claude: per-launch prompt, notification, and stop hooks report state. Permission prompts raise attention through the notification hook; they are answered in the agent's own terminal, never intercepted.
- Generic agents: PTY state and optional lifecycle hooks.
Both CLIs accept the same hook schema — an event name mapping to matcher groups of
command handlers, with the payload arriving on the handler's stdin — so one set of types
describes both and only the encoding differs: Claude takes JSON through
--settings, Codex takes inline TOML through -c. Codex parses
that value as TOML and rejects JSON, so the override has to encode to a single inline
line.
Hooks that resolve rather than observe are deliberately left unregistered for both
providers. Claude's PreToolUse and Codex's PermissionRequest
answer whether a tool call may proceed, and an approval Stormlight never answers is an
agent stuck waiting on it.
The next Codex revision should use App Server JSON-RPC for threads, turns, approvals,
and streamed items. Claude background-agent discovery or the Agent SDK can similarly
replace its CLI hook bridge. The runtime exposes stormlight event so
generic providers can report semantic state.
Application service
The application service validates requests, resolves a provider and workspace, and delegates terminal operations to the runtime. The TUI and CLI use the same service. A persistent workspace catalog supplies workspaces that do not currently contain an agent.
Workspace resolvers return a stable group ID, a group root, an execution root, and optional component metadata. External executable resolvers run before the built-in Git resolver, followed by a canonical-directory fallback. Resolvers may also enumerate a group's current execution roots; the application service exposes that same inventory to the CLI and dashboard directory picker. Enumeration is routed back to the resolver that claimed the workspace and is bounded, cached, and best-effort, so a failed external integration cannot block dashboard refresh. This keeps environment-specific workspace semantics outside the public runtime.
windrunner runtime
Process and terminal ownership live in a daemon built on the windrunner session engine. Each session the daemon holds is one PTY plus an authoritative terminal emulator, so a snapshot of an agent's screen and scrollback is exact serialized state, and attaching is a byte stream from that state forward — never a capture of whatever happened to be visible.
The daemon is Stormlight itself: internal/windrun.NewRuntime connects to
a unix socket (daemon.sock under $WINDRUNNER_DIR, else
$XDG_STATE_HOME/windrunner, else ~/.local/state/windrunner —
the windrunner library's own default, so windrunner ls lists Stormlight's
agents too) and, when nothing answers, starts stormlight _wrdaemon from the
running binary's resolved path (internal/selfpath) and waits for it to come
up. The daemon outlives every dashboard: agents, their terminals, and their scrollback
persist across dashboard restarts, and the daemon's sessions are the roster — there is
no second record to reconcile.
The daemon never learns what an agent is; that is the library's boundary. Agent
identity and state ride in the session's opaque metadata as one JSON document under the
stormlight_agent key — the serialized agent.Agent: id,
provider, task, name, workspace context, permission mode, activity, attention, mark,
session id, and transcript path. Two rules keep the document honest:
- Liveness and exit are the daemon's facts. Listing decodes the document and then
overwrites process state from the session itself —
Alive,ExitCode— so stale metadata can never claim a dead agent is working. An exited process with no recorded completion is classified from its exit code. - Updates are read-modify-write on the whole document
(
Runtime.mutateAgent). Two near-simultaneous writers — a hook event racing the dashboard — can lose one update; events are sparse enough that the next one repairs it, and a daemon-side compare-and-swap is noted for later.
Dispatch spawns the provider directly: the adapter's launch command becomes the
session's process, with STORMLIGHT_ID (how hook subprocesses name their
agent), STORMLIGHT_BIN (how they find Stormlight across upgrades), and
WINDRUNNER_DIR (how a hook's own stormlight _provider-event
invocation reaches the same daemon) in its environment. There is no supervisor process
between the daemon and the provider; exit state is read from the daemon. The daemon
names the session; that id is not adopted as the agent's id — the metadata carries
Stormlight's id, and the session id fills the display fields that expect a pane
handle.
Messages are delivered as terminal input: multi-line messages inside a bracketed paste so they arrive as one message, slash commands typed verbatim (providers ignore pasted slash commands), then a beat later the Enter that submits. Nothing is ever interpolated into a shell command string.
The terminal seam
Live terminals reach the dashboard through one narrow seam, declared as an optional runtime capability:
session.TerminalStreamer(internal/session) is the contract: attach to an agent's terminal at a size and get aTerminalStream— an exact snapshot seed, a channel of everything after it, and input and resize flowing back.windrun.Runtime.AttachTerminalimplements it over one dedicated daemon connection per attachment, resizing first so the snapshot arrives pre-wrapped for the view it is about to fill.app.Service.AttachTerminalsurfaces the capability to the UI as apty.Transport, failing cleanly when a runtime cannot stream.ptyview.Managerkeeps one live terminal per agent for the agent's whole life, reconciling the set against the roster on every refresh: agents without a terminal get one, departed agents lose theirs, and all of them follow the pane's dimensions. Selecting an agent switches which terminal is rendered; it never starts one.pty.Model(internal/pty) is the widget: acharmbracelet/x/vtemulator fed by the transport, with scrollback, coalesced frame notifications (~30fps) so a chatty agent cannot flood the event loop, and wheel-burst batching for high-resolution scrolling.
The Spanreed pane renders the selected agent's widget. While the pane holds focus the
keyboard belongs to the agent's terminal byte for byte, and the real terminal cursor is
placed where the agent's program put it — the pane is the terminal, not a picture of
one. The dashboard's own keys in that mode are modifier chords the hosted TUIs don't
bind: ctrl+space steps out, alt+j/k moves the
roster cursor so the portal swaps terminals under the keyboard, alt+z zooms
the grid over the full body, and alt+t flips to the transcript reading
view. Typing into an agent's terminal is the strongest form of having seen its result,
so attention clears on the way through.
F is the full-screen escape hatch: the runtime's Attach
returns a command (stormlight _wrattach <session>) that the dashboard
runs through tea.ExecProcess, suspending itself while the attachment owns
the whole terminal; ctrl+q detaches and the dashboard returns, reasserting
every widget's size because the attached client resized the daemon's sessions.
The transcript view renders the conversation from the provider's own JSONL transcript
once hooks have reported its path — the terminal screen is all a snapshot can see of an
alternate-screen agent, so the transcript file is the only complete history. The
renderer paints it: prompts, replies, tool calls, and trimmed results take the palette
in internal/theme, and the markdown Claude writes is read back as styling
by Glamour, against a stylesheet built from that same palette in
internal/provider/markdown.go. Glamour's own wrapping is switched off — the
pane is resizable, so line breaking belongs to the pane, which knows the current width.
While a turn is in flight the live screen is appended under a divider so streaming
output stays visible; an agent with no transcript falls back to its terminal
snapshot.
External overlays — the Yazi directory picker and the Neovim task editor — float over
the dashboard in a popup: the program runs in its own runtime-owned session
(session.OverlayHost, a runtime capability beside
TerminalStreamer), and the dashboard renders its terminal through the same
widget as the Spanreed, composited into a modal frame. The keyboard belongs to the
floating program while it is up — Ctrl-q cancels, which destroys the
session — and results return through permission-restricted temporary handoff files when
the program exits. Overlay sessions carry no agent document, so the roster never sees
them, and Close always removes them from the daemon: an overlay resurrected from
persistence would be a ghost with nothing waiting on its exit.
State model
The public agent record keeps process lifetime, activity, and attention separate, with a fourth axis for the human's own reading:
- Process: live or exited, with an optional exit code.
- Activity: starting, working, idle, completed, failed, or stopped.
- Attention: question, approval, authentication, waiting, or none.
- Mark: working, attention, or none — set by a human, never derived.
Attention is tiered. Question, approval, and authentication are urgent — the agent is blocked on an explicit human decision. Waiting is soft — the turn finished with a result the human has not seen. Every turn end is classified from the final assistant message (providers emit one event for both "done" and "asked a question", so content is the only instant discriminator): a closing question is urgent, anything else is an unseen result. The provider's delayed idle notification is deliberately ignored — it would re-raise attention the human already cleared. Attention clears on engagement: a new prompt, typing into the agent's terminal, replying, interrupting, paging through the result while it is on screen, or an explicit mark-seen. Navigating between panes and rows is deliberately not engagement — those are the keys a human presses on the way past a result, and counting them cleared the amber before it was ever read. The runtime refuses to let a soft signal downgrade an urgent state. Exited agents carry no attention — their exit status is the story.
This prevents a resumable completed conversation from being conflated with a currently running process, and keeps "needs me now" distinct from "idle on me" and from "just idle".
Entry into the amber inbox is stamped, by either route, and the stamp is what the attention sort orders by: first in, first out. It records entry rather than the latest signal, so a summary or an escalation arriving mid-wait does not send an agent to the back of a line it never left. An agent leaves the inbox only through engagement, never through mere navigation.
A mark is the one signal nothing derives. Everything above is inference, and
inference is sometimes wrong, so a human can say otherwise (m in the
dashboard, stormlight mark outside it) and the mark outranks the derived
reading everywhere it is displayed or counted. The two marks retire differently, because
different parties can answer them: a working mark claims the agent is still running,
which the agent settles as soon as it reports anything, so the next state-bearing update
retires it; an attention mark claims the human has something to return to, which no
provider event can answer, so only an explicit clear or the same engagement that clears
amber takes it down. Like attention, a mark stops applying once the process has
exited.
Persistence
Session metadata in the daemon is the source of truth for the running roster, and the daemon's persistence is what makes it durable: agents outlive every dashboard because their PTYs and terminals never belonged to one. The workspace catalog is an atomic JSON file independent of the daemon, as are the dashboard's column preferences.
The daemon's lifetime bounds the roster's. A reboot or a killed daemon takes the
processes and their terminals with it — but not the conversations.
internal/history keeps an append-only session log
(sessions.jsonl) recording every session id the providers ever report, with
the task, workspace, and transcript path; the log is compacted once per dashboard
launch, off every event path. A provider adapter's Resume maps a session
id — the one the agent's hooks reported, or failing that the one the provider's
transcript naming encodes — to a launch that reopens the conversation, and that launch
carries no prompt: a resumed agent idles at its composer. Nothing resumes work by
itself. An agent that never reported a turn has no session id and no transcript, so
there is nothing to reopen — re-dispatching its original task would be a materially
different act performed under the same name. The dashboard's history browser
(H) serves the same records long after their agents are deleted.
Workspace boundary
Workspace discovery is intentionally outside provider adapters. Provisioning and cleanup remain separate concerns: a future worktree manager can prepare a directory before dispatch and pass it through the same resolver and runtime. Cleanup must refuse to remove worktrees containing uncommitted changes or unpushed commits.