Lifecycle¶
This page covers the span from "the daemon sees an armed fiber" to "a human accepts the result."
The worker loop¶
The daemon starts one tmux session per eligible fiber, named
<slug-leaf>-<uid>-shuttle (legacy <slug-leaf>-shuttle for a fiber with no
ULID), running the agent CLI in shuttle.project_dir. felt shuttle
session-name <fiber> prints the canonical name. It composes a deliberately thin
prompt: the fiber id, the felt store path, an exit contract, and an optional
per-dispatch "From User" directive.
It does not paste the constitution into the prompt. The worker reads the fiber fresh from disk, so it picks up an edit you make mid-session instead of freezing a stale snapshot.
From there the worker:
- Surveys — reads the constitution, the previous
## Statushandoff, thegit login and around the project directory, and any sub-fibers. - Works — picks the highest-value slice itself. The constitution describes what "done" looks like; the worker sequences the steps.
- Writes back — rewrites
outcome:, rewrites## Status, corrects the spec if the session sharpened it, files findings as sub-fibers, commits. - Hands off — runs
felt shuttle handoff <fiber>as its final action.
Workers should exit earlier than feels natural. A clean handoff at half a
context window beats pushing through a compaction. ## Status plus the
constitution recovers most of a warm world-model on the next dispatch.
Exit semantics¶
The worker asks three questions, in order. The answer sets status, and
status decides what happens next.
1. Is the desired state realized? Set status: closed and exit. The card
lands in Awaiting review. A human accepts it or flips it back to active.
Substantive work needs independent fresh-eyes review before the session that
produced it closes — a subagent reviewer in-session, or leave the fiber active
and let the next dispatch review it cold. Edits to the fiber's own surfaces
(spec, ## Status, outcome, report.html) count as handoff, not work
product, and never block a close.
2. Blocked on something only a human can supply? Set status: closed, and
lead the outcome with Blocked: … so the card reads as a question.
3. More work, not blocked? Leave status: active and just hand off. The
daemon starts a fresh worker next tick, and it lands on your ## Status.
Closing parks the work¶
Shuttle obeys the vocabulary literally. Know which word does what.
| You say | The worker does | Next |
|---|---|---|
| "hand off" | case 3 — status stays active |
Daemon redispatches |
| "close it out" | status: closed, then handoff |
Card waits for you; no new worker |
Closing puts the work back on a human's desk. It claims nothing about completion. A worker should never upgrade a close-out into a continuation because the work looks unfinished — unfinished is often exactly why you want it back.
Tempered — human only¶
tempered carries the verdict, and a worker never sets it to true.
tempered |
Meaning |
|---|---|
| absent | Awaiting review |
true |
Accepted |
false |
Composted — mooted or superseded |
Workers also never uninstall their own shuttle: block. Closing and
uninstalling are separate decisions, and the block stays as historical record.
Resume vs fresh¶
Workers start fresh by default. Resuming a transcript happens in exactly two
cases: you press Resume on the board, or a oneshot died dirty. A death counts as
dirty when handed_off_at is missing or older than dispatched_at. Nothing
else feeds that test, which is why the handoff verb matters.
Scheduled runs, ad-hoc standing runs, daemon recovery, and orphan adoption all start fresh.
The board¶
The daemon serves a kanban board at http://127.0.0.1:4000/. It views the same
fibers, plus live tmux liveness. The browser computes column membership with
classifyFiber (ui/src/board/KanbanRules.ts). That function decides
membership alone.
Evaluated in order:
| Column | Condition |
|---|---|
| Tempered | closed + tempered: true |
| Composted | closed + tempered: false |
| Awaiting review | closed, tempered absent |
| In flight | live tmux worker with a shuttle block — liveness wins over everything below |
| Pinned | resting kind: pinned (open or active) |
| Drafts | no shuttle block |
| Scheduled | active + kind: standing — placed on the timeline at next launch |
| In flight | active, other kinds |
| Drafts | open |
The classifier reads only status, tempered, kind, and tmux liveness.
Neither the daemon nor the board reads tags.
Beyond the columns the board offers a pinned strip, a timeline for scheduled roles, a fiber and file viewer, Stash and Capture dialogs, Attach (opens the worker's tmux session in kitty specifically — a non-kitty user gets nothing), and a requeue/resume dialog with a directive box.
Attach is terminal lock-in, not platform lock-in: it drives kitty's
remote-control CLI, and kitty runs on Linux and macOS alike. The only
mac-specific part is the osascript call that raises the kitty window, and
that is already a no-op elsewhere. tmux attach -t shuttle-<fiber-id> reaches
any worker on any platform.
Two gestures carry different meanings. Drag-and-drop advances the card's
state. Modal buttons give you another worker on the same run.
Drag-to-tempered acts by kind: on a standing role it accepts and re-arms, on a
pinned role it accepts and re-parks to the strip, on a oneshot it writes the
terminus. Only accept clears the outcome.
The board is optional, and built separately
The repo does not ship the bundle. Build it with cd ui && npm ci && npx
vite build, which writes ui/dist. (npm run build adds a typecheck that
fails on a fresh clone — see
Sharp edges.) make all rebuilds only the
daemon escript. Without the bundle the root URL 404s with a hint, and the
API stays fully usable. If you change any /api/v1/* route, rebuild the
bundle — a stale bundle against a changed route table fails silently as a
404.
Dispatch eligibility¶
A fiber dispatches if and only if all of these hold. The daemon evaluates them
in this order (eligible?/2 and dispatch_gates_pass?/3 in
lib/shuttle/poller.ex).
- It lives in a felt store the daemon polls.
- It carries a
shuttle:block. That block alone defines "shuttle-managed"; no tag predicate exists. - Its agent is not
human. shuttle.hostequals this daemon's own host id.- Felt-native
statusisactive. - No worker is already running or claimed for it.
- The resume-loop circuit breaker is closed.
- The boot quarantine is released.
shuttle.project_direxists on this host.- Every
depends_ontarget exists and istempered: true.
Configured stores come from FELT_STORES (comma-separated) or the persisted
registry at ~/.config/felt/stores.json. Shuttle assumes no default store.
The circuit breaker (7) exists because a worker that dies on startup would
otherwise be relaunched forever. Five consecutive worker deaths, each under 90
seconds, pause autonomous dispatch for that fiber for ten minutes and surface it
as blocked. A healthy run or a force-dispatch clears it.
Boot quarantine¶
An overloaded machine once crashed the daemon repeatedly. Each restart's first poll dispatched every armed, workerless fiber it could see: eight token-burning launches in four minutes, several of them redundant.
So: a daemon restart is not dispatch authority. On every start, the daemon
parks every candidate it has never observed running into pending_launch and
dispatches nothing fresh. Work it did observe alive under its own uptime —
adopted at boot, or dispatched since — resumes normally. That counts as
continuation, not a fresh launch.
Release is manual. No timeout, no self-clearing.
bin/shuttle release
A human force-dispatch bypasses the quarantine without clearing it.
CLI verbs¶
Two binaries, cleanly split. felt shuttle serves agents: it runs offline,
validates the schema, and writes to disk. The
CLI reference tabulates every
verb and flag.
bin/shuttle drives daemon lifecycle, and lives only in the checkout:
bin/shuttle status
bin/shuttle snapshot
bin/shuttle dispatch <fiber>
bin/shuttle release # clear the boot quarantine
bin/shuttle reset <remote> # reset a remote's circuit breaker
bin/shuttle version
The daemon also exposes everything the board uses over HTTP under
/api/v1 — state, fibers, dispatch, claim, capture, transition,
kill, attach, agents, version, and the manual gate releases.
curl -s http://127.0.0.1:4000/api/v1/agents | jq
When to uninstall¶
Closing a fiber leaves its block in place, and that is deliberate. Uninstall earns its keep in four cases:
- Mistake recovery — wrong slug, immediate undo.
- Reshaping the contract — though
--reshapenow covers most of this. - Archiving — a closed fiber's card leaves the board entirely.
- Handing ownership to a different dispatcher.
Never uninstall to end a worker session. Use felt shuttle handoff.
Card missing?¶
Most "my card isn't showing" reduces to "no block installed yet." Check in this
order: is the fiber in a store the daemon polls, does felt shuttle status show
a block, is status: active, does shuttle.host match, and is the quarantine
released?
For multi-host setups, remote fibers reach the board over an SSH tunnel from the owning daemon — never via git sync. A git mirror may replicate a remote fiber's files locally. That replication is incidental; treat any behaviour that depends on it as a bug. If a remote card is missing, debug the tunnel, not the git state.
Remotes come from one config file
~/.config/felt/remotes.json lists every remote daemon: its name, its
local forwarded port, and how to reach it. The CLI and the daemon both read
it at runtime. Manage it with felt shuttle remotes list|add|rm|path. A
single-machine setup needs no such file — see Configuring
remotes.