Skip to content

Latest commit

 

History

History
723 lines (645 loc) · 41.4 KB

development.md

File metadata and controls

723 lines (645 loc) · 41.4 KB

Development

Everything runs on one machine, with one command, and needs no Claude subscription, Cloudflare account or GitHub App.

Setup

  • Rust via rustup — rust-toolchain.toml pins the version and adds the wasm32-unknown-unknown target.
  • Node.js 22 or newer (npm ci at the root installs the web app and wrangler). The web app depends on @hyperstructure/design through git, at a tag, which npm ci downloads from GitHub without signing in.
  • worker-build, which builds the control plane for wrangler. The first cargo xtask dev installs it with cargo install if it isn't on your PATH. On Linux that needs pkg-config and OpenSSL's headers: sudo apt install pkg-config libssl-dev on Debian and Ubuntu.
  • Optional, for release builds: zig and cargo install cargo-zigbuild.

The root .npmrc holds allow-git=all: npm 12 installs no git dependency unless allow-git allows it, and all installs them as npm 10 and 11 do. Don't narrow it to root, which allows only those that this repository's package.json files declare: npm 11.9 to 11.12 then refuse the design package, and npm 11.17 and newer refuse its new commit when you change its tag. npm 11.2 to 11.8 don't know the setting and warn Unknown project config "allow-git" at every command.

The root package.json's allowScripts says which dependencies may run install scripts: esbuild and workerd, whose scripts put their platform's binary in place of their JavaScript launcher, but not fsevents (macOS only), which has no script to run though the registry says it has one. npm 11.16 and newer list the scripts allowScripts doesn't cover at the end of every install; npm 11 still runs them, and npm 12 skips them. When a dependency you add brings install scripts, read what they do (npm install-scripts ls lists them; the install-scripts commands need npm 11.18 or newer). Then let them run with npm install-scripts approve --no-allow-scripts-pin <pkg>, which approves every version of the package, so that updating it needs no new approval, or keep them from running with npm install-scripts deny <pkg> if the package works without them. Neither command finds a package that npm doesn't install on your platform, like fsevents on Linux: write its entry by hand, and keep it when npm install-scripts prune there would remove it. CI and the standalone image install with Node.js 22's npm 10, which ignores allowScripts and runs every script.

The local stack

cargo xtask dev

This starts:

  • the control plane under wrangler dev at http://127.0.0.1:8787, with local D1 and Durable Objects (state in crates/fctl-control/.wrangler/);
  • the web app under Vite at http://localhost:5173, with hot reload, proxying /api to the control plane;
  • a host using the process driver whose agents run fake-claude: a scripted stand-in for Claude Code that speaks the same stream-json protocol.

The first run creates the primary instance owner (dev@fctl.test / fctl-dev-password), a Claude credential, a workspace called Development, a runner for it and a project called Playground, and stores a CLI token in .fctl-dev/token:

export FCTL_SERVER=http://127.0.0.1:8787 FCTL_TOKEN=$(cat .fctl-dev/token)
cargo run -p fctl -- run -p playground "write notes.md"

To try a Claude token kept on the runner, keep a made-up one on the dev host and connect it; the dev host's next heartbeat, within 15 seconds, tells the control plane, and sessions on it then run there with that token:

echo sk-ant-oat01-fctl-dev-kept-on-the-runner-0000000000 \
  | FCTL_CONFIG_DIR=.fctl-dev/host cargo run -p fctl -- host claude set-token
cargo run -p fctl -- claude connect --on-runners

To try keeping the token out of sandboxes, turn it on and ask fake-claude for its environment: it says it got the stand-in (sk-ant-oat01-fctl-stand-in-…), and its environment has HTTPS_PROXY at fctl-agent's proxy. (Release builds give Claude the token on the process driver; debug builds relay there, so the dev host does.)

cargo run -p fctl -- claude set --stand-in on
cargo run -p fctl -- run "printenv"

fake-claude never calls the Anthropic API. To see Claude's requests go through the host, run real Claude Code on a second runner (its host.toml's claude_bin) whose host has FCTL_DEV_CLAUDE_API pointing at a stand-in for the API on localhost, with that server's certificate in SSL_CERT_FILE: debug builds of the host send Claude's requests there, with the session's token in place of the stand-in. Release builds ignore it.

More workspaces, and people in them, take a few commands: cargo run -p fctl -- workspaces create "Side project", then workspaces add-runner dev-host -w side-project so that the dev runner takes its sessions too, and admin invite grace@example.com -w side-project, whose link signs Grace up in another browser. The dev instance has no GitHub App, so projects there have no repository and the GitHub tabs say so. It takes requests for early access, as app.fctl.sh does: signed out, the sign-in page links to /early-access, and the requests show under Admin → Early access and in cargo run -p fctl -- admin early-access.

fake-claude reacts to words in the prompt: explore looks around the working directory first, as agents do before they change something (a thought, whose tokens the session page counts as it comes, then commands, searches and a read, two of them at once, with another thought among them), which the session page folds into a line that opens into its steps (explore <n> looks around that many times in one line, more than the newest events the session page gets first, which then loads earlier ones until it has a screenful to show), write <name> creates and commits a file, push pushes the branch (dev projects have no repository, so a bare one in the sandbox's ~/.fake-github stands in for GitHub), watch <seconds> watches the pull request's checks in one tool call that long, as agents wait for CI before they end their turn (meanwhile the session page shows the commit and the push), rename <title> names the session with fctl edit (the host's own fctl comes first on its agents' PATH), screenshot draws a small PNG and publishes it with fctl publish (allow that first: cargo run -p fctl -- admin set --publish-screenshots on, then cargo run -p fctl -- projects set --publish-screenshots on), look reads an image with Read, as agents look at their screenshots (a small PNG it draws, or each image at the paths after the word, one call after another: look /tmp/before.png /tmp/after.png), which the session keeps and its page shows under the calls, remember <text> adds the rest of its line to the project's memory with fctl memory add, a line that starts with ask <question> asks the people who follow the session the rest of the line with fctl ask, offering the answers after each / to click (ask Ship it today? / Yes / Not yet), as agents do when they need someone's input, recall lists the memory with fctl memory, a line that starts with fctl ticket runs as it is (fctl ticket view ENG-1), as agents reach their project's tracker, printenv shows the CLAUDE_CODE_… variables it got and says the token back, streamed in pieces (the host redacts the Claude token in both), retro records a retrospective with fctl retro (completed, or partial or blocked when the message says so; the project's Memory and Retrospectives tabs, and the session page, show them), revise updates the session's newest retrospective to completed with fctl retro list and fctl retro update, as agents do when a later turn carries on with the same work, slow streams slowly, background <seconds> starts a shell command in the background that ends after that many seconds and takes its result in a turn of its own (the session waits for it, then runs again, as with Claude Code), delegate hands a look around the sandbox to a subagent, whose steps the session page shows under its Delegated line as they come (delegate in the background runs it in the background: the turn ends, the subagent works on, and a turn of its own takes its report, which starts without a running of its own, as with Claude Code), limit hits a usage limit that resets 20 seconds later (the session pauses and continues half a minute after the reset; a message that also says reset, like the one fctl-agent continues it with, doesn't count), overage reports paid usage (the session must pause), warning reports plan usage with Claude Code's warning that the weekly limit goes faster than the week passes (allowed_warning: the apps go by the share used instead), refresh asks the host for a new OAuth token, effort <level> (or Claude Code's /effort <level>) changes the effort level it works at (low … max, or none as a model that takes none), context <tokens> fills its context to that many tokens before it answers (context 155k, which the session page shows in amber, nine tenths of the way to where it compacts), compact (or Claude Code's /compact) compacts the conversation, a review session's task (Review acme/web#12, … at commit …) reviews as a review agent does: it reads the pull request's commits and diff, sums it up in its description first where its system prompt asks for fctl review summarize (low risk, medium past 50 added lines), reads fctl's earlier findings (fctl review threads), then posts with fctl review submit, marking earlier findings still open fixed, and without any, finding an issue on the first line the pull request adds (the dev instance has no GitHub App, so no review starts there by itself), fctl's message about a review of the agent's own pull request (fctl's review agent reviewed commit …) gets a look at the branch's commits and an answer that leaves each finding as it is, without calling GitHub (no other word in it counts), so does fctl's message about what happened to its pull request on GitHub (On GitHub, …: a comment, failed checks, conflicts, a merge), with a line for each part (the dev instance has no GitHub App, so none comes there by itself), and crash exits mid-turn. Anything else gets a short answer with a tool call. Like Claude Code, it names its model by ID, an alias's too (opus is claude-opus-5-5). It answers get_settings with its model and effort level (--effort, else medium), which the session page and fctl show show, and takes a change of them from the session page or fctl edit --model … --effort … (set_model, which refuses the model unknown, and apply_flag_settings) once its turn ends. Its context grows with each call, from the 18K tokens of its system prompt and tools; like Claude Code with a 200K window, it compacts the conversation on its own once its context reaches 167K tokens, and it answers get_context_usage with that window and where it compacts, which the Work section of the session page and fctl show show with the context. A message sent during a turn waits for it and gets a turn of its own; each result says how many messages still wait (queued_turn_count), as Claude Code's does, so the session stays running until fake-claude has answered the last one. Like Claude Code, it starts each turn with system/init, and reports its state (session_state_changed) when CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS is set, as fctl-agent sets it: running as a turn starts and idle once it has ended, but from a turn that starts a subagent in the background, running until the turn that takes its report has ended, as Claude Code 2.1.308 does. A message with attached files also gets a sentence about them: how many images it holds, and whether each listed file is on disk. Turns other than limit and crash report plan usage as Claude Code does (unifiedWindows), with five-hour and weekly windows that follow the clock, so Settings → Claude, the Agent Floor and fctl claude status show it. Each of its API calls reports tokens as Claude Code's do, its input as it starts and with its assistant messages, and its output in the message_delta that ends it, so the session page's Work counters move during a turn. Its system/init message lists a claude.ai connector, claude.ai Fake Docs, unless ENABLE_CLAUDEAI_MCP_SERVERS is false, so sessions in a project with claude.ai connectors turned off show none (fctl logs --json). Its commits end as Claude Code's do: with Claude's trailer, or with the attribution.commit fctl-agent writes into Claude's settings. To see yours, set a commit email (cargo run -p fctl -- account set --commit-email 1+dev@users.noreply.github.com); to see Claude's go, cargo run -p fctl -- projects set --credit-claude off.

Linear's side runs against a stand-in for Linear (xtask/linear), which cargo xtask dev starts at http://127.0.0.1:8790 and points the control plane's Linear app at (LINEAR_API_URL and the rest, as --vars of wrangler dev). It answers Linear's sign-in and GraphQL API for a made-up Linear workspace, Acme: the Engineering (ENG) and Design (DES) teams, a few issues, Dev Owner (the dev owner's Linear account) and Grace Hopper, whom fctl doesn't know. graphql-js runs each query on Linear's own schema (xtask/linear/schema.graphql), so that a query, a variable or an agent activity that Linear wouldn't take fails there too. cargo xtask dev connects the dev workspace to Acme through the redirects a browser follows, has Playground take Engineering's tickets and links the dev owner's account, unless they are already. The workspace's Linear tab, Playground's Tickets tab and Settings show them as they would with Linear, and connect and link through the stand-in. Acme, and a log of every request, live in .fctl-dev/linear/.

cargo xtask linear acts in Acme as people there do: it sends the control plane the delivery Linear would, signed with the webhook's secret, and prints what fctl answers in the issue's agent session as it comes, until fctl waits for the person:

cargo xtask linear delegate ENG-1                       # Dev Owner delegates ENG-1 to fctl
cargo xtask linear delegate "explore, then write notes.md"   # a new issue, delegated
cargo xtask linear mention ENG-3 "what would this take?"
cargo xtask linear reply ENG-1 "watch 60"               # a reply in ENG-1's agent session
cargo xtask linear stop ENG-1                           # its stop button
cargo xtask linear reply ENG-1 "Can I help?" --as grace # fctl asks Grace to link her account
cargo xtask linear show ENG-1                           # what fctl did in its agent sessions
cargo xtask linear uninstall                            # Acme uninstalls fctl's app

Nobody in Acme can delegate to fctl's app, or talk with it, while it isn't installed; the next cargo xtask dev installs it again.

Issues and replies reach fake-claude as a session's task does, so its words work in them. A delegation's agent session starts with Linear's own comment ("This thread is for an agent session with fctl."), as Linear's does, and fctl moves the issue on as the session works: to In Progress as a delegation's session starts, or once a mention's agent writes something (cargo xtask linear mention "Notes" "write notes.md"). Dev projects have no repository, so their sessions open no pull request, and nothing moves issues to In Review or Done there. In ENG-1 it writes notes.md and pushes it; ENG-3 is labeled explore, so fctl asks for an exploration, and fake-claude explores; in ENG-4 it hits the usage limit, and fctl says in Linear when the session carries on. DES-1 is in a team no project takes. http://127.0.0.1:8790 shows Acme's issues and what fctl did in each. The stand-in prints each change fctl makes in Acme, and as a problem what Linear would refuse, or what fctl didn't do in time: answer a delivery within five seconds, and say something in a new agent session within ten.

cargo xtask linear check checks every query and mutation fctl sends Linear (fctl_domain::linear::operations), with variables of the kinds it sends, against Linear's schema, without the stack; cargo xtask check web runs it too. The schema is a copy of the one Linear publishes with its SDK (linear/linear, packages/sdk/src/schema.graphql): its first line names the commit it came from, and npm run update-schema --workspace xtask/linear brings it up to date.

Notifications work locally too: http://localhost:5173 is a secure context, so turn them on in Settings → Notifications, follow a session and give it a task. The dev control plane sends them through your browser's real push service, so it needs the internet. A finished turn notifies you unless the session's tab is visible; limit pauses a session.

--no-host skips the host; --no-vite serves the built web app from wrangler instead; --smoke runs one session and a delegation in Linear's stand-in, and stops (Checks). Delete .fctl-dev/ and crates/fctl-control/.wrangler/ to start over.

The hourly housekeeping job (crates/fctl-control/src/housekeeping.rs: purging deleted sessions, deleting expired sign-in records, indexing sessions for search that the index lacks) runs on a cron trigger. To run it locally:

curl "http://127.0.0.1:8787/cdn-cgi/handler/scheduled?cron=17+*+*+*+*"

A session is searchable (⌘K, fctl search) five seconds after its messages and replies come, when its alarm puts them in its workspace's SearchObject. The job indexes again, from their first events, the sessions D1 says the index lacks something of, as from before search; to see it do so, say that of them all, then run it:

cd crates/fctl-control && npx wrangler d1 execute DB --local --command "UPDATE sessions SET search_indexed = 0"

Schedules (crates/fctl-control/src/schedules.rs) start their sessions on ScheduleObject's alarm, which rings under wrangler dev too: one that runs in a minute shows a run start on time, as fake-claude takes its task.

cargo run -p fctl -- schedules add "Check" -p playground --at 1m -m "write check.md"
cargo run -p fctl -- schedules add "Busy" -p playground --cron "*/15 * * * *" -m "watch 90"
cargo run -p fctl -- schedules run Busy -p playground   # its task now, with --context for a tool's text
cargo run -p fctl -- schedules runs Busy -p playground  # what each run started, or why it started none

To make a schedule due at once, set its next run in the local D1 and wake the alarm with the hourly job (npx wrangler d1 execute DB --local --command "UPDATE schedules SET next_run_at = 0 WHERE name = 'Busy'" in crates/fctl-control, then the curl above): a run while its last session still works (watch 90) is skipped. A row in claude_usage with a utilization of 1 and a resets_at ahead puts the dev subscription's plan at its limit, which skips the runs of a schedule until then. The dev .fctl-dev/token is an access token, so its runs count as a tool's.

The demo stack

cargo xtask demo                         # the demo cast, lived in, at http://localhost:5173
node xtask/demo/shoot.mjs --out shots    # from where Playwright is installed: its screenshots

cargo xtask demo runs the local stack as cargo xtask dev does, with its state in .fctl-demo/ (made afresh each time), and loads the made-up world of the demo skill into it: the people, workspaces, runners, projects, schedules, memory and sessions in xtask/demo/cast.toml. Its people make it through the API, with a host for each runner and a stand-in GitHub (bare repositories in .fctl-demo/github that git reaches in place of github.com, holding the files in xtask/demo/repos, where dependency manifests are kept as <name>.stand-in so that GitHub doesn't count them as fctl's); each session plays its transcript in xtask/demo/transcripts, forty times faster than written. Then, with the control plane stopped, it moves everything to the story's times in D1 and the Durable Objects' SQLite, and fills in what a dev instance has no GitHub App or Linear for: pull requests' checks, reviews, tickets, older sessions for the projects' counts. The sessions still at work in the story work on once it is back, and the people watching them watch. Sign in as any of them with the cast's password, which it prints with Noor's address.

The story happens on a Tuesday at 09:10. The seed sets that moment at a minute when some time zone's clocks read 09:10, within the hour before it finishes, and moves the schedules to that zone and to the day of the week it is there; .fctl-demo/shots.json says which. xtask/demo/shoot.mjs takes the browser there, signs in as Noor and shoots the pages the skill lists, in both themes and at a phone's width (--only, --page, --theme), and --open opens a browser at that moment to look around or record in. Any other browser shows the times as they are where it is. With --features <dir>, it takes the screenshots of fctl.sh's feature pages instead, as WebP, into hyperstructure/fctl-site's src/features/shots (the demo skill says what they need).

xtask/demo/diff-video.mjs makes the video of a session's diffs following its agent that fctl.sh's Sessions page shows, without a stack: the session's diffs from its transcript's edits (cargo xtask demo steps platform-437), shown by the app's diff dialog on web/demo/diffs.html, a page Vite serves in development only. The demo skill says how to run it.

fake-claude plays the transcripts with --replays <dir>: each *.toml there is one session's turns, which it streams as Claude Code would, its tool calls with their outputs, and makes their edits, writes, commits and pushes for real in the session's working directory. The format is in crates/fake-claude/src/replay.rs. cargo test -p xtask the_cast_holds_together checks that every session has a transcript that answers its messages and changes only what its repository holds.

The standalone control plane

Outside Cloudflare, the control plane runs as deploy/standalone/server.mjs runs it: the Worker and the web app built for production, on workerd through Miniflare, as wrangler dev runs them, with what Cloudflare does around the Worker done by the server: migrations, the client's address, the hourly job (Self-hosting, ADR 0026). Its container image is deploy/standalone/Dockerfile. To run it from your checkout, without Docker:

FCTL_SETUP_TOKEN=dev-setup-token cargo xtask serve      # http://localhost:8787, state in .fctl-serve/
cargo xtask dev --smoke --server http://localhost:8787  # a host and a session through it

--server points cargo xtask dev at a control plane that already runs, instead of starting wrangler dev: it bootstraps the dev owner there with the dev setup token, and runs the host (and with --smoke, the session) against it, without Linear's stand-in. Give it the server's FCTL_PUBLIC_URL, the origin the server accepts requests from. Its state goes in .fctl-dev/server/. Delete .fctl-serve/ to start the server over.

The image runs the same session with Docker, as the Standalone image workflow does (Checks):

docker build -f deploy/standalone/Dockerfile -t fctl-control .
docker run --rm -d --name fctl -p 127.0.0.1:8787:8787 \
  -e FCTL_PUBLIC_URL=http://127.0.0.1:8787 -e FCTL_SETUP_TOKEN=dev-setup-token fctl-control
cargo xtask dev --smoke --server http://127.0.0.1:8787
docker stop fctl

Checks

cargo xtask check          # what CI runs
cargo xtask check --quick  # without the production builds
cargo xtask check test     # one part: lint, test or web
cargo xtask dev --smoke    # and CI's other check: a session through the local stack

That is cargo fmt --check, a check that web/src/gen matches the Rust types, Biome and TypeScript for the web app, Biome for the standalone server and the Linear stand-in, Clippy (native and wasm) with warnings as errors, cargo test, the web app's Vitest suite, the standalone server's and the Linear stand-in's tests (node --test), fctl's Linear queries against Linear's schema (cargo xtask linear check), and a production build of the web app. It comes in three parts: lint (formatting, the generated types and Clippy), test (cargo test) and web (the rest), and runs all three, in the order that fails fastest, when you name none. CI runs each part in a job of its own, at once, and its cargo xtask check check passes once all three have; a part that fails there fails the same way locally with cargo xtask check <part>.

cargo xtask dev --smoke starts the local stack as cargo xtask dev does and runs fctl run -p playground --model haiku with a task that has fake-claude commit notes.md, push it, watch its checks for 12 seconds, read an image (look), keep a note in the project's memory, record a partial retrospective and update it, and leave a command and a subagent working in the background. It waits until the session has shown the commit pushed before the turn ended, the transcript has named the image the session kept, which the session then gives back as it was, the transcript has shown the note and the retrospective recorded and updated, the turn has ended, the session has waited for the command and the subagent and has run again while the agent took each one's result in a turn of its own, checks that the project's memory has the note and the session its one retrospective, completed and updated, then stops the session, checks the model and effort level it reports and that the project remembers the model it asked for. It makes an enrollment token that asks for approval and enrolls a runner with it through the API, as fctl host install --enroll does, and checks that a second can't take the token's one place, that the runner waits and serves no workspace until Dev Owner approves it and does after, that it removes itself as an uninstall does, and that the revoked token enrolls no more. Then, in the Linear stand-in, Dev Owner delegates a new issue to fctl, and the smoke test waits until fctl has said in the issue's agent session that it looks at the issue and works on it in a session of Playground, moved the issue to In Progress, told the agent the issue was delegated to it (the stand-in starts a delegation's thread with Linear's own words, as Linear does, which fctl must not take for a mention), linked the session there and answered as the turn ended. A reply has the agent run fctl ticket commands, whose list, search, view, new sub-issue, comment and change of state, label and priority must reach Acme; another reply starts a turn, which Dev Owner's stop must interrupt, with fctl's word that it stopped; another hits the usage limit, and fctl's pause note must name Dev Owner's time zone; Grace's reply must get her asked to link her account; a mention of fctl in another issue must start a session that answers there; and an issue of a team no project takes must get fctl's refusal. The stand-in must have found nothing Linear would refuse, or fctl didn't do in time. The smoke test then stops the sessions and everything else. It fails when any step does. CI runs it from a fresh checkout, so its migrations, Worker, bootstrap, host and agent all run before a change can deploy. Locally it uses your .fctl-dev/ state and ports 8787, 8790 and 5173: stop cargo xtask dev first.

cargo xtask dev, with --smoke or without, builds the CLI, the host and the agent, the Worker (worker-build --dev, in target/worker, so that the two cargo builds don't wait for each other), and the web app and the local D1's migrations at once; the lines of the last two come after worker │ and web │.

CI caches the dependencies, not the workspace's own crates (Swatinem/rust-cache, set up by .github/actions/rust with the toolchain), and builds without debug info. Two builds of the workspace are cached by the files they are built from instead: xtask's binary (.github/actions/xtask), which the jobs run as xtask check lint and the like, and on main the Worker's release build, which runs beside the checks for the deploy. Only main saves caches; pull requests use main's.

CI also runs Clippy over fctl and fctl-host on a Mac (the "fctl on macOS" job), and cargo xtask check waits for it: the release builds fctl for macOS, and code that only Linux has (O_PATH, fallocate, the firecracker driver's) needs #[cfg(target_os = "linux")] there. To check from Linux, rustup target add aarch64-apple-darwin, then cargo clippy -p fctl -p fctl-host --target aarch64-apple-darwin; ring's build script needs a C compiler for macOS, such as zig's (python3 -m ziglang cc -target aarch64-macos, without the -arch and -mmacosx-version-min flags cc passes it) in CC_aarch64_apple_darwin.

The Standalone image workflow (.github/workflows/standalone-image.yml) builds the standalone control plane's image, starts it and runs cargo xtask dev --smoke --server against it, so the container, its migrations and its proxy run a session too. The build takes several minutes, so it runs on the pull requests that change the image, its server or the bindings it reads (deploy/standalone, .dockerignore, crates/fctl-control/wrangler.toml), and for each release before it publishes the image; other pull requests don't run it. To check the image of another change, run the same session through it yourself (The standalone control plane).

Pull requests that change the agent skills in .claude/skills also run the Skills workflow, which validates them all in one job against the Agent Skills specification, with agent-skills-validator.

Dependabot opens security updates, and no version updates (.github/dependabot.yml). It opens them from the root, where package-lock.json and Cargo.lock are, and titles them as Conventional Commits (build(deps): bump …, ci(deps): bump … for the workflows' actions), so they pass these checks like any other pull request. A title that a long package name and its versions take past 72 characters (build(deps): bump @cloudflare/workerd-linux-64 from …) fails the Conventional Commits check: shorten it on the pull request, which reruns the check. GitHub also raises an alert for web/package.json, deploy/standalone/package.json, xtask/linear/package.json and the crates' own Cargo.toml files, but Dependabot would update those without the lockfile, so their directories open none.

Repository map

Path What
crates/fctl-proto Wire types for every protocol; TypeScript is generated from them
crates/fctl-domain Pure logic: permissions, validation, tokens, naming, scheduling, event shaping
crates/fctl-control The control plane Worker (Rust → wasm): API, auth, Durable Objects, GitHub
crates/fctl-client HTTP and WebSocket client used by the CLI and hosts
crates/fctl-host The host daemon: control connection, sessions, sandbox drivers, images
crates/fctl-agent The supervisor inside each sandbox: runs Claude, journals events, prepares git
crates/fctl The fctl binary: CLI plus fctl host
crates/fake-claude The scripted Claude stand-in
xtask cargo xtask: dev, check, gen, serve, deploy, secrets, dist, publish
xtask/linear The Linear stand-in: Linear's sign-in, GraphQL API (on Linear's schema) and webhook deliveries
web The web app (React, TanStack Router and Query, Tailwind, WebGL)
deploy/instances Instance descriptions for cargo xtask deploy
deploy/standalone The control plane without Cloudflare: its server, container image, Compose and Kubernetes examples
docs These docs; docs/adr records decisions, and docs/llms.txt sums fctl up for agents

Changing a protocol

  1. Change the types in crates/fctl-proto — add fields as Option (with #[serde(default)] where needed), never repurpose a type tag.
  2. cargo xtask gen regenerates web/src/gen.
  3. Keep older peers working: hosts and agents can lag the control plane by a version, and the web app talks to whatever is deployed.

Working on the control plane

  • It compiles to wasm32-unknown-unknown and runs on workers-rs. No unwrap, expect or panics (Clippy denies them): a panic kills the request, and in a Durable Object it can poison the instance.
  • Durable Object SQL must not fail on a schema that is already migrated: create tables with IF NOT EXISTS and add columns with add_column, which checks first.
  • D1 migrations are in crates/fctl-control/migrations; cargo xtask dev applies them locally (in CI, to a fresh database), cargo xtask deploy before deploying, and the standalone server as it starts. Never edit a migration that has shipped.
  • It runs outside Cloudflare too, on workerd through Miniflare (deploy/standalone), with the bindings in wrangler.toml: a binding added there and in xtask/src/deploy.rs reaches both. Workers APIs that Miniflare lacks don't work there. Self-hosted instances keep their state where Miniflare does (state/v3 in their data directory): when you upgrade wrangler, check that its Miniflare still reads it there.

Working on the web app

cd web
npm run dev        # with the control plane from `cargo xtask dev --no-vite`, or any instance via FCTL_API
npm run check      # Biome and TypeScript
npm test

Design tokens, fonts, the WebGL scenes, the mark and the app icons come from @hyperstructure/design, pinned to a tag in web/package.json (it has a playground showing all of them). Use the semantic colors (canvas, surface, ink, accent, highlight, pink, blue, danger…) rather than raw values; danger is the one red, for destructive actions, errors and failures only. Hovered rows and buttons fill with hover (hover:bg-hover, fainter on some large rows), not raised, which is white in the light theme, as surface is. The rows of a list that each open a session or a project come from src/components/ui/list-row.ts: the whole row is the link, and it fills as a rounded tile a little wider than the list while the lines beside it give way, so it needs no arrow to say it opens. (The pull requests page's rows are its own, each with a button that opens its pull request on GitHub.) The scenes render dithered to the brand palette; they pause when hidden and render one still frame for people who prefer reduced motion.

An address the app doesn't know shows src/routes/not-found.tsx, and a request that fails before a page can show (the control plane's 5xx, no connection) or a page that crashes shows src/routes/error.tsx, the router's error page. Both are the design package's stream, with the code as glyphs people can throw (src/components/stream-screen.tsx); on the error page the power goes out and "Try again" turns it back on, asking /api/v1/health before it loads the page again. To see it, block /api/v1/auth/me in DevTools (Network → Block request URL) and reload. fctl.sh has its own 404 and 5xx pages, in hyperstructure/fctl-site.

People install the app on their phones and computers. public/manifest.webmanifest describes it, the brand-assets plugin in vite.config.ts serves the favicon and icons from the design package, and public/sw.js shows public/offline.html when a page cannot load offline, and shows notifications. The service worker registers by itself only in production builds (turning notifications on registers it anywhere): to try installing, run cargo xtask dev --no-vite and open http://127.0.0.1:8787 (Chrome's DevTools → Application shows the manifest and the worker). In the installed app on a phone, the page reaches under the notch and the home indicator, so anything fixed to an edge of the screen keeps clear of them with env(safe-area-inset-*), as the message box does.

On a phone the app keeps the screen's size. Zooming is off: the viewport tag in index.html asks for none (which also keeps Safari from zooming into a text field), and src/lib/zoom.ts cancels the pinches Safari on iOS allows anyway. The image viewer zooms its picture by itself instead: src/components/ui/lightbox.tsx follows the fingers, and Zoom in src/lib/zoom.ts works out where they take the picture. No page is wider than the screen: what can be wider than a phone, like the Agent Floor's workspace chips or a page's tabs, scrolls in a row of its own (overflow-x-auto), long names, addresses and links wrap ([overflow-wrap:anywhere]) or truncate, and the signed-in layout clips whatever still sticks out. A card whose buttons go under its text when it is narrow, like Home's invitations, goes by its own width rather than the screen's, with a container query (@container on the card, @xl: and @max-xl: inside): from 768 px the sidebar takes 288 px of the screen unless someone dragged it wider (up to 420) or narrower (240), or 60 folded into its rail, so a card is narrower at 768 px than at 767. When you change a page, look at it 320 to 430 px wide, with long names in its data, and beside the sidebar at 768 px.

Other repositories

Repository What How it connects
hyperstructure/design Tokens, fonts, WebGL scenes, the marks web/ depends on a tag; bumps arrive as pull requests
hyperstructure/fctl-site fctl.sh: landing page, these docs, /install, /releases renders docs/ at the commit in its fctl-docs.ref, which this repository's CI moves on to a newer commit of main once that commit passes its checks, if its docs/ differ; serves the releases cargo xtask publish writes to the KV namespace in deploy/releases.toml
hyperstructure/sandbox The default sandbox image, ghcr.io/hyperstructure/sandbox its Image workflow publishes an image for each fctl release, which the Release workflow starts; hosts pull it, and fctl host image build builds its main (Sandbox images)

Releasing

Releases come from main through the Release workflow: Actions → Release → Run workflow, or gh workflow run release.yml --ref main -f bump=auto. It picks the version from the Conventional Commits since the last tag, adds the notes to CHANGELOG.md, pushes the release commit and tag with a deploy key (which starts CI on main, so app.fctl.sh redeploys), builds the binaries in parallel jobs (cargo xtask dist build: fctl for macOS and Linux on arm64 and x86_64, and the Linux agents), makes the archives from them (cargo xtask dist package), creates the GitHub release with the notes and publishes to https://fctl.sh/releases (cargo xtask publish --latest). Beside those jobs, the Standalone image workflow runs a session through the release's standalone control plane and then publishes its image to ghcr.io/hyperstructure/fctl-control; gh workflow run standalone-image.yml --ref main -f tag=v0.1.2 publishes a release's image again. Once the release is on fctl.sh, the Release workflow starts two workflows elsewhere, as the organization's fctl-actions GitHub App: the Image workflow of hyperstructure/sandbox, where the default sandbox image lives, which publishes the release's image to ghcr.io/hyperstructure/sandbox (the Release workflow waits for it), and the brew bump of hyperstructure/homebrew-tap, which updates the Homebrew formula.

cargo xtask release --dry-run   # the next version and its notes
cargo xtask notes v0.1.2        # one release's notes

The steps and what to do when one fails are in .claude/skills/release/SKILL.md; commit and pull request conventions in .claude/skills/commit/SKILL.md.

To build locally: cargo xtask dist (or --target aarch64-apple-darwin). It does what the Release workflow does in separate jobs: cargo xtask dist build <fctl|fctl-agent> --target <triple> for each binary, into dist/bin/v<version>/, then cargo xtask dist package.

Deploying

main deploys app.fctl.sh from CI (fctl.sh deploys from hyperstructure/fctl-site). The jobs that deploy, make and publish releases or pin fctl.sh's docs run in the production environment, which holds their secrets (the Cloudflare token, the deploy keys that push release commits here and docs pins to fctl-site, and the fctl-actions GitHub App's key, which starts the Homebrew tap's bump and the sandbox image's build) and which only main may use, so workflows on other branches cannot read them. Rulesets keep everything else off main and off the v* tags (ADR 0021). By hand:

cargo xtask deploy fctl-sh

Cloudflare's API throttles a token, an account or an address that has made too many requests: it answers 429 with a Retry-After, for up to five minutes, even to a deploy's first request. And now and then a server in front of it fails, and answers a 5xx that isn't JSON (503 Service Unavailable, "no healthy upstream"), which wrangler reports as "Received a malformed response from the API". Wrangler gives up at once on that, and on a 429 in its D1, KV and secret commands, so cargo xtask deploy, secrets and publish run such a wrangler command again, up to four times: once the wait a 429 asks for is over, and 5, 15 and 45 s after an answer that isn't JSON (util::retry_cloudflare). d1 create runs again only after a 429 (util::retry_throttled): an answer that isn't JSON may have come once the database was made. A 5xx that the API answers in JSON can't be told from wrangler's output, and isn't run again.