Command line¶
cockpit gives scripts and agent sessions the same verdict the menu bar shows.
Install it on your PATH, or run it from
the repository with swift run cockpit … inside cockpit/.
Where the answer comes from¶
Every command that reads the fleet takes the cheapest source that is trustworthy, in this order:
--fixture <name>: a bundled recorded incident, no network at all.- The app's snapshot at
~/Library/Application Support/FleetCockpit/glance.json, when it is under 90 seconds old and you did not pass--fresh,--urlor--via. This answers in milliseconds and opens nothing. - Its own connection:
--urldirectly, otherwise an SSH tunnel through--viaaliases (defaultultra, thenultra-ts), exactly as the app would open one.
The human output always ends with the route it used, for example
via app (ultra, 12s ago) or via ultra-ts.
why, wait, run and mcp ask the dashboard's /api/v1
instead, over the same route, and never read the snapshot or a fixture. The
daemon does the work: it diagnoses the repo, decides when checks are done and
finds orphaned jobs, and these commands print its answer. A dashboard older
than /api/v1 gets the message to update actions-runners on the host.
Global options¶
| Option | Meaning |
|---|---|
--json |
Machine-readable output (pretty-printed, sorted keys) |
--fixture <name> |
Use a bundled fixture instead of the live fleet (cockpit fixtures lists them) |
--via <alias> |
SSH alias to tunnel through; repeat for a fallback list. Default ultra, ultra-ts |
--url <url> |
Reach the dashboard directly, for example over Tailscale Serve |
--fresh |
Ignore the app's snapshot and connect now |
--port <n> |
The dashboard's port on the host, for sentinel. Other commands tunnel to 7878; use --url for a dashboard on another port |
-h, --help |
Usage |
Exit codes¶
| Code | Meaning |
|---|---|
0 |
Fine, or healthy waiting |
1 |
A real fault (a warning or critical verdict), a red check, or a failed action |
2 |
wait: waiting is pointless. run: refused without --yes |
3 |
Nothing could be read, or wait timed out |
64 |
Usage error (a missing argument, an unknown command) |
cockpit status¶
The fleet verdict, one screen.
$ cockpit status
✖ Disk floor is holding jobs
3 jobs held because free disk is under the admission floor. It looks like load; it is disk.
· 36.6 GB free, floor 40 GB
· 3 jobs held at Set up runner: comet-web, comet-backend, ember-ios
· Waiting does not fix this; freeing disk does
→ Preview cleanup
0 running, 0 queued, 3 held, 54 runners · via app (ultra, 4s ago)
The mark is ✔ for ok and info, ▲ warning, ✖ critical, ? unknown. → is
the next move, with its command or URL when it has one.
--json prints { verdict, route, counts }, where verdict is the glance's
verdict object and counts is
{ running, queued, held, runners } (absent when nothing could be read).
Exit code: from the verdict's tone (0, 1 or 3).
When the daemon's last finished tick is more than 4 minutes old, status (and
why) say Collector stalled and exit 3 rather than repeat the old verdict:
an app still connected to a stalled collector keeps rewriting its snapshot with
the last glance, whose "Working" may have been true half an hour ago. See
Verdict reference.
cockpit why <repo>¶
Everything the fleet knows about one repository, from
GET /api/v1/repos/{owner}/{repo}/diagnosis.
<repo> is owner/name, or name when only one repository the fleet serves
has that name.
$ cockpit why comet-web
Fleet: ▲ Host is saturated
comet-web: 3 runner(s)
lost build-host-comet-web lost contact mid-job 5 min ago
busy build-host-comet-web-2 web-e2e · e2e / e2e / webkit · 3 min of ~75 min
idle build-host-comet-web-3 idle
Checks:
develop @ 8c10c20: 1 of 2 done, done in 40m–70m
Failed in the last 2 hours:
web-e2e: Runner lost contact — the host, not the code [not your code]
And for a repo whose run is simply waiting:
$ cockpit why ember-ios
Fleet: ✔ Working
ember-ios: 4 runner(s)
idle build-host-ember-ios idle
…
Queued:
ios-ci — 9m, behind a busy runner (medium); starts in 4m–11m
· all 4 matching runners are busy
→ Wait, or add a runner.
Sections appear only when they have something in them. A repo with no runners
says so: its jobs can only queue. Each queued run shows its age, the cause in
words with the classifier's confidence, and the ETA; under it, · lines are the
classifier's evidence and → its recommendation.
A job orphaned by a draining runner gets its own section, with the commands that end it:
Orphaned (the runner went away while the job was finishing):
build: orphaned by a draining runner — not your code. Every step passed, then MACSERIAL0-lake-api (draining) went away; stuck at "Complete job" for 12 min.
gh api -X POST repos/addisdev/lake-api/actions/runs/38318681067/force-cancel
# about a minute later, once the run shows completed:
gh run rerun 38318681067 -R addisdev/lake-api
--json prints the daemon's diagnosis as it sent it: verdict, runners,
queue, runs, recent, failures, checks, orphans, incidents and
freshness.
Exit code: from the fleet verdict's tone, so why in a script still fails
when the fleet is broken; 3 when nothing could be read or the dashboard's view
is stale.
cockpit queue¶
Every queued run, oldest first, with cause and ETA.
--json prints the glance's queue array. Exit code 0, or 3 when nothing
could be read.
cockpit wait¶
Blocks until a commit's checks finish. Built for scripts and agent sessions that
would otherwise run gh pr checks --watch, which waits forever on a check that
will never start.
| Option | Meaning |
|---|---|
--pr N |
The pull request's head commit |
--sha S |
A specific commit |
--branch B |
The branch's latest commit |
--timeout |
90s, 45m, 2h, or a bare number of minutes. Default 45m |
--recover |
With --pr only: force-cancel and re-run this PR's orphaned runs instead of stopping |
wait asks GET /api/v1/repos/{owner}/{repo}/checks,
which the daemon holds open for up to 5 minutes until the checks finish, and
asks again until --timeout. The rules below are the daemon's
(dashboard/lib/checks.js). Progress goes to stderr, once per change:
$ cockpit wait comet-web --pr 96
… comet-web PR #96: 2 of 5 done, done in 11m–26m
… comet-web PR #96: 4 of 5 done, done in 3m–8m
✔ comet-web PR #96: 5 of 5 done
| Exit | Printed | Meaning |
|---|---|---|
0 |
✔ |
Every check passed, and GitHub confirms every run and job for the head finished (see a green is confirmed on GitHub) |
1 |
✖ and each failed check, with its URL |
At least one failed or was cancelled. A cancellation is often an admission hold that ran past timeout-minutes, and the output says so |
2 |
⏹ not waiting — <reason> |
Waiting cannot end well: the host is down, the disk floor is holding jobs, GitHub is refusing this repo's jobs for billing (a block on other repos does not stop the wait), one of the checks is queued with a cause that never clears, or a job was orphaned by a draining runner (the reason carries the recovery commands). Stop and report the reason |
3 |
…timed out or ? dashboard unreachable |
The timeout passed, or (for --sha and --branch) the dashboard could not be reached; try cockpit sentinel |
The deadline is the command line's own: each request asks the daemon to hold
no longer than the time left, and a last backstop exits 3 thirty seconds
past the deadline whatever else is happening. A dashboard that stops answering
does not end the wait. The tunnel is reopened and the request retried every 10
seconds, and with --pr cockpit asks GitHub itself meanwhile, once a minute,
with your own gh login
(gh pr view --json headRefOid,statusCheckRollup, the branch ref, and
actions/runs?head_sha=). GitHub's finished answer, by the same rule as
a confirmed green, ends the wait and says so:
A --sha or --branch wait needs the dashboard, and exits 3 when it cannot
be reached at the start.
--pr judges the head commit¶
With --pr, the answer is the PR's head commit, never an older commit's
result. A red from a commit you have since pushed over, or force-pushed off,
does not end the wait. Until the head has a run, the wait keeps waiting. After a
force-push back to an earlier commit, that commit's old result waits for the new
run ("waiting for kit-ci to run again on this commit").
A green is confirmed on GitHub¶
The fleet's view rolls up the runs it has seen, and right after a push it has
not seen them all. On acme/stream-app PR #5 (2026-10-24) a wait that
started 8 seconds after a push printed 1 of 1 green at once: the view still
held the previous head's run, and the new head's run, with nine jobs, was
queued. Every agent merges on exit 0, so that could merge untested code.
Now GitHub names the head (an open PR's branch ref), and a green ends the wait only when GitHub has, for that head:
- every workflow run (
actions/runs?head_sha=) completed, the newest of each workflow counting; - every job of those runs, and every other check and status, completed with
success,skippedorneutral; - nothing new for 30 seconds: runs for
pushandpull_requestregister separately, aworkflow_runrun appears only after the run it follows, and a job behindneeds:only once its needs finish.
Until then the wait prints GitHub's count and reason:
… stream-app PR #5: 2 of 3 done
… stream-app PR #5: 9 of 9 done — settling: GitHub's newest run or job for d42a20f changed 4 s ago, and a green waits 30 s for any run still to register
✔ stream-app PR #5: 1 of 1 green (GitHub: 1 run, 9 jobs)
GitHub not answering is not a yes: the wait stays pending. With a dashboard
older than this rule (no verified in its answer), the CLI confirms the green
itself with your gh login.
A red is checked against the run's latest attempt¶
A re-run (gh run rerun, with or without --failed) keeps the run id, and the
fleet's view can still hold the attempt it retries for a tick or more. So
before a red ends the wait, the daemon asks GitHub for each failed run's latest
attempt. A run that is running again keeps the wait open; one that already
passed ends it green.
When the fleet's view is behind¶
The daemon's rows are a copy of GitHub's, made by its fast loop, and that loop can stop finishing ticks on a saturated host. So the daemon also asks GitHub for the commit's check runs and statuses (REST, cached 20 seconds per commit):
| The fleet's view | GitHub is asked |
|---|---|
| Stale, with no runs for the target, or behind a re-run | Every 30 seconds |
| Fresh | Every 2 minutes |
GitHub's finished answer ends the wait, with how far behind the fleet was:
✔ comet-web PR #96: 1 of 1 green (from GitHub; the fleet's view is 27m old, it still read 0 of 1 done)
That note is a daemon problem worth reporting, not part of the PR's result. GitHub still pending never overrides the fleet's own red, and holds its green until GitHub finishes.
A job orphaned by a draining runner¶
A runner that is drained while it finishes a job can stop before the job's
result reaches GitHub. The job then passed every step and still reads
in_progress, with "Complete runner" cancelled or stuck and "Complete job" in
progress, and it stays that way: gh run cancel does nothing, and only
POST repos/<repo>/actions/runs/<id>/force-cancel completes it (addisdev/lake-api
PR #73, 2026-10-12/13). The drain bug behind it is fixed, but a runner can still
be stopped mid-report (a host reboot, a killed service), so the daemon checks
every active run's jobs. See Orphaned jobs for the
exact rule.
Without --recover the wait stops with exit 2 and the commands:
⏹ lake-api PR #73: not waiting — build: orphaned by a draining runner — not your code. Every step passed, then MACSERIAL0-lake-api (draining) went away; stuck at "Complete job" for 12 min.
gh api -X POST repos/addisdev/lake-api/actions/runs/38318681067/force-cancel
# about a minute later, once the run shows completed:
gh run rerun 38318681067 -R addisdev/lake-api
or let cockpit do both: cockpit wait lake-api --pr 73 --recover
With --pr N --recover, the wait runs the dashboard's run.recoverOrphan
action for each orphaned run, then keeps waiting. The dashboard force-cancels
the run, re-runs it once GitHub has completed it, and records both in its
action log under the cockpit's paired name. Each run is recovered at most once
per wait, and the cancelled result the force-cancel leaves for a minute doesn't
end the wait. A job whose work had already failed is never recovered, because a
re-run would repeat it. Recovery is opt-in and limited to the PR you are waiting
on because it acts on GitHub: cancelling runs across the fleet kills other
sessions' fresh re-runs. It needs cockpit pair.
cockpit sentinel¶
Checks the host out of band, the way the app does when the dashboard does not answer: SSH reachability on each alias, GitHub's view of the host's runners, a runner on another machine, githubstatus.com. Prints the verdict from the out-of-band table.
$ cockpit sentinel
✖ ultra is asleep, off or off the network
mini in the same house is online, so power and the network are fine. Only someone there can wake it.
· SSH port closed on every route
· GitHub: 0 of 3 checked runners on ultra online
· mini: online
→ Wake it in person
via out of band
The GitHub checks need to know which runners to ask about, which they take from
the app's last snapshot. With no snapshot they are skipped, and the output says
so. --port sets the dashboard port asked on the host; --via the aliases.
Exit code: from the verdict's tone.
cockpit top¶
The host's top CPU users over SSH, naming Spotlight outright when it is the culprit (it is the usual cause of a saturated host).
$ cockpit top
Spotlight is using 150% CPU — it is indexing, most likely the runner work trees. Exclude the fleet root in Spotlight privacy.
65.3% mds
62.0% swift-frontend
54.2% mds_stores
11.2% mdworker_shared
--via <alias> picks the host (default ultra). --json prints
{ verdict, spotlightCPU, top }. Exit 1 when Spotlight is using 50% CPU or
more, 3 when ps could not be run, else 0.
cockpit load¶
Host load by how many runners were busy, from the dashboard's per-minute host
samples (/api/load-by-busy): load1 p50 and p90 for each busy-runner count,
and p90 per core. This is the table the admission lanes'
heavy limit is set from; re-read it when the job mix changes.
$ cockpit load --days 7
ultra: load1 by busy runners, last 7 day(s), 9841 sample(s), 30 cores
busy samples load1 p50 load1 p90 p90/core
0 2210 3.0 8.0 0.3
2 890 12.0 27.0 0.9
4 640 28.0 53.0 1.8
lanes observe: heavy 4, light 12, total 16
busy counts runners GitHub shows busy, including jobs held by admission.
--days N (1 to 90, default 7) sets the window. --json prints the daemon's
answer. Busy runners include jobs the admission hook is holding, which use no
CPU, so on a host that holds a lot the low rows read high.
cockpit brief¶
A markdown incident brief of the current state: the verdict, its evidence and next move, everything else open, host vitals, runners that are neither idle nor busy, the queue and open alerts. The same text as Copy brief in the app, written to paste into an issue or an agent session.
$ cockpit brief
## Fleet: Disk floor is holding jobs
_2026-10-03T13:17:34Z · verdict `disk-floor` (critical) · via app · cli_
3 jobs held because free disk is under the admission floor. It looks like load; it is disk.
**Evidence**
- 36.6 GB free, floor 40 GB
- 3 jobs held at Set up runner: comet-web, comet-backend, ember-ios
- Waiting does not fix this; freeing disk does
**Next move:** Preview cleanup
**State** — 0 running, 0 queued, 3 held, 54 runners, snapshot 0s old
- build-host: load 1.7/core, memory warning, swap-ins 2/s, disk 36.6 GB free (floor 40 GB)
**Runners not idle or busy**
- `build-host-comet-web` held-disk: held: 36 GB disk free, below the 40 GB floor
…
cockpit brief | pbcopy puts it on the clipboard.
Exit code: from the verdict's tone.
cockpit pair¶
Pairs the command line with the dashboard, with a token of its own, separate from the app's and revocable on its own.
It runs fleetctl.sh pair on the host over the first --via alias (default
ultra) to mint a six-digit code, exchanges it for a device token named
"cockpit CLI on ~/Library/Application Support/FleetCockpit/cli-token-cli:<host> with mode
0600. --fleet-root is where the repository lives on the host (default
~/actions-runners).
The token is a file rather than a Keychain item on purpose: SwiftPM builds are signed ad hoc, so every rebuild would otherwise meet a Keychain prompt, and a prompt blocks a script or an agent session that cannot answer it.
cockpit run¶
Runs one action from the dashboard's catalogue, through
POST /api/v1/actions/{id}: a preview, then the
run with the confirmation the preview returned.
cockpit run fleet.health # a read: runs at once
cockpit run fleet.healthRepair # refused: needs --yes
cockpit run fleet.healthRepair --yes
cockpit run runner.restart --name build-host-web-2 --yes
| Option | Meaning |
|---|---|
--name <runner> |
The runner, for runner.* actions |
--host <id> |
The host, for host.* actions (default: the dashboard's own) |
--repo <owner/name>, --run <id> |
The run, for run.* actions |
--yes |
Required for anything whose catalogue danger is not none |
Any other --flag value is passed as the action's argument of that name. The
dashboard refuses an argument the action doesn't take and says which ones it
does; GET /api/v1/actions lists them. Without --yes, a changing action
prints its label, danger, what it does and the command it would run, and exits
2: an agent exploring the CLI must not repair or restart by accident. Needs
cockpit pair first.
--json prints the dashboard's answer:
{ status, action, args, ok, command, code, output, durationMs }. Exit 0 on
success, 1 on failure or refusal.
cockpit mcp¶
The dashboard's MCP tools over stdio. The tunnel opens when a call needs it and
closes after --idle seconds with none in flight (default 90; 0 holds it
for the whole session). See Agent sessions.
cockpit fixtures¶
Lists the bundled fixture names, for --fixture:
| Fixture | The state it records |
|---|---|
live |
A real busy afternoon |
quiet |
Healthy and idle |
waiting |
Runs queued behind busy runners: healthy |
dead |
A runner service exited and nothing restarted it |
diskBelowIdle |
Disk under the floor, nothing asked to start yet |
diskHold |
Three jobs held at Set up runner by the disk floor |
saturated |
Jobs lost contact mid-step while Spotlight indexed the work trees |
accountBlocked |
GitHub refusing jobs for billing |
drift |
Sibling runners with different labels |
agentDown |
A federated agent host stopped heartbeating |
blind |
Nothing readable |
Recipes¶
# Fail a script early when the fleet is broken, before pushing.
cockpit status >/dev/null || { cockpit status; exit 1; }
# Push, then wait without hanging on a check that will never start.
git push && cockpit wait comet-web --branch "$(git branch --show-current)" --timeout 30m
case $? in
0) echo green ;;
1) echo "red: read the failures" ;;
2) echo "not the code: $(cockpit status --json | jq -r .verdict.title)" ;;
3) echo "timed out or unreachable" ;;
esac
# Everything queued for longer than 10 minutes.
cockpit queue --json | jq '.[] | select(.queuedMs > 600000) | {repo, workflow, cause}'