Local OTP-supervised dispatcher for felt constitution workers. Polls the felt tree, launches one tmux worker per eligible fiber, exposes a snapshot surface for dashboards and other consumers.
The Elixir daemon is the production dispatcher.
Shuttle is becoming a fully independent package. Portolan is being retired;
treat Shuttle as a self-contained tool with its own browser UI, its own launch
story, and no assumption that any Portolan process is running. When you touch
code that reaches for Portolan — its :4004 backend, its hook-event stream, its
city/pinning model, its origins — the goal is to sever, not to preserve parity.
Portolan is a peer that happened to come first, not a dependency. Default to not
referencing it at all; the historical comments that explain why a shape exists
("ported from Portolan's …") are fine to leave as provenance, but new code and
new docs should stand on their own.
Long-term: Shuttle and felt merge into one compact, cohesive package. The end state is a single tool — the felt tree as the data layer and Shuttle's dispatch + UI as the surface over it — simplified down from the current daemon/CLI/UI spread. When weighing a change, prefer the design that moves toward that convergence: fewer moving parts, fewer cross-process contracts, felt and Shuttle as one thing rather than two that shell to each other.
The named shape for the merge: keep the Elixir code for the networked
daemon — dispatch, the per-worker watcher, the :4000 API — that's where
OTP earns its keep. But formalize the shuttle: frontmatter block into felt's
main schema, so the contract that today lives on both sides (validated by the
Go pkg/schema and parsed by the Elixir daemon) lives in one place. Felt owns
the data model; Shuttle owns the network and the surface. Tracked in felt as
[[ai-futures/shuttle/unify-with-felt]]. The gating first step before merge
work could start in earnest was deciding the fate of felt's synced SQLite index +
the history log built on it (the cross-machine SQLite-sync cost Cail wants gone).
That decision is made and, on the shed-history branches, largely implemented:
felt history is gone, and the daemon now detects clean worker exits via the
shuttle.handed_off_at frontmatter field — not a --kind handoff felt-history
event. Continuation state (session_uuid / dispatched_at / handed_off_at)
lives entirely in the shuttle: block, which is exactly the merge end-state
(felt owns the data model). The index-as-throwaway-cache slice is deferred to a
follow-on cycle. Investigation: [[ai-futures/felt/shed-sqlite-history]].
Live runtime couplings (these silently break or no-op without Portolan, and are the real severing work):
- UI
:4004backend (ui/src/board/FiberDetailModal.ts,FileViewerPanel.ts) —portolanBasedefaults tohttp://localhost:4004and the standaloneKanbanModalnever overrides it, so Sent-files, Save-to-downloads, and the project-file viewer all hit a Portolan backend that won't exist once it's retired. These features are dead in the standalone UI today. Severing means serving those routes from the:4000daemon (or dropping them). lib/shuttle_web/cors_plug.ex— allowlists Portolan.app's Tauri custom-protocol origins. Harmless but dead once Portolan is gone; trim.- UI fonts (
ui/src/board/KanbanModal.css) — references fonts "served from Portolan'spublic/fonts/". Vendor them intoui/instead.
Conceptual/data-model coupling (no runtime dependency, but the design carries Portolan assumptions worth unwinding as Shuttle simplifies):
- City / pinning model (
KanbanCityResolver.ts,projectModel.ts,KanbanReadModel.ts) — "which city owns this fiber" is a Portolan-local concept the daemon feed doesn't carry; the UI reconstructs it from loom paths. kind/priority/isRoot(KanbanFiber.ts) — "Portolan conventions felt does not interpret," defaulted client-side.- Gate/transition semantics (
transition.ex,origins_controller.ex,actions.ex) — mirror Portolan's kanban placement pipeline.
The remaining grep -ri portolan hits are historical provenance comments — no
action needed beyond letting them age out as the code they describe is rewritten.
One command stands up the entire local surface on a new host, branching by
host type: prerequisites check → daemon escript (mix deps.get + build) →
ui/dist (built on macOS, rsync'd to clusters) → loom event-stream hook →
keep-alive (the launchd LaunchAgent on macOS / the shuttle-daemon respawn
loop on the clusters). It composes what were separate manual steps and is
honest about missing prerequisites.
./install.sh --dry-run # check prerequisites + print the plan, change nothing
./install.sh # full bootstrap for this host
make install # same, via the Makefile (pass flags: make install ARGS="--dry-run")
Flags: --skip-ui / --build-ui (UI defaults to build on macOS, skip on
clusters), --skip-hook, --with-tunnels (also felt shuttle tunnels install
on the macOS hub). This is the "top-level installer" of the felt+shuttle unify
work; felt shuttle install <fiber> is the unrelated fiber-install verb, so the
system bootstrap is deliberately not that name (a felt shuttle wrapper waits
for the Stage-B repo merge). The individual lifecycle targets below remain for
day-to-day daemon work:
The escript loads its BEAMs at boot, so editing source has zero effect on a running daemon until you restart. Use the Makefile:
make install # one-command full bootstrap (see "Fresh-machine install" above)
make build # mix escript.build → bin/shuttle (MIX_ENV=dev)
make start # nohup detached; logs → ~/Library/Logs/shuttle.log (macOS)
make stop # SIGTERM with 5s grace
make restart # build + stop + start (the load-bearing daemon target)
make all # restart (daemon)
make logs # tail -f the log
make status # felt shuttle ps + snapshot summary
make clean # rm _build and stray Elixir.*.beam at project root
make install-agent # durable launchd keep-alive (crash + login restart)
make uninstall-agent # unload + remove the launchd agent
make start is a bare nohup with no supervisor: it won't restart on crash or
relaunch at login. Shuttle's own durable surface is a launchd LaunchAgent
(share/io.shuttle.daemon.plist.template → ~/Library/LaunchAgents/io.shuttle.daemon.plist),
installed by make install-agent: KeepAlive restarts the daemon on crash,
RunAtLoad starts it at login. Independent of any other process.
Run it from outside ~/Documents — this is load-bearing. macOS TCC blocks
launchd-spawned processes from ~/Documents, ~/Desktop, and ~/Downloads, and
Full Disk Access does not inherit the way it does under Terminal (a terminal
app takes responsibility for its children, so everything you launch from a
shell shares the terminal's grant; launchd has no such umbrella, and FDA doesn't
even cross an exec to a differently-signed binary). So a launchd daemon whose
escript/ui/dist/felt stores sit under ~/Documents either crash-loops
(getcwd: Operation not permitted, escript: Failed to open file) or silently
fails to walk stores — and the fix would be granting FDA to each binary in the
tree (beam.smp, felt, …), which is fragile (the erlang path is
version-pinned) and exactly the per-binary grind to avoid.
The clean setup, and the current production layout:
- The repo lives outside Documents — the canonical checkout is
~/dev/shuttle(not~/Documents/projects/shuttle). The escript andui/distare then readable by launchd with no grant. AGENT_LOOM_HOMESscopes felt polling to~/loom(the Makefile default, baked into the plist asLOOM_HOMES).~/loomis outside Documents and the felt aggregate — it re-discovers each project's substores by following the symlinks under~/loom/.felt/(FeltStores.expand_with_symlinked_substores), so configuring just~/loomis enough. Caveat: substores whose real root is itself under a protected folder (e.g. an iCloudwedding, a Documentslightcone) are discovered but can't be walked by the launchd daemon — those fibers won't enumerate until their project roots also move out of Documents.PATHis captured from a login shell at install time (AGENT_PATHin the Makefile, baked into the plist). launchd's own env is too bare to findescript(Homebrew) at boot orfelt(~/.local/bin) at runtime — and a login shell at runtime (bash -lc) does NOT fix it, because under launchd's bare env the profile doesn't reconstruct PATH (exit 127, escript unfound). A PATH missingfeltspecifically yields:enoent→ 500 on/api/v1/fibers/composite(the kanban load), with the board fine otherwise. Capturing the real login PATH once, at install, is deterministic and needs no hand-maintained list.
Result: make install-agent from ~/dev/shuttle → daemon binds :4000,
KeepAlive + RunAtLoad, zero Full Disk Access grants, survives erlang
upgrades. On the clusters the durable surface is still the while true; bin/shuttle start tmux respawn loop in session shuttle-daemon (no launchd);
the LaunchAgent is macOS-only.
make install-agent warns if $PWD is under a protected folder. There is an
escape hatch — granting FDA to each I/O binary in the tree (…/erlang/<v>/…/beam.smp,
re-granted after every erlang upgrade, plus ~/.local/bin/felt) — but it's
fragile and per-binary; relocating out of Documents is the supported fix.
Shuttle derives per-session activity (WaitingTracker) and the sent-files trail
(SentFiles) from its OWN Claude Code hook-event stream. ~/loom/hooks/shuttle-hook.sh
appends one JSON line per hook event to $SHUTTLE_EVENTS_FILE (default
~/.shuttle/events.jsonl, dir $SHUTTLE_DATA_DIR). The readers read ONLY this
path — the Portolan fallback was dropped once the hook was live on all three
hosts; Portolan is fully severed here.
The hook lives in loom, registered by loom/setup.sh. It can't live in this
repo: ~/loom is the same absolute path on every machine, but the shuttle
checkout is not (~/dev/shuttle here, ~/Documents/projects/shuttle on the
clusters), and ~/.claude/settings.json needs a stable absolute command path.
loom/setup.sh's Python block registers ~/loom/hooks/shuttle-hook.sh into
~/.claude/settings.json across the tracked events (UserPromptSubmit, PreToolUse,
Stop, Notification, SessionStart, SessionEnd). To install on a machine: sync loom
there, then run ~/loom/setup.sh. The hook needs jq on PATH; without it it
exits silently (no events → no activity ranking, but the board still serves).
Each host's daemon tails its own host's ~/.shuttle/events.jsonl.
Connecting to candide and cineca (SSH auth — read this first). The two remotes authenticate differently, and getting it wrong looks like "the host is down" when it isn't:
-
candide (IAP) — plain pubkey auth with
~/.ssh/id_rsa(theHost *identity), reached through anncProxyCommandhop. No cert dance:ssh candidejust works whenever you're on a network that can reach IAP. Nothing expires. -
cineca (Leonardo, CINECA) — auth is a step-ca short-lived SSH certificate held in the ssh-agent, valid 24h. Refresh it once per day with:
step ssh login "$CINECA_SSO_EMAIL" --provisioner cineca-hpcWhen the cert is fresh,
ssh cinecaworks non-interactively. When it has expired, everyssh cinecafails instantly withPermission denied— including the kanban Attach button, which runsssh -tt cineca tmux attach …in a kitty tab, so the symptom is a terminal that flashes open and dies. That is the expired cert, not a Shuttle bug: re-run thestep ssh loginand attach works again. The~/.ssh/cineca_key/cineca_key-cert.pubpaths in the ssh config are step's cert store — they may be absent on disk and ssh prints a harmlessno such identitywarning; the live credential is the cert in the agent. Do not pass-o BatchMode=yeswhen sanity-checking cineca (it suppresses the cert path and falsely reports a dead host), and ignore~/.ssh/ssh_wrapper.shentirely — it's VS Code's remote helper, unrelated to Shuttle.
Deploying is ALWAYS safe — local or remote — and is never a blocker.
Rebuilding and restarting the daemon (make all, cycling :4000, reloading the
LaunchAgent, the respawn loop) does not kill running jobs: tmux owns the
worker process, Shuttle only owns the watcher (the load-bearing invariant
below). A restart cycles the watcher and rebinds the API; the shuttle-<id>
tmux sessions keep running untouched and the daemon re-adopts them on boot. So
deploy freely whenever there's a fix to ship — never hold back, gate it behind
"there are workers running," or frame a deploy as risky. The only cost is the
brief API/board blip during the ~1s (local) to ~2min (candide cold-walk)
restart; in-flight work is unaffected.
An autonomous worker that has built and verified a change SHOULD deploy it —
that is the default, not a step to stop before. Because the deploy itself is
mechanically safe (above), the only thing a branch-and-wait gate buys is a human
code review — and in practice that review rarely happens, so parking finished,
verified work on a branch is mostly latency and friction, not safety. The
verification that does matter happens in-session: build → run the tripwire
(mix test, cd ui && npm run build) → get the skill's independent fresh-eyes
review (a subagent over the diff-against-constitution, an adversarial pass for
complex work) → then deploy, in the same session. Don't hand a finished,
green change back to Cail just to have him run make all + rsync. Reserve
"stop for the human" for the genuinely different case — a change whose design
he should weigh in on before it ships (a capability removed, a contract redrawn,
a load-bearing model choice); even then, surface the alternatives in the fiber
and keep moving rather than treating the deploy mechanics as the gate. A
constitution may still pin an explicit human-review gate when the stakes warrant
it, but that is the deliberate exception a constitution names, not the resting
posture of every change.
Deploying to remote hosts (candide, cineca): push to GitHub first, then build on the host — don't copy the macOS escript, as BEAM bytecode format varies across OTP versions and the binary will crash on startup on a different host.
ssh candide "cd ~/Documents/projects/shuttle && git pull && make all"
ssh cineca "cd ~/Documents/projects/shuttle && git pull && make all"After a remote deploy, verify both /api/v1/version and one behavior-shaped
payload. A new git_short_sha only proves BuildInfo was rebuilt; if the live
payload still has old semantics, run make clean && make build, then let the
respawn loop restart the daemon from the clean escript.
The respawn loop owns the remote daemon — make stop/make all may not
cycle it. On candide/cineca a while true; ./bin/shuttle start loop in tmux
session shuttle-daemon owns the live daemon. make stop/make all target the
pidfile that make start writes, which is not the respawn-spawned daemon, so
they can build a fresh bin/shuttle yet leave the old binary serving :4000. To
actually cycle to the new binary, kill the :4000 listener directly
(lsof -ti:4000 -sTCP:LISTEN | xargs kill) — the respawn loop restarts it from
the rebuilt escript. Confirm git_short_sha flipped; if not, the old process is
still bound. candide startup is slow (~2 min) — it scans large shapepipe felt
stores and adopts orphan sessions before binding :4000; wait it out, don't
assume a crash.
Candide: OTP 27.3.4.12 pinned in ~/.tool-versions. Daemon log:
~/.shuttle/shuttle.log. cineca runs OTP 28.0.2 and compiles fine (the old
"OTP 28.0.x compilation crash" no longer reproduces on current main; only a
non-fatal "regexes re-compiled at runtime" perf warning remains — OTP 28.1+ or
27- silences it).
The daemon serves its own web UI at http://127.0.0.1:4000/ — the kanban
board, Stash/Capture, and the fiber/file viewer, served as the static ui/dist
bundle by the same process as the :4000 API (Plug.Static + SpaController).
To pull it up locally: make start (or Portolan's dev.sh), then open the root
URL in a browser. A fresh checkout that hasn't built the bundle gets a 404 with
the hint cd ui && npm run build; the API stays usable regardless.
The UI bundle is shipped, not built on-host. make all rebuilds only the
Elixir escript — it does not build ui/dist. And the UI can't be built on
the clusters from a source-only lightcone-ui clone: the aliased renderer source
imports its myst peers (myst-to-react, @myst-theme/*), which Node resolves
from lightcone-ui's own node_modules — present only after a pnpm install
of that workspace. But the bundle is host-independent static output, so the lean
path is build ui/dist locally (where the deps resolve) and rsync it:
cd ui && npm run build # locally; lightcone-ui present → paper entry included
rsync -az --delete ui/dist/ candide:~/Documents/projects/shuttle/ui/dist/
rsync -az --delete ui/dist/ cineca:~/Documents/projects/shuttle/ui/dist/(The renderer is compiled into the bundle, so a remote serving the shipped
dist self-serves the paper render — it needs no lightcone-ui at runtime.)
A daemon route change is a bundle-rebuild event. make all rebuilds the
escript and rebinds :4000 but never touches ui/dist, and nothing checks that
the shipped bundle and the daemon's route table still agree. So any change to the
/api/v1/* shape (add/remove/rename a route) MUST be paired with a cd ui && npm run build + rsync to every host — the browser always runs the local bundle, and
a route mismatch fails silently as a 404 with no daemon-side error. This is exactly
how the shed-history merge broke all launches: it deleted POST /api/v1/felt-history,
but the stale dist still posted the directive there as the first step of New
Session, so the launch 404'd before it ever dispatched (provenance:
[[shuttle/findings/finding-uidist-stale-after-route-removal]]). If a fresh
npm run build exits 194 with zero output, that's not a type error — it's a
corrupted node_modules (circular .bin symlinks); npm ci fixes it.
The ASTRA paper path needs node + a built MySTRA on each owning host.
GET /api/v1/astra is owner-routed and shells out to priv/mystra/bake.mjs,
which imports MySTRA's built dist. Each host that owns astra.yamls you want
to render needs: node (any v22+) and a MySTRA checkout built once —
git clone -b cail/migrate-to-astra-spec-sdk …/MySTRA && cd MySTRA && npm install && npm run build at ~/Documents/projects/LightconeResearch/MySTRA (the sibling
path bake.mjs resolves by default; its dist/ is gitignored). The bake finds
node via a bash -lc login-shell fallback, so it works even though the respawn
loop sources asdf but not nvm. A host without node/MySTRA fails /astra cleanly;
the board + fibers are unaffected.
One artifact in this repo: the Elixir daemon. bin/shuttle is the only
buildable thing here — editing lib/shuttle/*.ex needs make restart. The
agent-facing CLI is felt shuttle <verb>, owned and shipped by felt (the old
standalone shuttle-ctl Go shim is retired); nothing in this repo builds it.
bin/shuttle is an escript — it bundles BEAM bytecode at build time and
loads it at boot. A restart without make build is a no-op for picking up
source edits. make restart always.
Portolan owns the normal local production surface. In Cail's everyday
setup, ~/Documents/projects/portolan/dev.sh starts Portolan's frontend,
backend, and Shuttle daemon together (bin/shuttle start on port 4000), and
bash dev.sh kill tears down all three ports. If you are restarting the live
stack the human is using, prefer the Portolan script so Shuttle is not left as
a stray standalone daemon fighting Portolan's process lifecycle. Use
make restart for Shuttle-only daemon development, then restart Portolan's
dev.sh stack when the browser-facing app should pick up the rebuilt daemon.
If mix escript.build warns about "redefining module Shuttle.X" with the
"current version loaded from Elixir.Shuttle.X.beam" hint, run make clean
first — stray .beam files at the project root shadow the real ones. They
should never be committed.
bin/shuttle snapshot # JSON snapshot of daemon state
bin/shuttle dispatch <fiber-id> # one-shot dispatch
# felt shuttle — agent-facing CLI; offline; schema-validating
felt shuttle status # all fibers with shuttle: blocks
felt shuttle status --all # local + every configured remote
felt shuttle status --remote <name> # single remote
felt shuttle ps # live tmux workers only
felt shuttle install <fiber> --project-dir "$PWD" [-m <agent-id>] [--disabled]
felt shuttle repeat <fiber> --schedule "0 9 * * 1-5" --tz Europe/Paris --project-dir "$PWD"
felt shuttle pause <fiber> # disable + kill live worker; --no-kill preserves it
felt shuttle resume / accept <fiber>
felt shuttle set-model <fiber> <agent-id>
felt shuttle dispatch <fiber>
felt shuttle handoff <fiber> # worker's clean-exit ritual: stamp
# shuttle.handed_off_at (→ next dispatch
# is fresh) + end own tmux session. The
# single final action; folds in kill $PPID.
felt shuttle snapshot
felt shuttle abort / attach <fiber>
felt shuttle validate-identity # UID migration/cross-city validation- tmux owns the worker process; Shuttle owns the watcher. Workers stay
attachable via
tmux attach -t shuttle-<fiber-id>. Supervise watchers, not workers. - Felt is the data layer; Shuttle shells out to the felt CLI. Don't import felt internals.
- Remote content comes from the owning daemon over the tunnel — NEVER from
git sync. A fiber is owned by exactly one host; only that host's daemon can
read its body, files, and assets off its own filesystem. Every cross-host
READ (
/api/v1/fibers/:id?body=true,/file,/astra) and every cross-host WRITE is owner-routed viaShuttle.OriginRouter: the composite board stamps each fiber'sorigin, the client carries it back, and the local daemon forwards to the owner's identical endpoint over the SSH LocalForward (candide→:4001, cineca→:4002). The~/loomgit mirror replicating a remote fiber's files locally is incidental and must never be relied on — if any feature works only because a file happened to git-sync, that is a bug. The symptom when this invariant is violated: a remote card shows its outcome (it rides the composite feed) but the body reads empty / "not in the local mirror", because the read was attempted locally instead of being owner-routed. New endpoints that surface a fiber's host-local content MUST route throughOriginRouter, not assume the bytes are reachable on this host. - Agent records live in one source of truth: felt's registry. Felt owns
the registry for the merge; the daemon reads the already-resolved record off
felt's
shuttle.resolved.agentJSON and shellsfelt shuttle agents [resolve]for the registry / no-fiber cases. There is no daemon-embeddedshare/agents.jsonand noconfig/agents.exs. shuttle.agentfield drives agent selection. Theshuttle:block'sagent:field resolves against the registry. Default agent isclaude-sonnet.shuttle.hostfield drives daemon affinity — strictly. A daemon dispatches a block iffblock.host == own_host_id(itsSHUTTLE_HOSTor:inet.gethostname()). There is no"local"default and nonilwildcard: an absent or emptyhost:is unowned and ineligible on every daemon.felt shuttle install/repeatstamphostby default so blocks are born owned. The same predicate gates the orphan-resurrection path, so a remote restart can't re-grab another host's fiber.shuttle.project_diris required for enabled installs.felt shuttle installandrepeatrequire--project-dir; workers start there instead of falling back to the felt store.- felt shuttle is the agent-facing CLI. Local write verbs validate before
write and work offline. Cross-host writes belong to Portolan's kanban/API
surface, not to
felt shuttle.bin/shuttlehandles daemon lifecycle and dispatch. - No tag predicate for dispatch. The
shuttle:block'senabled: truefield is the dispatch signal. Tags are free-form qualitative noticings; onlyideais load-bearing for Portolan's kanban column placement.
- Poller (
lib/shuttle/poller.ex) owns the tick. It walks each configured felt store, pulls candidate metadata viafelt ls --jsonand per-fiber detail viafelt show -j, and considers a fiber eligible iffshuttle.enabled: trueANDstatus in ["open", "active"]AND not already running AND deps satisfied. - Configured stores come from
LOOM_HOMES(comma-separated env var) → persisted~/.shuttle/felt_stores.json→LOOM_HOME→~/loom.POST /api/v1/felt-storesrewrites the persisted file. - Dispatcher (
lib/shuttle/dispatcher.ex) resolves the agent viaShuttle.Agents.resolve_by_name/1against the embedded registry, spawnsshuttle-<fiber-id>tmux session. - Standing roles —
shuttle.kind: standingwith a cronschedule:. Scheduled runs dispatch only whennext_due_atis due ANDreview.stateisscheduledoraccepted. Manual dispatch is ad-hoc (adhoc-...run id) and preservesnext_due_at; worker exit flips state toawaiting, andfelt shuttle acceptadvancesnext_due_atonly for scheduled runs.
All prompt variants share this shape (compose_prompt/3 in dispatcher.ex):
- Orientation paragraph — what Shuttle is, what the worker is here to do, how the practice loads. Per-prompt, not boilerplate. Goes first because in causal attention every downstream token sees the prefix.
Fiber: <id>(andRun: <run-id>for standing) — identity lines.Felt store: <path>— the worker's absolute anchor. Whenprompt_fiber_id's work_dir-local translation safe-fails, the id above is global and doesn't resolve from cwd; the store line makes the fallback mechanical (felt -C <felt-store> show <id>).From User— the user's directive, when one rides this dispatch. It is theuser_messagedispatch parameter (inlined into the prompt at launch and discarded), not a persisted felt event. The since-window "which comment is current?" computation is gone: the directive arrives with the dispatch.
The fiber's outcome and handoff prose are not inlined — they're already in
scope after felt show <id>, which renders the body's ## Status block (the
worker's last-writer-wins handoff) along with the rest of the constitution. The
shuttle skill prescribes the worker reads it on arrival. (felt history no
longer exists; the editorial chain it used to carry now lives in ## Status +
the git log of the constitution.)
felt shuttle status # offline walker view (independent of daemon)
bin/shuttle snapshot # raw JSON snapshot
make status # daemon-side view (ps + snapshot)
~/Library/Logs/shuttle.log # daemon stdout/stderr (macOS)
tmux ls | grep '^shuttle-' # live workers
curl -s http://127.0.0.1:4000/api/v1/agents | jq
curl -s http://127.0.0.1:4000/api/v1/state | jq
curl -s http://127.0.0.1:4000/api/v1/state/composite | jq
felt shuttle validate-identity # checks :4000/:4001/:4002 by defaultDispatch sanity ladder:
felt shuttle statusshowsenabled: true, idle, oneshot? → fiber is well-formed and the offline walker sees it.bin/shuttle snapshotlists it undereligible[]? → daemon dispatched.felt shuttlesees it but daemon doesn't → daemon binary is stale.make restart.- Daemon sees it but agent never appears → check
share/agents.jsonfor the resolved agent'scliand that the wrapper is onPATH.
Kanban stuck on "Loading…" / /api/v1/state returns
{"error":"poller_unavailable", ..., "{:timeout, {GenServer, :call, [Shuttle.Poller, …, 1500]}}"}
right after a fresh daemon start. The poller serves its last snapshot, but on
a cold boot there is none yet, so the snapshot call starves behind the first full
walk until it completes — and the first tick on a fresh machine is cold: empty
OS file cache, dataless iCloud sidecars (com~apple~CloudDocs stores ship .felt
index files as dataless placeholders that block on a network download the first
time felt reads them), and every configured store walked back-to-back. Observed
once at ~106s (Sent 200 in 106275ms in shuttle.log). It is a one-time tax:
once warm, all stores poll in well under a second and the board loads. So wait
out the first walk rather than trimming ~/.shuttle/felt_stores.json — the
persisted list is fine, and most project stores are slices of ~/loom (the
aggregate store; its ids are already prefixed ai-futures/…, portolan/…) so
trimming gains little. Two real follow-ups: (1) a store path with no .felt/
dir ("not in a felt repository") errors every tick — drop it from the list;
(2) the remotes timing out independently (ssh_check_failed, :4001 econnrefused) is separate noise, not this.
shuttle/
├── AGENTS.md canonical agent-facing guide
├── CLAUDE.md compatibility pointer to AGENTS.md
├── Makefile build + daemon lifecycle
├── mix.exs Mix project
├── bin/shuttle the daemon escript (built artifact)
├── lib/ Elixir source
│ ├── shuttle/poller.ex discover + eligibility + retry queue
│ ├── shuttle/dispatcher.ex agent resolution, tmux launch
│ └── shuttle_web/ agent-API HTTP endpoints (/api/v1/...)
├── share/ shared data (plist template, launchd assets)
├── config/ Elixir env config (dev/test/prod endpoint settings)
├── test/ Mix test suite
└── deps/, _build/ Mix-managed; gitignored
The agent-facing CLI (felt shuttle <verb>) and the agent registry are owned by
felt, not built here — this repo is pure Elixir + TS.
mix test # full Elixir suite
mix test --only focus # tagged subset
# Opt-in real harness smoke. Opens real Claude/Codex/Pi CLIs in tmux,
# sends no prompt, captures the idle pane, then kills the smoke sessions.
SHUTTLE_REAL_HARNESS_SMOKE=1 mix test --only integration test/shuttle/real_harness_smoke_test.exsThe real harness smoke is deliberately outside ordinary mix test. It uses
tmux session names like shuttle-harness-smoke-<harness>-<unique>, records
captures under _build/test/shuttle_harness_smoke/, and skips harnesses that
are not available in bash -l.
See CONTRIBUTING.md.