Celestial 发布多编码智能体编排系统
geoffwellman/celestial
Celestial 是一个把工单转成可审查软件的多智能体编排系统,为每个工作区配备编排者、执行者、侦察者与审查者,并让它们在可见的 herdr 面板中运行。它支持 Claude Code、oh-my-pi、codex、opencode、pi 等编码智能体,工作按委派、实现、检查、审查推进,合并规则与工单方案以策略形式注入每个智能体的系统提示词。
Turn work orders into reviewed software with a visible, steerable team of coding agents.
One clone per dev box. Celestial organises work into workspaces, equips coding agents (Claude Code, oh-my-pi, codex, opencode, pi), and puts orchestrators and workers into visible herdr panes. Work moves through delegation, implementation, checks and review under your workspace policy. You stay at the controls: inspect the work, answer blockers and make the decisions that need a human.
The dashboard is your factory floor. Isometric workstations show each reported task as a work order in intake, build, test, review, dispatch or the holding area. Select a crate to inspect its branch, agent and PR, or focus its worker. Positions follow real reported signals, not invented progress bars; dispatch means ready for a merge decision, not already shipped.
Illustrative work orders in the live dashboard. No agent execution is simulated.
Why
Running one coding agent is easy. Running twelve — orchestrators delegating to workers in worktrees, reviewers gating merges, services on ports, tickets moving — needs a production system. Celestial supplies the coordination, visibility and housekeeping around the agents:
- Every agent runs in a visible pane. No invisible background subagents; if it isn't in a pane, it didn't happen.
- Policy is injected, not hoped for. Each workspace declares merge rules, ticket schemes, runtimes and reviewers once; every agent launched there gets them in its system prompt.
- The fleet cleans up after itself. A steward tick collects merged worktrees, reaps idle agent processes, nudges stalled reviews, and names anything waiting on a human.
- Every work order has a place on the floor. The isometric dashboard distinguishes active work, waiting, blocked work and missing signals. Its task list remains available when you need a denser operational view.
Give this to your agent
You already have a coding agent — Claude Code, oh-my-pi, pi, codex, opencode. Paste this into it and let it do the install:
Install Celestial on this machine and get it running:
1. git clone https://github.com/geoffwellman/celestial.git ~/celestial
2. Add eval "$(~/celestial/bin/cel shellenv)" to my shell rc and load it.
3. Run cel setup, then cel doctor. Fix everything red; ask me for anything
that needs my account (gh auth login, claude setup-token, pi /login).
4. Ask me: a workspace name, my GitHub org, and the repos I want in it.
Run cel ws new <name> --org <org>, add the repos to workspace.yaml, run
cel ws sync <name>, then cel doctor again.
5. Start the console with cel console, then run cel fleet and
cel dash --ensure. Show me both outputs and the dashboard URL, and tell
me the one command I use from now on.
Setup installs third-party code and may invoke sudo; read the block before
you paste it, and read requirements first. If you would
rather type it yourself, the same install is below.
Quick start
git clone https://github.com/geoffwellman/celestial.git ~/celestial eval "$(~/celestial/bin/cel shellenv)" # add this line to your shell rc cel setup # review the manifests before installing cel doctor # verify prerequisites and account setup cel ws new acme --org your-gh-user # scaffold a workspace cel ws sync acme # clone its repos, link skills, check tools cel console # the desk you keep open: routes the box
cel console is the interface a person keeps open — one per box, across every
workspace. cd ~/ws/acme && cel run root is the alternative: it boots that
one workspace's layout with a standing root agent. Pick the console unless you
want a director that lives inside a single workspace and plans for it.
Requires GNU/Linux; automatic prerequisite installation targets apt-based
distributions. Fresh binary installs support x86-64 and arm64. See
requirements before running setup. Setup installs third-party
code and may invoke sudo; it does not supply provider subscriptions or log
you into GitHub, Linear or agent accounts.
The shape a workspace declares
workspace.yaml may say what should be running in a workspace, and cel ws up
puts it back:
layout: orchestrators: auto # auto: one per product that wants one | manual | none panes: # optional extra panes, opened at `up`, in order - label: dash cwd: . cmd: cel dash --ensure # optional; an empty pane when absent - label: notes cwd: .
products[].orchestrator (auto/manual) decides per product underneath the
workspace-wide switch. A workspace with no layout: block behaves as
orchestrators: manual with no extra panes — exactly what it did before.
up is a reconcile, not a launcher, and that is the point: when herdr loses a
live agent's name (it clears it on restart), every surface that resolves an
orchestrator by its alias reports the product as dead — and the answer to a
dead orchestrator is to start another one on top of the running one. So a live
agent in a product's own directory is that product's orchestrator whatever
herdr calls it: cel fleet shows orch unnamed rather than -, cel doctor
says which directory it is in, cel ws up renames it, and cel run orchestrator refuses to start a second one over it without --force.
How it works
Three layers on disk, and a factory floor of agents above them.
~/celestial the plane: mechanism only (this repo, public)
├── bin/cel one CLI for everything
├── core/roles/ role prompts, injected at launch
├── core/skills/ skills linked into every agent
└── agents.yaml which agents, how to install, how to inject
~/ws/<name> a workspace: private content + overrides (own repo)
├── workspace.yaml repos, policy, tickets, runtimes, env, services
├── layouts.yml herdr layouts (wins over the plane's)
├── externals.yaml extra tools (additive overlay)
├── skills/ workspace-scoped skills
└── repos/<product>/ the actual product clones (gitignored here)
~/.local/share/cel box state: registry, published pages (never in git)
A workspace with its own GitHub account
gh keeps several accounts per host but only one is active, box-wide, so
gh auth switch for one workspace would re-identify every other workspace.
A workspace whose repos belong to a second account says so in
workspace.yaml:
github: user: acct-b # the gh account this workspace acts as ssh_host: github-acct-b # optional: an ~/.ssh/config Host alias with acct-b's key
Every pane cel run / cel-fanout delegate|scout starts for that workspace
gets GH_TOKEN="$(gh auth token --user acct-b)", evaluated in the pane (the
value never passes through cel), and every gh call the plane makes for the
workspace (steward sweeps, land/collect, reviewer PR lookup, cel ws push,
cel gc, cel release) runs with that account's token for that call only.
Nothing ever runs gh auth switch. If the account is not logged in, launches
and calls for that workspace refuse rather than fall back to the active
account. With ssh_host, cel ws sync clones over git@github-acct-b:owner/repo.git;
the url: in workspace.yaml keeps the canonical git@github.com: form.
Without a github: block nothing changes.
Two human steps, once per box:
-
gh auth loginas acct-b (it is added beside the existing account; check withgh auth status). -
An SSH key for acct-b, added to acct-b on GitHub, and an alias in
~/.ssh/config:Host github-acct-b HostName github.com User git IdentityFile ~/.ssh/id_ed25519_acct_b IdentitiesOnly yes
cel doctor checks both for every workspace that declares github.user.
console one per box - routes; reads cel fleet; never builds
└── orchestrator one per product (1..n repos) - plans, delegates, judges, lands
├── worker one ticket, one worktree, one PR
├── scout reads and reports; never writes code
├── spike tries and reports; throwaway code, never shipped
└── reviewer one PR, one review per round
A workspace is not a tier. It is the scope that holds configuration and
grouping: policy, ticket tracker, environment, worker profiles, dashboard. A
product is the unit an orchestrator owns — one repo, or several that ship
together. A product that contains all of a workspace's repos is simply "the
workspace orchestrator"; there is no third tier below it, and never was a need
for one. cel run root is optional: a standing director for a workspace that
wants one. root remains the mailbox name for "the top, whoever is listening",
which is the console otherwise.
cel run <role> starts any station with its role injected and permissions
pre-approved; workers are spawned by orchestrators through the fanout skill,
which cuts worktrees from origin's real default branch, refuses diverged refs,
and makes every worker land its work as a PR — finished work is never
invisible. policy.pr_open: draft | ready decides whether that PR opens as a
draft (default) or ready for review with the workspace reviewer requested.
Factory vocabulary
The floor has its own words. They are used in the prose below, and none of them renames a command:
| Word | Means |
|---|---|
| ticket | a work order — the unit a worker is given |
| verdict | the QA gate on a branch: gate result, red-then-green, diff, CI, review |
| land | ship it — squash, merge, clean up |
| steward | the conveyor: the timer that keeps the floor moving without you |
| console | the floor manager's desk: one per box, routes, never builds |
The console
cel console is Celestial's own interface — drawn by the plane, not an agent
pretending to be one. It takes the whole terminal: the alternate screen, like
htop or vim, so nothing it draws lands in your scrollback and quitting
gives you back the screen you had. Panels, top to bottom:
- Fleet —
cel fleet --jsonas a table, one block per workspace, coloured by state and refreshed every 10 seconds (--refresh) and after every command you run. ↑/↓ select a row, Ctrl+F focuses that unit's orchestrator pane, Ctrl+O opens its dashboard, and Enter (or a double-click) opens the unit view. Command history is Ctrl+P / Ctrl+N and the Ctrl+R picker, as in a shell. Every other keyboard action on this screen is a Ctrl chord: a letter you type is always a letter typed. - Waiting on you — every open decision and blocker addressed to
root, across all workspaces, oldest first with their ids. Ctrl+T switches the selection here; Enter (or a double-click) opens the item. - Detail — a place, not a popup: the whole message, who sent it and when,
and the thread around it — everything else in that workspace's mailbox
with the same
ref, or from the same sender within the hour.rresolves,pstarts a reply with the cursor inside the quotes,ggoes to the sender,Esccomes back. Bare letters act here and only here, because the detail view has no command line. - Inbox tail — the newest mail, prefixed with its workspace. Ctrl+T cycles the selection fleet → waiting → inbox, and Enter (or a double-click) opens the message here too. A decision or a blocker also rings the bell and raises a desktop notification.
- Unit view — one product, whole: its orchestrator (state, pane, slots,
workspace and repos) with
[focus]and[message]; every worker it has out as a row — ticket, id, state, live agent, quiet time, verdict in colour, commits ahead and PR number; that workspace's open decisions; and the last ten lines of its mail — and, since CEL-25, the board and the PRs between them. Ctrl+T cycles the focus round the six panels; panels that would not fit collapse to their title and a count, because nothing may render below the last row of the screen. ↑/↓ pick a row, Enter opens it,Escgoes back.nnudges the selected worker,Rrestarts an orchestrator that is not live,aanswers the waiting item and closes it — each a proposal on the command line, never a keypress that changes the box. - Board — the product's tickets, from
cel-linear board --json, grouped by state in the team's own workflow order:ABC-49 In Progress @worker 2h title…, with the worker column saying what this box is doing about a ticket the team thinks is in progress. Enter opens the ticket — description head, last two comments, the worker on it — andsproposes "pick up ABC-49 next" to the orchestrator,mmoves it with the team's states offered as numbered options,oopens it in a browser. - PRs — the open pull requests on the product's repos, one
gh pr listper repo:#12 ABC-49-slug review APPROVED ci ✓ 2h title…. Enter shows the checks by name, the review state and the worker behind the branch.llands it — only when the review is APPROVED and the checks are green, and throughcel-fanout landon the delegation, because merging by hand leaves a worker holding a branch nobody will collect.vstarts a reviewer. - Since you last looked — one line under the unit view's header, computed
from the console's own cursor for that workspace:
since 09:41: 3 status from bundle-orch (last: "…") · 2 PRs merged · 1 decision waiting. It is the console's mark, not the mailbox's read marker — looking at a panel must not mark anyone else's mail as read. - Timeline —
T(or Ctrl+Y) from the main screen: the box's own history in one column, newest last, from the mailboxes, the delegation ledger and the pull request lists at once —12:47 alpha merged #2008 ABC-105. Enter opens whatever the line is about;Esccomes back. - Worker view — the answer to why. It runs
cel-fanout why <id>when it opens and shows what it said: the facts line, the pane tail, the mail from that worker, the PR and anext:sentence.pprompts it,ffocuses its pane,ccollects,xreleases,ttries the preview,wasks again. Anything that changes state is proposed on the command line and runs when you press Enter, never on the keypress alone. - Output — the last command's full output, rendered rather than dumped: the
console knows the shape of what it ran, so
--jsoncomes back as the same tables the panels use and anything else that parses as JSON askey: valuelines.rtoggles[raw]— the bytes exactly as the command printed them. scrolling with PageUp/PageDown or the wheel; Ctrl+L collapses it and gives the room back. - Command line — it edits like a shell: a real cursor (←/→, Home/End,
Ctrl+A / Ctrl+E), Ctrl+W, Ctrl+K, Ctrl+U, Tab completion over the cel
vocabulary and live workspace and orchestrator names. ↑/↓ walk the history,
filtered by whatever is already typed, and Ctrl+R opens a history picker you
can type into. Under it are two lines that never overwrite each other: a
status message that clears itself after 8 seconds (
--status-secs, 0 = never) and a permanent legend for the focused panel.?+ Enter lists every binding.
The mouse works: click a row to select it, double-click for that panel's
primary action (open the unit, open the message), roll the wheel over a panel
to scroll it, click [resolve] [reply] [go to] in the detail view — or
[worker] / [unit], which take you to the page of whoever sent it — and the
buttons in the unit and worker views.
Mouse reporting and the alternate screen are turned off on every exit path
there is — quit, Ctrl+C, SIGTERM, a crash — because a terminal left in mouse
mode is a terminal you cannot select text in.
What you type is either a command (it starts with cel, cel-fanout,
cel-linear, gh or herdr) — which runs as-is, after the same allowlist the
agent console is held to — or a sentence, which is sent to a small model.
Anything it returns is proposed: it lands on your command line and runs when
you press Enter again, or disappears on Esc. The model never executes
anything.
A sentence the console can already answer comes back as an answer rather than as homework. The state the model is given carries every worker, its verdict, its quiet time and its PR, so "which workers are idle", "why is ABC-49 stalled" and "what is waiting on me on alpha" are answered from it and nothing runs. Otherwise a sentence can come back as a chain of up to five commands when it needs a sequence — every line is put to the allowlist before any of them runs, and then they run in order, stopping at the first one that exits non-zero. And a miss is not a dead end: the model is asked a second time for up to three candidates, each with a one-line reason, and they are listed in the OUTPUT panel:
no command for that - did you mean:
1 cel inbox read --for root --workspace alpha -- what the steward left there
2 cel fleet -- state of every workspace
3 cel-fanout status --workspace alpha -- what is in flight
Type 1, 2 or 3 and Enter — or click one — to put it on the command line.
It is still only a proposal.
The model is optional, and everything above works without it. It is a router,
not a chat: it knows the vocabulary table and the current fleet, and it can
only pick commands from that table. To wire one in, write
~/.local/share/cel/config.yaml (chmod 600 — it may hold a key):
console: provider: openrouter # openrouter | anthropic | openai | deepseek model: anthropic/claude-haiku-4-5 key_env: OPENROUTER_API_KEY # read from the environment, else console.key router: # optional; without it, the chat model routes provider: openrouter # openrouter | typesafe model: typesafe/jev-1.13 # on typesafe: jev-latest key_env: OPENROUTER_API_KEY # else console.key_env, else console.key run_confidence: 0.75 # at or above this, the chain is proposed as usual propose_confidence: 0.5 # at or above this, proposed with the intent named rows_inline: 6 # more rows than this and the console answers instead summary_timeout: 4 # seconds the English summary may take before the # counted lines stand on their own
The router
Routing a sentence is a classification problem — ten intents, one of them
right — and a decision model answers exactly that: it does not generate
text, it picks one option from a set you define and returns the whole
probability distribution with it. Configure console.router and the sentence
goes there first: "what is happening with bundle" came back in 0.4 s against
6.5 s for the chat model, because the model picks a label and the console
fills in the command itself.
That last part is the safety property. The router never writes a command line.
It answers product_status 0.98, and the console — not the model — finds the
product in the fleet it already holds, takes the workspace that owns it and
builds the three commands. A slot it cannot fill (no product named, a ticket
no worker carries) is a miss, and a miss falls through to the chat model
rather than guessing: a guessed --workspace is somebody else's mailbox.
Every question goes in one request. The decisions endpoint evaluates them in parallel, so asking five costs what asking one costs: beside the intent the console asks which workspace and which product the sentence is about (used only where no name was typed — an id always comes from the sentence, never from the model), whether carrying it out would discard work, kill a process or change GitHub state, and whether the state even holds the answer.
Confidence then routes rather than merely gating:
- at or above
run_confidence(0.75) the chain is proposed as it always was; - at or above
propose_confidence(0.5) it is proposed with the intent named —I think you mean product_status - Enter runs it; - below that, the console asks one question back, built from the top two
intents in the model's own probability order:
did you mean (1) product_status or (2) fleet?
A sentence the model calls destructive is proposed rather than run however
sure it was of the label: sure is not the same as safe. (min_confidence from
before this still works and is read as run_confidence.)
Answers instead of transcripts
Ask about one stalled worker and the console used to print the whole worker
table. When a command returns more than rows_inline (6) rows, three things
now share the work and none of them does another's job:
- the decision model picks which rows answer the question —
choicereturns a probability for every option, so ranking twenty-two rows is one request — and scores how urgent each is, fromnothing to dotostuck or failing - needs you now. It writes nothing; - the console computes every number exactly from the fleet JSON it already holds: the counts, the ages, the tally of everything left out;
- the chat model turns those counted facts into two to four plain lines.
3 of 22 workers need you.
ABC-49 stuck - needs you now, stalled 41m (worker ABC-49-slug)
ABC-51 waiting on a person, finished 2h, PR #61 (worker ABC-51-slug)
ABC-53 stuck - needs you now, refused 5m (worker ABC-53-slug)
19 others: 14 landed, 3 running normally, 2 released. `a` shows them all.
No number in the prose may be absent from the facts it was given — an invented
count is refused and the counted lines stay. Those lines are drawn first and
the English is swapped in only if it arrives inside summary_timeout (4 s),
so the console never waits on a model for an answer it already has. a (or
r) shows the full transcript, unchanged.
The chat model keeps the two jobs that need prose — the answer written from
what the commands printed, and every sentence the router could not place — so
a router that is down, misconfigured or absent costs nothing but latency.
--no-router on the command line and console.router.enabled: false in the
config bypass it entirely.
Endpoints come from agents.yaml's provider table; the request is one plain
fetch with no SDK. With no provider configured, a sentence gets one line
saying so and the command line carries on working.
cel console --render-once prints the panels as plain text and exits — no
alternate screen, no mouse — for pipes and for when a full-screen UI is the
last thing you want; --status "…" renders a status line with it. cel console --ask "<sentence>" prints the chain one command per line, or the
answer when the state holds one (exit 0), or the numbered options on a miss
(exit 1). --ask also prints what the router chose and how long it took on
stderr (router: product_status 0.98 in 0.4s). --render-once --unit <product> and --render-once --worker <id>
print the two depth views the same way. cel run console --agent still starts
the original Claude pane for people who would rather talk to a full agent.
The review loop runs itself
Declare a reviewer in workspace.yaml:
review: { runtime: omp, model: gpt-5.6-sol }
and orchestrators start one reviewer pane per PR. A reviewer delivers one
verdict per round and records it in the delegation ledger — cel-fanout review <id> approved|changes --by <alias> — which is where a review exists;
cel-fanout land accepts that verdict on repos where the PR author and the
reviewer share one GitHub account, and GitHub approval remains the authority
where they do not. review.post: github opts a repo into posting the review
to GitHub as well, for reviewers that have an identity of their own; the
default is inbox, because an agent approving its owner's PR under the
owner's account is the owner approving himself in public. Reviewers prompt
workers directly on request-changes; workers fix, push, and prompt back for
re-review; you hear about it only on approval or escalation. A steward tick
(every 5 minutes by default, deterministic, token-free) backstops the whole
thing: verified disposable worktrees are removed, positively identified idle
workers can be reaped, stalled PRs are nudged, and blocked agents or preserved
unlanded work are named until dealt with. Unknown state is a reason to retain
work, not evidence that it is safe to delete.
Agents talk without hijacking your keyboard
herdr agent prompt types into a pane, so a status report landing while you
are mid-sentence merges with your draft. Routine agent-to-agent traffic goes
through cel inbox instead - a JSONL mailbox per workspace, with a read
cursor per reader so a standing root pane and the console can both drain
root's mail without stealing each other's place - and delivery is out-of-band:
on claude, recipients run a Monitor background task (cel inbox watch, or
cel inbox watch --all-workspaces for the console) so new mail arrives as a
notification, and a UserPromptSubmit hook drains anything missed on your
next turn. omp has neither, so cel run loads tools/hooks/inbox.omp.ts on
root and orchestrator panes: it watches the mailbox and raises ui.notify,
then injects unread mail into the next turn once - it never touches the
composer. Console tell to a live orchestrator is mail alone; nudge and
talk are the deliberate interrupts. Escalations that truly cannot wait still prompt, deliberately.
Mail to root is for a person: escalation, decision, blocked.
Ticket status is the ledger's job — cel-fanout status is the authority and
cel fleet, the dashboard and the console all render it — so nothing reports
it to root as mail. One box's root mailbox had taken 553 messages in nine
days, 466 of them status, and 72 escalations were sitting under them; cel inbox send root --kind status now warns and still sends, because a role file
in the wild may still do it.
A mailbox nobody reads is a fault, not silence. cel fleet --json carries
mail: {to_root_unread, oldest_secs, reader} per workspace — reader is the
live pane whose alias matches the mailbox, or a running console, or empty —
cel doctor says alpha: root has 13 unread, oldest 7h, nobody reading it,
and the steward raises one rolled-up blocked item per workspace (which takes
the desktop-notification path, as an escalation now does too) when an unread
escalation is older than CEL_ROOT_UNREAD_SECS with nobody alive to read it.
What is left gets ranked. cel inbox open --for root --ranked, the
console's digest and the dashboard card show the top inbox.top_n (3) of the
unread, then and N more. A decision model scores each message once against
four concrete levels (0 informational … 3 blocking work right now) and
supplies only that level; counts, ages, ordering, the cut and the caching
are the code's. Below triage.min_confidence (0.6) a message keeps its kind's
default rank, so an unreachable model degrades to the old ordering rather than
losing messages.
Work items — the thing being made
The factory used to track its machines and not its products: cel fleet showed workers, cel-fanout status delegations, cel-linear board
tickets, gh pr list pull requests. Nothing represented THE THING BEING MADE
as it moves from ticket to worktree to PR to merged to released, so "what
happened to ABC-49" had no single answer.
A work item is that noun. It is keyed by its ticket id when it has one and by its delegation id when it has none, and it joins whatever the four systems know — each part optional, because an item exists the moment any one of them has heard of it.
cel work [<workspace>] [--stage S] [--product P] [--json] [--fresh]— the board: every item grouped by stage, newest activity first inside each group, one line each with who or what holds it now, its age and the next action a person would take. With no workspace, every registered workspace.cel work <key> [--json]— one item: its four parts and its full event list.cel history [<workspace>] [--since 7d] [--key K] [--json]— the vertical timeline: events grouped by work item, groups ordered by most recent event, each group in time order, drawn with a spine so the eye follows one item down the page. No workspace named means every registry workspace.--jsonis always ONE array, whatever the number of workspaces:[{key, title, stage, next, ws, product, last, events: [...]}, ...], newest group first across all of them, each item carrying itsws.
alpha - work history 7 days, 41 events
|
+- ABC-51 the console answers merged 2h
| 09:12 ticket In Progress
| 10:31 worktree worker started (pi/opus-pi)
| 12:04 pr #66 opened
| 13:58 pr approved by alpha-pr-66-review
| 14:22 pr merged
|
The stage is computed in code, first match wins, and the order is the
factory's own sequence: released, merged, landing (PR approved AND
green - approved by GitHub or by the ledger verdict cel-fanout review
records, green meaning no red check and a passed gate verdict, else passing
checks: the same evidence cel-fanout land consumes), review (a PR is open), building (a worker holds it — including
unconfirmed, a dispatch whose prompt was never accepted), ready (a
start-able ticket nobody has started), backlog. A gate that timed out or
produced no verdict is not a failure and never renders as one.
The JSON shape is frozen here. The console and the dashboard render this document and add nothing to it; any field a view needs is added to the model, not to the view.
{ "key": "ABC-49", "keyed_on": "ticket|delegation|pr",
"ws": "alpha", "product": "bundle", "title": "...",
"stage": "building", "next": "collect", "last": "2026-01-02T00:00:00Z",
"ticket": { "id": "ABC-49", "state": "In Progress", "url": "...",
"created": "...", "updated": "..." },
"delegation": { "id": "ABC-49-slug", "state": "collected", "branch": "...",
"repo": "widget", "worker": "wFC:p1", "profile": "opus-pi",
"worktree": "...", "verdict": {...}, "review": {...},
"landed_at": "...", "released_at": "..." },
"pr": { "number": 66, "state": "MERGED", "review": "APPROVED",
"checks": "SUCCESS", "url": "...", "repo": "widget",
"branch": "...", "opened": "...", "merged": "...", "updated": "..." },
"events": [ { "at": "...", "source": "ticket|worktree|pr|mail|afk|reviewer", "what": "..." } ] }landed_at and released_at are read from the delegation's history and
never stored twice. Every state transition a delegation makes appends
{state, at, by} to that array — half the lifecycle used to be unrecorded, so
no timeline could draw it. A row written before that (no history) has one
synthesised at READ time from created and its verdict/review stamps, and the
synthesised history is never written back to disk.
Events merge the ledger history, the ticket's stamps, the PR's opened / reviewed / merged stamps, the reviewer registry (who reviewed it, and when), the AFK act log (what the box did on its own overnight, and under which pre-authorisation) and all of the workspace mailbox — the old console timeline windowed to 24 hours and so lost every message about anything that took longer than a day.
One gh pr list per repo and one cel-linear board per workspace, cached
together in $CEL_CACHE/work-<ws>.json for work.cache_secs (120 by default,
in ~/.local/share/cel/config.yaml); --fresh bypasses it. The ledger and
the mailbox are local files and are never cached — with gh and cel-linear
both failing the view still renders what the box itself knows.
Watch and steer
cel fleet— the whole box in one deterministic read: every workspace, every product (a repo in no declared product is its own), whether its orchestrator is live, workers in flight across the product's repos against the cap, stalled and unlanded work, and root's mail. Token-free and free of agent judgement;--jsonfor scripts. The console answers from this, never from memory.- Liveness: what the pane is doing, not how long it has been quiet. The
stall timers ask one question - how long since anything moved - and every
real failure on this box in the two days to 2026-09-20 slipped through them,
because the runtime's own status was wrong and the clock had not run out: a
suite hung for three hours while herdr said
working, a worker burned three cores while it saididle, another sat insleepblocks reportingworking, a fourth saiddonewhile waiting on a task it had started. Code filters first - only a worker whose pane text has not moved instill_secs(300) while the runtime saysworking, one reportedidle/doneagainst arunningledger row, or one the marker rule already caught is a candidate - and each candidate costs one decision request carrying the last 40 lines of its pane and three named facts, nothing else. The answer isworking,waiting_on_input,looping,crashed,finishedorunclearwith a confidence:loopingandcrashedare reported at any age,waiting_on_inputafter 15 minutes,finishedwhile the ledger still says running. It only ever REPORTS - no path from an answer to a kill, a release or a prompt - and the timers decide alone whenever there is no router, no key, a failing endpoint or an answer belowliveness.min_confidence.cel-fanout whyprints the reason in words with how much longer the timer would have taken;cel fleet --jsoncarriesactivityandactivity_confidence; the console'slivecolumn showsworking!loopwhen the runtime and the pane disagree. Configure underliveness:in~/.local/share/cel/config.yaml(enabled,model,min_confidence,still_secs,wait_secs,lines);cel doctorsays whether it is on and which model answers. - Memory is part of that read. Every process whose working directory is
under a worker's worktree is that worker's - its shell, its agent, its tools
and its test runs - so summing their
VmRSSgives what a worker actually costs: ~340 MB for a pi worker, ~370 MB mid-gate.cel fleetprintsmemper product andbox 6.9G free of 24Gon each workspace's head line;--jsoncarriesrss_mbper worker, per unit and per orchestrator pane plus aboxblock. The console shows the same numbers per row, with the box's headroom on the status edge (warn under 15% available, bad under 8%) andsin the unit view to sort workers by memory. The steward raises one blocker under 10% naming the largest trees, and tells a product's orchestrator when one worker passesCEL_MEM_WORKER_WARN_MB(2 GB) - it never kills anything, because a sweep that reaped a worker mid-gate would destroy the work it was measuring. cel dash— one dashboard for the whole box: an attention queue ("needs you"), every in-flight branch joined with its agent + PR + CI state across every workspace, sticky filters, and a prompt box that drives any agent./classic?ws=<name>keeps the old one-workspace page for this release.cel publish <file>— self-hosted pages server (tailnet-private by default, per-document promotion to a public tier). Published pages carry a feedback widget that routes straight back to the agent that published them — select text to comment on the exact passage.cel gc/cel steward— the cleanup and liveness machinery, also runnable by hand.cel gc --orphansreaps what the plane started and nobody owns any more: inbox watchers whose console exited, test fixtures whose worktree is gone, bare shells on ptys no pane owns, gate runners whose suite was killed. The steward does it once a tick and says so in one line.cel box space/cel gc --box— the box is a resource the factory spends.cel box spacemeasures what is large, what of it is reclaimable, and which of three classes it is in: ours (worktrees,~/.cache/cel), regenerable (docker images and build cache, toolchain caches — deleting costs a re-download, never data) and someone's (restore dumps, dated backups — reported with an age and a suggestion, and never swept by anything).cel gc --boxsweeps the first two after the worktree pass, on a fourteen-day age for docker with base images exempt;--dry-runcovers it exactly as it covers the old work. The steward sweeps on a six-hour cadence andcel doctorwarns below a free-space floor, naming the largest reclaimable class and the command that clears it.
What runs in the background
Four kinds of thing run without you: one timer, long-lived servers, a per-turn hook, and the agents themselves. Nothing else is hidden.
The steward — the only scheduled process
cel steward is a single pass over the whole fleet. Its checks do not call a
model, but they can remove verified disposable worktrees, terminate eligible
idle workers, maintain servers and prompt agents. Those agents may then spend
tokens under their existing account permissions.
cel steward --install # systemd user timer, every 5 minutes cel steward --install --interval 10 # a different cadence cel steward --install --remove # stop it cel steward # one tick by hand systemctl --user list-timers | grep cel-steward journalctl --user -u cel-steward # what it did, and when
A systemd user timer rather than a shell loop in a pane, because the
steward's whole job is to be running when nobody is watching, and a pane loop
dies silently with the herdr session. Persistent=true, so a tick missed while
the box slept runs on wake — a ticket moved to the trigger state overnight is
still waiting in the morning. Enable loginctl enable-linger or it stops when
you log out.
Each tick, in order:
| Step | What it does |
|---|---|
| garbage collect | retains dirty, unlanded, live or unverifiable work; reaps only ownership-verified workers observed idle for 12h (2h under memory pressure), never merely old processes |
| review sweep | for each repo, gh pr list --author @me: approved PRs, changes-requested with nobody working, and red gates with nobody working each nudge the owning orchestrator |
| untracked PRs | a PR older than a day whose branch carries no ticket id — invisible on the board, so it is surfaced |
| blocked agents | any agent waiting on input is named every tick, deliberately without dedup; that is your queue |
| stalled workers | the one failure no inbox watcher can see: a worker whose provider stream died sends no mail and reads as idle. A fatal pane marker (server_error, F5 to Retry) quiet for 15 minutes, or an agent gone from the roster, escalates to root naming the ticket, pane, stall age, whether the branch is pushed and what is uncommitted — louder when the work is unlanded, because that is when delay costs the work itself |
| stale mailboxes | a recipient with unread mail older than 30 minutes has probably lost its inbox monitor, so its pane is told to re-arm and drain |
| page feedback | feedback whose publishing pane is gone stays a warning until someone drains it |
| ready tickets | tickets in the workspace's trigger_state with no branch anywhere are handed to the product's orchestrator, else root — this is what makes "move it to Todo" start work |
| servers | cel dash --ensure - the one dashboard, never one per workspace; expired public shares are deleted, not merely refused |
| ensure orchestrators | every product declaring orchestrator: auto with no live pane is started with cel run orchestrator, at most once every 30 minutes so a crash-looping one is not relaunched every tick |
| updates | once a day, whether this plane is behind — the newest release tag on the release channel, the commits on origin/main on the main channel, rolled up into one root item naming how many and which build you are on |
Nudges are rate-limited per subject (4 hours; the update check, 24) through
~/.local/share/cel/steward-state, so a stuck orchestrator is reminded rather
than spammed.
Servers — started on demand, kept alive by the steward
Each runs under setsid, so it outlives the pane or agent that started it, and
logs to ~/.local/share/cel/logs/.
| Service | Where | Kept alive by |
|---|---|---|
cel dash |
one for the box: dash: port: in ~/.local/share/cel/config.yaml, else the first workspace's dash.port, else :7770; every other workspace's old dash.port redirects to it for one release |
the steward, every tick |
cel pages |
tailnet tier, :7780 |
cel pages --ensure |
cel pages --public |
internet-reachable tier, :7781 |
cel pages --ensure |
cel pages tunnel |
cloudflared/ngrok, for sharing without Tailscale | started by hand |
--ensure is idempotent: it starts the service only if it is not already
answering, so it is safe on a loop.
The hooks — the only things that touch a live session
Four hooks, all registered in the agent's settings by cel setup, all failing
open on their own errors so housekeeping can never take an agent down:
| Hook | Event | What it does |
|---|---|---|
inbox-drain.sh |
UserPromptSubmit |
drains the pane's own mailbox at the start of a turn, so mail is never typed over what you were writing |
inbox-guard.sh |
Stop |
if the pane has unresolved decisions, blocks the turn's end once with the count and the re-arm instruction — a dead monitor can no longer go unnoticed |
orchestrator-guard.sh |
PreToolUse |
read-only orchestrators: from a root or orchestrator pane, refuses commits, pushes, merges, branch switches and edits under repos/ (by shell or by the native edit tools). Workers are untouched. CEL_GUARD=0 opts a pane out; CEL_GUARD_ALLOW_ONCE=1 approves one call |
learn-stow.sh |
SessionEnd |
decays and budgets the workspace's learnings.md |
Identity for all of them comes from the pane's working directory — a workspace
root is root, <ws>/repos/<repo> is that repo's orchestrator, a worktree is
a worker — so nothing needs env plumbing through herdr.
cel inbox watch is the other half of the inbox — a Monitor an agent arms
inside its own session to notice mail arriving while it is idle. It is not a
daemon; when it dies, the Stop hook and the steward's stale-mailbox check are
what catch it.
Services
cel services is one list of everything this box is running on a port: the
services a workspace declares under services: and every cel-fanout try
preview, joined.
services: - name: builder # name + url only stays valid, and is url: http://localhost:4322 # observe-only: there is nothing to start cmd: pnpm --filter builder dev # with a cmd it can be started and stopped cwd: repos/widget health: / # a path that must answer 200 within 2 s env: { MODE: dev } restart: auto # the steward tries once before it escalates
Each row carries state (up when the port is listening, healthy when the
health path answers, else down), the port, the pid and the resident memory of
the process tree in its cwd, how long it has been up, and reach — the URL
that works from somewhere that is not this box. cel services start|stop| restart|logs <name> runs it in a herdr pane under the workspace's own pane and
remembers the pane in <ws>/.cel/services.json; a service without a cmd is
observe-only and says so.
Reach exists because every dev server and every preview binds 127.0.0.1,
which from a laptop is the laptop. The dashboard already listens on the tailnet
IP, so it proxies them: http://<tailnet-ip>:<dash-port>/svc/<port>/… is
forwarded to loopback, WebSocket upgrades included, for ports that belong to a
known service or preview only, and only with the dashboard's own control token —
a tailnet neighbour cannot browse this box's loopback. cel-fanout try prints
that URL as its last line when the dashboard is up.
One dashboard for the box. There used to be one server per workspace,
each polling the same herdr and gh and each drawing the same box-level rows,
so one gateway read as several. Now cel dash runs one server covering every
registered workspace; a workspace's services panel lists only its own
services, and anything tagged box (and the subscriptions) appears once in
the Box panel. The old per-workspace ports answer with a redirect to it
for one release; cel doctor names the dashboard and any leftover
per-workspace server (cel dash --restart stops them).
The steward probes every service that declares health: once per tick. Two
consecutive down ticks raise one rolled-up blocker to root naming the service
and the last line of its pane; back up clears it. restart: auto restarts it
once first, and says so in the blocker.
Some services belong to the box rather than to any workspace — the auth
gateway is used by every workspace and owned by none. They are
declared one JSON file per service in ~/.config/cel/services.d/ (CEL_SERVICES_D),
in exactly the shape of a services: row plus a bare port: shorthand, 0600
in a 0700 directory because env may carry a bearer. They appear in
cel services under a box block — after the workspace's own rows, or alone
when you run it from outside every workspace — carry workspace: "box" in
--json, start and stop by name, are probed by the same steward sweep, and
keep their state in ~/.local/share/cel/services/<name>.json rather than in
any workspace. env values whose key looks like a credential are rendered
*** everywhere. cel gateway install writes the gateway's, and cel doctor says
how many the box declares and how many are healthy.
In the console, S opens the SERVICES view — one row per service and preview,
o opens its reachable URL (printed as well as opened, so the link is
clickable through herdr from a laptop), S starts or stops, r restarts, L
reads its log, x stops a preview. The dashboard has the same rows as a card.
The agents
The orchestrators, workers, scouts, spikes and reviewers are herdr panes, not
services. They are started by cel run and cel-fanout delegate, and they
stop when their pane does. cel-fanout status is the ledger of what was
delegated, on which model, and where it got to.
A worktree is a fresh checkout, so the gitignored files an app needs to run —
keys, a built wasm directory — are simply absent from it. repos[].seed in
workspace.yaml names them and cel-fanout delegate places them (symlink, or
copy: true) before the worker starts, telling it they are not its to commit.
With repos[].preview declared, cel-fanout try <id> then starts that ticket's
branch from its own worktree on a free block of ten ports and prints the url —
so trying a PR no longer means parking the one shared checkout on it. --stop
ends the preview, release ends it for you, and cel-fanout status shows the
port it is on.
The console runs one background watch of its own: cel inbox watch --for root --all-workspaces, so a decision raised in any workspace raises a desktop
notification rather than waiting for someone to look. Like every watch it is
not a daemon — it lives and dies with the console.
Any model, any CLI, per worker
runtime: picks the CLI for a role; a worker profile picks the CLI and
the model and the reasoning level, and any launch can be pointed at one:
thinking: medium # default level when no profile applies worker_profiles: default: { runtime: omp, model: openai-codex/gpt-5.6-sol, thinking: low } astra: { runtime: codex, model: gpt-6-astra, thinking: low } deepseek: { runtime: omp, model: deepseek/deepseek-flash, thinking: medium, fallback: openrouter/deepseek/deepseek-v4.1-flash }
Bind a profile to a role and it applies with no flag at all:
role_profiles: root: opus # deepest model for planning and delegation orchestrator: default # cheaper for coordination worker: default scout: opus # read-only research; needs web tools, not volume
This is the only way to configure root and the orchestrators, because neither
is launched by hand — root comes up from the layout when the workspace boots,
and the sub-orchestrators are started by root itself. A flag for those two is
really an instruction to an agent, and agents forget. scout is optional and
falls back to worker; bind it when investigations want a different CLI or
model from the one that ships tickets.
cel profiles # resolutions + role bindings cel run worker --repo r --branch b --profile astra # override once cel-fanout delegate r b spec.md --profile deepseek # profile lands in the ledger
Three things make this more than a flag:
- Every CLI spells it differently, and the plane knows.
agents.yamlrecords each runtime's model flag and how it takes a reasoning level — claude--effort, omp/pi--thinking, codex-c model_reasoning_effort=…, opencode not at all. A level a runtime doesn't accept is clamped down its own ladder, never passed through to be rejected at launch. fallback:is checked before the pane spawns. It names the same model reached a different way — a direct provider API, or an aggregator. If the primary's key is missing from the workspace environment, the fallback is used and said out loud. Without that check an unauthenticated worker starts, prints an auth error and idles, which from outside is indistinguishable from one thinking hard.- The choice is recorded.
cel-fanout statusshows which model built which branch, so running two profiles at the same ticket is evidence rather than anecdote.
Keys live in the workspace's gitignored env.local; nothing but the model id
ever reaches this repo.
Team workspaces
A team workspace has three layers:
| Layer | File | Committed | Holds |
|---|---|---|---|
| Team contract | workspace.yaml |
yes | everything, including the default routing |
| Personal routing | workspace.local.yaml |
no | routing overrides only |
| Secrets | env.local |
no | keys |
The local file may change role_profiles (workspace-level and per
products[]/repos[] entry), add or replace worker_profiles, and override
env: keys. It may not change policy, tickets, repos, products,
gates, prefixes, merge or review settings, or services - and the policy
block rendered into CLAUDE.md/AGENTS.md always comes from workspace.yaml
alone. cel doctor fails if workspace.yaml is not committed.
Joining a team workspace: clone it (cel ws add <url>), copy
workspace.local.example.yaml to workspace.local.yaml, choose your
profiles, run cel doctor.
Per-box routing
A team box under one login per person, each on a different provider, has a
problem env.local cannot solve: root and the orchestrators only ever get a
model from role_profiles, which lives in the workspace's own committed
file - one binding, shared by everyone who clones it. A login without the
credential that binding names has no route for either, short of an
uncommitted edit to a file everyone else also reads.
workspace.local.yaml, gitignored, sits beside workspace.yaml and overrides
role_profiles (workspace-level, and per products[]/repos[] entry,
matched by name) and adds worker_profiles - or replaces one whole, by name,
if it names one the committed file already has, so a local sonnet profile
never inherits a base sonnet profile's via:, fallback: or thinking:.
It is allowed to do only that: it cannot add a repo, change policy, or hide
anything a teammate reading workspace.yaml needs to know. Local always wins
over the committed file. cel doctor warns on anything else the file
declares (an unrelated top-level key, or a products[]/repos[] name with
no counterpart in the committed file) since the merge otherwise drops it
with no error.
# workspace.local.yaml, gitignored, this box only worker_profiles: sonnet: { runtime: claude, model: claude-sonnet-5, thinking: low } role_profiles: orchestrator: opus # a base profile - resolves fine, no local worker_profiles entry needed worker: sonnet # a local profile - see worker_profiles above
cel profiles marks a binding or a profile that came from this file with
(local).
Subscriptions
The fleet does not run on API keys alone. It runs on two signed-in subscriptions — Claude (through pi's OAuth and through Claude Code) and Codex (ChatGPT, through omp) — and a five-hour window at 100% stops every worker on that account just as hard as an empty balance does, without an error message anywhere anyone looks.
cel quota asks both accounts directly and prints them above the API
balances: one row per signed-in account, each window with its percentage and
the local time it resets, and whether extra usage is still a path. A token is
never printed anywhere — an account is named by its provider and a short
stable id. The readings are cached for 60 seconds, so the console's status
edge (claude 16%/41% · codex 9%/62%, amber at 80, red at 100), the q QUOTA
view, cel fleet --json and the dashboard's Subscriptions card all read the
cache rather than calling a provider on every draw.
The steward checks the windows once a tick and raises one rolled-up item per
account — a status over CEL_SUB_WARN_PCT (80), a blocker at 100 — and
takes it down again when the window drops. A profile routed at a spent 5h
window is vetoed before its pane spawns, the same way a dry balance is.
Gateway — several subscriptions behind one door
One Codex or Claude subscription maxes out. cel gateway puts all of them
behind one loopback door and spreads workers across them, and shows what each
one has left.
cel gateway install # CLIProxyAPI as one box service, loopback only cel gateway status # one row per account: provider, id, plan, email cel gateway login claude # sign another subscription in (--no-browser: device flow)
Under it is one process, cli-proxy-api: it is both the credential vault —
one OAuth JSON per account in a 0700 auth-dir under the box state directory,
never inside a repo — and an OpenAI/Anthropic-shaped surface on 127.0.0.1
that mints the OAuth itself and picks an account by session key. It
replaced omp's broker + gateway pair, which on this box had never run: every
worker queued on one account, and on 2026-09-20 one of them hit its five-hour
limit and stalled four workers while another sat at 16%. omp is still
installed — it is an agent runtime — but nothing under cel gateway calls it.
cel gateway install writes the proxy's config.yaml (it has no flag-only
mode) with four settings that are not negotiable: host: 127.0.0.1, because
one api-key unlocks every subscription in the vault; routing.session-affinity: true, because it defaults to false and false means a worker switches
account mid-conversation; no management block at all, so there is no remote
control surface to get wrong; and an auth-dir in the box state dir. Health is
GET /healthz, unauthenticated — the real probe the old gateway never had.
A profile reaches it with one field:
worker_profiles: gw: { runtime: pi, model: codex/gpt-5.5, via: gateway }
cel run worker --profile gw writes the gateway's providers into
~/.pi/agent/models.json (merged — the owner's other providers are untouched,
and the model list comes from the gateway's own /v1/models, with plain ids:
Claude models go through pi's anthropic-messages surface, everything else
through openai-completions), and sets CEL_SESSION_ID in the pane, which is
the only thing affinity can pin an account by — pi sends no session
identity of its own, so without the injected x-session-id header every worker
on the box is the same anonymous session.
Three things are worth knowing before you rely on it:
- Affinity defaults off upstream. The config this writes turns it on; a hand-edited config that loses the line gives every worker a different account from turn to turn, which is worse than one account.
- The proxy reports no usage. Built-in usage accounting was removed upstream, so an account row carries no windows until the usage reader lands; "no usage probe" is not "0% used".
- One api-key grants every subscription in the vault to anything that can
reach the port. The service binds loopback only, the config is
0600, and nothing prints the key.
Its accounts land in the same places the signed-in subscriptions above do: the
QUOTA view's via gateway section, the dashboard's Subscriptions card, and
cel gateway status --json - one row per account, carrying source: gateway.
Tickets
Set tickets: { system: linear } and the plane wires Linear's official MCP
server into Claude sessions at sync, while every other runtime shares
cel-linear (a thin GraphQL shim). The lifecycle contract —
In Progress on start, In Review + PR link on open, Done on merge — rides the
policy block into every agent.
Commands
| Command | What |
|---|---|
cel setup / cel doctor |
install everything / verify the box |
cel fleet [--json] |
the whole box in one deterministic read: orchestrator liveness, workers n/cap, stalled and unlanded work per product, root's mail per workspace |
cel work [<ws>] [<key>] |
the work item: ticket + worktree + PR + mail as one thing, grouped by stage with the next action per row; <key> renders one item and its events; --stage · --product · --json · --fresh |
cel history [<ws>] [--since 7d] [--key K] |
the vertical timeline: every event grouped by work item, newest group first, drawn with a spine |
cel update [--check·--rollback·--channel] |
move to the newest release tag (or to origin/main on the main channel), re-link and re-render, then verify; --check prints what you have not got yet and exits 1 when behind; --rollback undoes the last update; --channel main·release picks which stream this box follows |
cel ws new · add · sync · list · push · env |
workspace setup |
cel ws up · down · reset · status <name> |
a workspace has a declared shape and this puts it back: up reconciles (herdr workspace, declared panes, an orchestrator per product that wants one, and a rename for any live agent herdr has lost the name of) and is safe to run twice; down stops the agents it owns and closes its panes, refusing over work that is neither pushed nor landed; reset is down-then-up; status is up --dry-run as a table. It never removes a worktree or touches the ledger — letting work go is cel-fanout release |
cel console |
the desk you keep open: fleet, decisions, inbox and a command line that also takes a sentence |
cel run [root·orchestrator·worker·reviewer] |
start an agent, role injected |
cel run orchestrator --product <p> |
start the orchestrator for a product (1..n repos) |
cel profiles |
worker profiles and the exact launch flags each resolves to |
cel steward --install [--interval m] |
run the steward on a timer (the thing that makes any of it proactive) |
cel quota [provider] [--json] |
the signed-in Claude and Codex subscriptions — every window, its percentage and when it resets — above the credit left per API provider; a route below its floor, or on a spent 5h window, is vetoed before a pane spawns |
cel learn add · list · reinforce · stow |
durable facts the workspace has established — pinned / aging / perishable — budgeted into every agent's policy block |
cel inbox open · resolve |
decisions stay open until resolved; reading one does not answer it; --ranked puts the most urgent unread first |
cel-fanout scout <repo> <brief> |
an investigation: disposable worktree, a report as the deliverable, no ticket, no PR |
cel-fanout spike <repo> <brief> |
a trial: throwaway code in a disposable worktree, a report as the deliverable, never shipped |
cel-fanout land <id> |
the one merge path — fleet-authored, approved, green, gate passed, policy.merge allows |
cel-verify <worktree> |
a structured verdict for a branch: gate result, red-then-green, diff, CI, review |
cel dash |
the box's one dashboard |
cel publish / cel pages |
self-hosted documents |
cel gc [--reap h] [--orphans] [--box] |
reclaim worktrees + idle agents; --orphans reaps processes with no owner; --box sweeps docker and toolchain caches |
cel box space [--json] |
what is large on this box, what is reclaimable, and what is nobody's to delete |
cel inbox send · read · count · watch |
agent messages that never type into a pane; --all-workspaces for the console |
cel steward |
one proactive tick over the whole fleet |
cel spike / cel promote |
throwaway repo → real repo |
What lives where
The repo is mechanism only. Your setup never lands in it:
| Where | Committed? | |
|---|---|---|
| The tool | ~/celestial |
public repo (this one) |
| Which workspaces exist on this box | ~/.local/share/cel/registry.yaml |
never |
| Published pages | ~/.local/share/cel/pages* |
never |
| Workspace content + overrides | ~/ws/<name> (own private repo) |
yours |
| Secrets | <ws>/env.local (gitignored) |
never |
Workspace files always win over plane defaults: layouts.yml over the plane's
layouts, externals.yaml additively, env:/env.local over your profile,
workspace skills/ scoped to that workspace's repos only.
Cleanup safety
GC protects every removal path with delegation and process checks. It requires
a clean attached Git HEAD and proven default-branch ancestry, or a merged PR
whose recorded head is exactly the local HEAD. A missing/broken default,
unknown discovery, running delegation or newer local commit blocks removal.
cel-fanout release also refuses uncertain work unless you explicitly choose
--keep-worktree or the destructive --discard.
The reaper uses herdr foreground-process evidence, a workspace role-file
ownership stamp and a durable observed-idle clock under
~/.local/state/cel/gc-idle.json. Its first observation never kills.
Working/blocked/unknown agents, root/orchestrators, manually launched processes
and unsupported ownership stamps are retained regardless of age. Cooperating
fanout operations share lifecycle locks; unrelated manual Git/herdr changes
cannot be made transactional with external tools. Atomic state replacement
prevents partial readers, but is not a promise of power-loss durability.
Private HTTP services
Private pages and dashboard listeners are local/tailnet control surfaces, not multi-user authenticated applications. Do not tunnel them onto the public internet. Published private HTML is trusted active same-origin content. Only the separate token-addressed public pages tier is intended for sharing.
Private requests require an exact trusted Host. Browser mutations also require
an allowed Origin and a per-process X-Cel-CSRF token; built-in controls supply
it automatically. For reverse proxies or extra tailnet names, configure
CEL_PAGES_TRUSTED_ORIGINS / CEL_DASH_TRUSTED_ORIGINS with comma-separated
exact origins such as https://control.example before starting the service.
Forwarded headers never grant trust. Wildcard binding does not grant arbitrary
Host access. Restart after configuration changes.
Local automation reads {csrfToken} from GET /api/session, then supplies
that value in X-Cel-CSRF with Content-Type: application/json on mutations.
The session endpoint has no CORS permission and is not available on the public
tier. Generated tokens rotate on restart; stale browser tabs need a reload.
Optional CEL_PAGES_CSRF_TOKEN / CEL_DASH_CSRF_TOKEN overrides require at least
32 URL-safe characters; treat them as secrets. This is anti-CSRF and
DNS-rebinding protection, not account authentication: a directly trusted
local/tailnet caller can obtain the token.
Requirements
GNU/Linux with Bash, GNU coreutils, /proc, Git, Python 3 and
curl 7.55.0 or newer. Tests and the Node services use Node 24.x; YAML
processing uses Python yq (the jq wrapper), not the Go program of the same
name. herdr supplies panes. A systemd user session is
required only for the optional steward timer. macOS and other kernels are
rejected before CLI dispatch.
cel setup installs missing prerequisites through apt when available;
otherwise it tells you what to install manually. Fresh application versions
and native checksums live in agents.yaml, external plugin
commits in externals.yaml, and Pi extension versions in
tools/pi/extensions.txt. External overlays must
declare source and a full 40-character Git ref; legacy version-only
declarations are rejected.
Pins govern fresh setup, not a hermetic machine image. Existing executables are retained (GitHub CLI must be at least 2.90), distribution packages follow your package sources, and npm transitive dependencies, plugin build dependencies and later upstream self-updates are not locked by Celestial. Previously installed plugins from unrelated marketplaces are not silently removed. Installation/download failures are reported rather than called success. Review manifests and upstream code before installing.
Development and security
See architecture, contributing, security policy, and the release checklist. CI runs the shell/HTTP suite, source-and-history hygiene checks and a pinned credential scanner. Private release vocabulary stays outside this repository; the public generic check alone is not proof that a particular organisation's information is absent.
Releasing a product
The plane runs a factory, so it can cut a release of the things the factory
makes — and releasing celestial itself is one ordinary instance of that, not
the only meaning. It owns no versioning semantics: a repo declares how it is
released in workspace.yaml, and cel release knows which workflow,
dispatches it, follows it and reports what came out.
repos: - name: gizmo release: workflow: release.yaml # file name in .github/workflows/ input: bump # the dispatch input's NAME; its value is # the command's second argument accepts: [major, minor, patch] # optional: the only allowed values tag: "v{version}" # optional: how the resulting tag is named - name: widget release: workflow: release.yml input: version accepts: semver # the literal word: any x.y.z version_file: VERSION # optional: what `status` reads as "current" changelog: changelog.d/ # optional: fragments, for --dry-run
cel release <product> <version|bump> # dispatch it
cel release <product> <value> --repo <r> # when a product has two releasable repos
cel release <product> <value> --dry-run # the plan and the pending notes; dispatches nothing
cel release status [<product>] [--all-workspaces] [--json]
A product is one or more repos; exactly one of them may declare a release:
block, or you name it with --repo. A repo with no block is not releasable
and says so. Before anything is spent the command checks, in order, that
the repo is releasable, that the value is one the workflow accepts (a listed
word, or semver and greater than the current version), that gh is there,
and that you may dispatch it — gh repo view --json viewerPermission must
say WRITE, MAINTAIN or ADMIN. Otherwise it refuses locally and tells you
where that product's releases come from, because the previous version of this
command passed every local check for anyone who was not the maintainer and
then took a 403 from GitHub at the last step, looking like it should have
worked. On a TTY the run is followed to its conclusion and the resulting tag
and Release URL are printed. cel release status gives one row per releasable
repo: current version, commits since that tag, any run in flight, the newest
Release.
Celestial's own workspace declares exactly this block for celestial
(workflow: release.yml, input: version, accepts: semver, version_file: VERSION, changelog: changelog.d/, tag: "v{version}"), which is why
cel release celestial 0.3.0 and — in a workspace where it is the only
releasable repo — the short cel release 0.3.0 both do what they always did.
The pieces celestial uses on itself are ordinary, documented and reusable by
any repo that wants that shape: tools/release/cut.py
rewrites VERSION and assembles changelog.d/ fragments into a dated
section, tools/release/notes.py prints one
section (including the pending one) without touching the tree, and
.github/workflows/release.yml is the
worked example of wiring them into a workflow_dispatch that opens the bump
PR and publishes the Release from the tag. Copy that shape, or declare
whatever workflow you already have — the plane only needs to be told its name
and its input.
Versioning & updates
Celestial follows semver; VERSION + v* tags are the
truth and every release is announced on
GitHub Releases with
notes from CHANGELOG.md — Watch → Custom → Releases to get
notified. On the box, cel version tells you what you're running as
celestial v0.2.0+31 (d43910c, main): the nearest tag, the commits since
it, the sha and the branch — the +31 is the part people quote, because a
version alone says v0.2.0 for thirty-odd merges.
Cutting a version of celestial is the same verb every other product gets:
cel release celestial <x.y.z> (or the Release
workflow's Cut job in the Actions tab) runs the suite and the hygiene scans,
bumps VERSION, turns [Unreleased] into that version's section and opens a
release: v<x.y.z> pull request — main takes no direct pushes, so the bump is
reviewed like any other change. Merging it tags v<x.y.z> and publishes the
GitHub Release from the same notes, on its own. --dry-run shows what would
ship, cel release status shows the current version, commits since the tag
and the newest Release, and the
release checklist has the long form. A change
worth a changelog line adds its own changelog.d/<branch>.md — first line the
category heading (### Added, ### Changed, ### Fixed, ### Removed), the
rest the entry — and the cut assembles those fragments into the section and
deletes them, so two open pull requests never edit the same CHANGELOG.md line.
A box follows one of two channels, set in ~/.local/share/cel/config.yaml
(box-level, created 0600) and switched with cel update --channel main|release:
update: channel: release # release (default) | main
On release — the default, and what you want unless you are working on the
plane itself — cel doctor, the steward and cel update --check compare you
to the newest release tag and cel update lands on it. On main they compare
you to origin/main: --check names how many commits are ahead of your
build, lists their subjects oldest first and the [Unreleased] notes you
haven't got, and cel update fast-forwards to main's tip. Both channels
refuse to touch a dirty tree or one that is off main, record where they came
from, and undo with cel update --rollback.
Licence
MIT © Geoff Wellman
来源:GitHub · github.com
