# Rewriting cs in Rust (/blog/we-rewrote-cs-in-rust-with-a-fleet-of-agents)

`cs` is our command-line tool: you use it to log in, mint API keys, call tools from a shell, and push files to your CoreSpeed Drive. `tied` is the daemon behind [TIE](/blog/introducing-corespeed-tie). It keeps a WebSocket open to CoreSpeed and exposes your machine's shell, files, local MCP servers, browser and screen to your agents as tools.

Both were [TypeScript](https://www.typescriptlang.org/) until this week, so both needed a JavaScript runtime: `cs` ran on Node 20 or Bun, `tied` on [Node 22](https://nodejs.org/). For TIE for Mac, a [Swift](https://www.swift.org/) menu bar app, that meant bundling a runtime (a 128 MB executable, a 56 MB download) or finding one on the user's Mac (0.1.3, and its "Get Node…" button).

Thirty hours later, both are one [Rust](https://www.rust-lang.org/) binary.

* `npx @corespeed/cs@latest` installs a native `cs` for macOS, Linux and Windows. The binary starts in 3 ms, where the old one took about a second (through `npx` you still wait for Node).
* TIE for Mac 0.2.0 ships that binary inside the app: 13.8 MB, nothing to install first, about a quarter of what the runtime-bundled version weighed.
* The daemon idles at 6 MB of RAM instead of 85.
* There is no TypeScript CLI or daemon left in the repository.

About 50,000 lines of Rust, 45 pull requests, 76 formal approvals, ten agent sessions writing in parallel, a different model reviewing every change, and one person saying yes or no. From the first question on Tuesday afternoon to npm `latest` on Wednesday night. A rewrite that costs a day and a half is a different decision from one that costs a quarter.

## A 128 MB executable for 2 MB of program \[#a-128-mb-executable-for-2-mb-of-program]

Until October 22, `Tie.app` shipped `tied` as a [Bun](https://bun.com/) single-file executable: 128 MB, of which the daemon's own code was about 2 MB. The rest was the Bun runtime, packaged twice, downloaded again on every update, so that a menu bar app could run a few thousand lines of TypeScript.

## Just use the user's Node? \[#just-use-the-users-node]

TIE for Mac 0.1.3 dropped the runtime and ran `tied.mjs` on whatever Node 22 the user had. The app lost 125 MB and gained a `NodeLocator` (your login shell for nvm, fnm and volta, then `/opt/homebrew/bin`, `/usr/local/bin` and the `node@22` paths), and the first question in a TIE bug report became which Node it had found. Neither was what we wanted: a daemon with nothing under it.

## Why Rust? \[#why-rust]

No runtime means a compiled language, and Rust has the client parts: [tokio](https://tokio.rs/), [reqwest](https://crates.io/crates/reqwest) with [rustls](https://github.com/rustls/rustls), [tokio-tungstenite](https://crates.io/crates/tokio-tungstenite) for the device link, [`rmcp`](https://crates.io/crates/rmcp) for the MCP servers the daemon hosts, a crate for each OS's credential store, and a static [musl](https://musl.libc.org/) build that runs on any Linux. `cs` and `tied` already shared config, credentials, paths, logging and the HTTP client, so they became one binary: `cs` for people, `cs tied serve` for the daemon, always the same version because it is the same file.

## The stack \[#the-stack]

| What                                                                                                                                                           | Used for                                                                     |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| [Rust](https://www.rust-lang.org/) 1.99                                                                                                                        | the language; one binary, five targets                                       |
| [tokio](https://tokio.rs/)                                                                                                                                     | the async runtime under the daemon and the CLI                               |
| [reqwest](https://crates.io/crates/reqwest) + [rustls](https://github.com/rustls/rustls)                                                                       | HTTP, TLS, proxies; the WebSocket upgrade starts as a reqwest request        |
| [tokio-tungstenite](https://crates.io/crates/tokio-tungstenite)                                                                                                | the device link, once reqwest has upgraded it                                |
| [rmcp](https://crates.io/crates/rmcp)                                                                                                                          | the MCP client for the local servers `tied` hosts                            |
| [serde\_json](https://crates.io/crates/serde_json) (`float_roundtrip`), [ryu-js](https://crates.io/crates/ryu-js), [regress](https://crates.io/crates/regress) | JSON, number text and regexes that behave like V8's                          |
| [musl](https://musl.libc.org/)                                                                                                                                 | static Linux binaries that run on any distribution                           |
| [Node.js](https://nodejs.org/) 20 or [Bun](https://bun.com/)                                                                                                   | only to run the 3.7 KB npm launcher; the binary needs neither                |
| [npm](https://www.npmjs.com/package/@corespeed/cs)                                                                                                             | `@corespeed/cs` plus five platform packages, published by trusted publishing |
| [Swift](https://www.swift.org/)                                                                                                                                | TIE for Mac, which now carries the Rust `cs` and runs `cs tied serve`        |
| [GitHub Actions](https://github.com/features/actions)                                                                                                          | build, sign, notarize, test and publish; Linux arm64 jobs on our GCP fleet   |
| [Claude Opus 5.5](https://www.anthropic.com/claude) · [Codex](https://openai.com/codex/)                                                                       | wrote the port · reviewed every pull request                                 |

## Claude, rewrite cs in Rust \[#claude-rewrite-cs-in-rust]

A rewrite in another language costs months and gives you the same program back, so nobody does one for fun. With agents doing the port it cost a day and a half of my attention and some compute. The review and the tests still cost, and you need those anyway. Two questions were left: one binary or two (the spike decided), and how we would know the new one behaves like the old one (black-box tests that run against either build, and a number).

## Loops that write & review code \[#loops-that-write--review-code]

The port ran on Agent Fleet, our cloud for agents (Ensemble, inside the company). Each agent was one session on one machine running one loop: implement, fmt, [clippy](https://github.com/rust-lang/rust-clippy), tests, push, open a PR, get reviewed, fix, repeat. One coordinating session talked to me, routed decisions, merged, and wrote no code.

### Adversarial review \[#adversarial-review]

[Claude (Opus 5.5)](https://www.anthropic.com/claude) sessions wrote. A [Codex](https://openai.com/codex/) session reviewed every PR, a different model family on purpose, under a fixed contract: diff from the merge base at a pinned SHA, run targeted tests where suspicious, own the CI check, post the review and a check run on the exact commit. A push after approval means a new review, which is why 45 pull requests took 76 approvals. P1 blocks; P2 and P3 don't. Some of what they caught, none of it visible in a diff:

* **P1, TIE for Mac.** Two overlapping refreshes could both stop the old daemon, and the second `stop` could hit the app's own new daemon in its first five seconds, when nothing restarts it. Fixed by claiming the pid before any `await` and re-checking it over the control socket.
* **P1, release workflow.** The publish step checked npm's `latest` so it would never overwrite a Rust release, but a failed lookup read as "no Rust release yet". Now fails closed.
* **P2, npm launcher.** `import.meta.dirname` needs Node 20.11; the docs said Node 20. Replaced with `fileURLToPath`.
* **P2, recorded CLI cases.** The macOS cases stubbed `security` through `PATH`, but the Rust credential store calls `/usr/bin/security` by absolute path, so a test could touch a real Keychain entry.
* **P2, `cs-wire`.** The serde types accept a JSON array where the schema wanted an object. Still open.
* **P2, TIE for Mac.** A probe that fails once marks the pid as decided until the app restarts. Still open.

## What we built \[#what-we-built]

### Prep work \[#prep-work]

A design document first: the crate layout, one dependency rule (only the binary may depend on the daemon crate, checked with `cargo tree` in CI), four security rules, which daemon wins when two are running, and a migration in six steps. A second page recorded the stack trade-offs, including the rejected ones.

### Trial run \[#trial-run]

Then a throwaway spike: the device link, its JSON-RPC layer, `shell_run`, two file tools and one hosted MCP server, run against the TypeScript daemon's 20-check conformance suite. 20 of 20 on Linux in debug, release, `opt-level = "z"` and static musl. 20 of 20 on macOS too, on a Mac the agent could reach only through TIE: it installed a toolchain in a scratch folder over `shell_run`, built, tested, cleaned up. The spike also answered the first question:

| Build (spike, release, stripped, fat LTO) | Size              |
| ----------------------------------------- | ----------------- |
| x86\_64 Linux, full                       | 9.60 MB           |
| x86\_64 Linux, daemon only                | 9.25 MB           |
| x86\_64 Linux, full, `opt-level = "z"`    | 6.32 MB           |
| aarch64 macOS, full / daemon only         | 7.61 MB / 7.35 MB |

The CLI is 350 KB of it, so one binary.

### Being bug-compatible with JavaScript \[#being-bug-compatible-with-javascript]

The interesting code is where Rust's defaults and JavaScript's disagree, because scripts parse `cs` output and agents have months of tool calls against `tied`. `cs-core` has a module for that, and its doc comment is the list:

```rust
//! JavaScript's rules, where Tie for Mac 0.1 and cs 0.10 depend on them and the Rust
//! default is different: lengths and cuts in UTF-16 units, `trim`,
//! `String(value)`, truthiness, number text, the order of object keys,
//! `JSON.stringify` and `Date.prototype.toISOString`.
```

The surprises: `.length` is UTF-16 code units, and our output truncation limits were defined in it. [`serde_json`](https://crates.io/crates/serde_json)'s float parser isn't correctly rounded and `JSON.parse` is, hence `float_roundtrip`. And the old CLI never printed a number it had parsed, it printed the server's bytes, so `9223372036854775807` stays `9223372036854775807`. That one needed a small hand-written JSON parser.

### Two daemons on one machine \[#two-daemons-on-one-machine]

During an upgrade two daemons share the box: the old one, still running, and the copy `cs tie` just installed. TIE for Mac can add a third. One rule: the newest binary on the machine wins, whichever installer brought it.

```rust
/// Whether a running daemon must stop for this binary's copy: the newest
/// binary on the machine wins, whichever way it was installed. A daemon of
/// an older version is replaced and a newer one is kept. At the same
/// version (or a version that does not parse), the start time decides: a
/// daemon that started before the copy last changed runs an older build. A
/// start time that does not parse leaves the daemon alone.
pub fn must_give_way(
    daemon_version: &str,
    daemon_started_at: &str,
    our_version: &str,
    written_at: SystemTime,
) -> bool {
    let theirs = semver::Version::parse(daemon_version.trim());
    let ours = semver::Version::parse(our_version);
    match (theirs, ours) {
        (Ok(theirs), Ok(ours)) if theirs.cmp_precedence(&ours) != Ordering::Equal => {
            theirs.cmp_precedence(&ours) == Ordering::Less
        }
        _ => {
            let Ok(started) = daemon_started_at.parse::<jiff::Timestamp>() else {
                return false;
            };
            let Ok(written) = jiff::Timestamp::try_from(written_at) else {
                return false;
            };
            started < written
        }
    }
}
```

The same function exists in Swift inside TIE for Mac, with the same test cases.

### Parity, measured \[#parity-measured]

Progress was a number. The black-box suites take a `CS_BIN` and run against either build: the link and its wire format, the control socket, approvals, the daemon lifecycle, login, CLI output, an SDK client, and 325 CLI invocations recorded from the TypeScript build and replayed byte for byte. One script ran them all against both builds and printed two columns; when a suite reached parity, CI gated on it.

```text
rust: 437/437 passed
ts:   438/438 passed
```

Rust skips one case, `cs --version`, which TypeScript never implemented. Every deliberate difference is in a table on the design page with its reason. A few rows:

* **Device link.** TypeScript: ignores proxies. Rust: goes through the same proxy as every other request; a proxy setting that isn't a URL stops the dial with that error. The device credential never goes direct when the owner set a proxy.
* **Linux credential.** TypeScript: needs `secret-tool`, else a 0600 file. Rust: talks to the [Secret Service](https://specifications.freedesktop.org/secret-service/latest/) directly.
* **Windows process kill.** TypeScript: `taskkill /T /F`. Rust: [a Job Object](https://learn.microsoft.com/windows/win32/procthread/job-objects). Kills children whose parent already exited.
* **Sort order (`fs_list`, tool search ties).** TypeScript: `localeCompare`. Rust: Unicode code point, case-insensitive first. Exact locale sorting costs 1.25 MB of ICU data.
* **Credit numbers (`cs usage`).** TypeScript: `toLocaleString`. Rust: always `1,234.5`. No ICU data either.
* **A cut inside a surrogate pair.** TypeScript: a lone surrogate. Rust: U+FFFD. A Rust `String` can't hold half a surrogate.
* **`cs --version`.** TypeScript: `Unknown command`, exit 1. Rust: prints the version. Installers need it.

CI runs fmt, [`cargo deny`](https://github.com/EmbarkStudios/cargo-deny), the dependency rule, clippy, the tests, the link suite and the scoreboard gate; a Windows lane runs the tests and the link suite on `windows-latest`; a weekly macOS job boots the real computer-use driver and fails if its tool list drifts from the table in `cs-tied`.

## Landing it \[#landing-it]

### Ten agents, one branch \[#ten-agents-one-branch]

Ten agents in parallel on one integration branch, one per piece: core, the device link, the daemon, the shell provider, the computer-use provider, the CLI core, the CLI tools, `cs tie`, CI, and conformance. `main` didn't change until the whole thing was ready.

### False starts \[#false-starts]

The repository allows squash merges only, and that cost an evening. Squash-merge the bottom of a stack of dependent PRs and every layer above it conflicts; squash two stacks back to back and `Cargo.lock` can list the same crate twice, which breaks every `--locked` build. We did both before changing the process.

Two agent sandboxes also died in the same hour. A test for "a deep JSON document can't overflow the stack" built a 200,000-deep array and printed it with indentation, which grows with the square of the depth: about 80 GB. The test uses depth 2,000 now and the agents run tests under a memory limit.

### Merging the Rust rewrite \[#merging-the-rust-rewrite]

The final merge was one native GitHub stack of 19 PRs, assembled as a relay. Each group merged the top of the group below into its own bottom layer, carried that up with plain merges (no rebases, no force pushes), ran the gates, pushed, and sent the next group one line:

```text
go: rust/cli-core-accounts @ 0e34b9bf
```

With all 19 approved on their exact heads, [`gh stack merge`](https://github.com/github/gh-stack) landed them in one atomic operation; the resulting tree was identical to the top layer's head. One PR then took the branch into `main`: 522 files, mostly recorded test cases, which we deleted 25 minutes later. A byte-for-byte snapshot of the old output would have frozen every future change to it.

### Porting mistakes \[#porting-mistakes]

* A Python `str.replace` meant to fill in a Windows prompt ran after `cargo fmt` had reflowed its target. It matched nothing and exited 0; the Windows daemon shipped a stub and scored 17 of 20. Every scripted edit now asserts that it changed something.
* `cs tied serve --background` hung on Windows: the detached child inherited the parent's stdio handles, so the parent never saw EOF.
* The npm launcher spawned the binary and only then registered its signal handlers. A Ctrl-C in that gap hit Node's default handler: the launcher died and `cs` kept running, 6 failures in 200 runs. The fix is ordering:

  ```diff
  -  const child = spawn(file, argv, { stdio: "inherit" });
  -  // Ctrl-C reaches the whole process group, the child too: the child decides
  -  // what it means. A signal sent to this process alone goes on to the child.
     /** @param {NodeJS.Signals} signal */
     const forward = (signal) => child.kill(signal);
  -  process.on("SIGINT", () => {});
  -  process.on("SIGTERM", forward);
  -  process.on("SIGHUP", forward);
  +  for (const signal of /** @type {const} */ (["SIGINT", "SIGTERM", "SIGHUP"])) {
  +    process.on(signal, forward);
  +  }
  +  const child = spawn(file, argv, { stdio: "inherit" });
  ```

  Node runs signal handlers from the event loop, so `child` is always assigned by the time one fires. 0 in 400 after.

## cs is better in Rust \[#cs-is-better-in-rust]

### Smaller than what it replaced \[#smaller-than-what-it-replaced]

| TIE for Mac download                                         | Size                                                              |
| ------------------------------------------------------------ | ----------------------------------------------------------------- |
| 0.1.2, the app with a Bun runtime inside                     | 56.5 MB zip, a 128 MB executable inside                           |
| 0.1.3 to 0.1.7, the app on your own Node 22                  | 2.3 to 2.7 MB, plus an 88 MB Node installer if you don't have one |
| 0.2.0, the app with the Rust `cs` inside, both architectures | 13.8 MB dmg, a 25.7 MB universal binary inside                    |

A quarter of the runtime-bundled download, and nothing else to install. The npm package grew, 3.0 MB of JavaScript to 17 MB on one machine, because it now carries a native binary (11.9 MB on Apple silicon, 16.9 MB for Linux x64, static musl); but the 3.0 MB never ran without a Node that isn't in that number.

### Reduced memory usage \[#reduced-memory-usage]

|                                     | TypeScript 0.10.0 | Rust 0.11.1 |           |
| ----------------------------------- | ----------------- | ----------- | --------- |
| `tied` idle, not tied               | 85 MB             | 6.3 MB      | 13× less  |
| `tied` serving 46 tools for 3 hours | –                 | 18 MB       |           |
| `cs --help`                         | 68 MB             | 8.9 MB      | 7.6× less |

### No stack to overflow \[#no-stack-to-overflow]

The lossless JSON parser doesn't recurse: parse, print, compare and drop use an explicit stack, so no document depth can overflow the thread's stack. The TypeScript version relied on V8's limits.

### 300× faster, when you call the binary \[#300-faster-when-you-call-the-binary]

| `cs --help`              | TypeScript 0.10.0 | Rust 0.11.1 |                          |
| ------------------------ | ----------------- | ----------- | ------------------------ |
| Linux x64, binary        | \~1,000 ms        | 3 ms        | \~300× faster            |
| Linux x64, through `npx` | \~1,000 ms        | \~900 ms    | Node's startup, not ours |
| macOS arm64, binary      | –                 | 10 ms       |                          |

The second row is the catch: through `npx` you still start Node to run the 3.7 KB launcher. The daemon, TIE for Mac and anything that calls the binary directly don't pay that.

## Production \[#production]

One real upgrade before shipping. We had a Mac tied with the TypeScript `cs tie`: a LaunchAgent running `node ~/.config/cs/tied/tied.mjs serve`, up for 12 days. The agent's only way into that Mac was that daemon, and the upgrade stops it. So it wrote the upgrade as a detached script with a rollback: run `cs tie` from the packed tarballs, poll `cs tied status --json` until it says connected, for up to 90 seconds, otherwise `launchctl bootout` the new agent and put the old plist back.

```text
[18:54:35] before: 83459 /opt/homebrew/Cellar/node/26.11.1_1/bin/node /Users/garry/.config/cs/tied/tied.mjs serve
The running tied is older than the one this cs brings; restarting it.
tied started (pid 71685); log: ~/.config/cs/tied.log
✓ Checked: CoreSpeed sees garrys-mac-mini connected with 9 tools.
[18:54:39] after: 71685       00:02 /Users/garry/.config/cs/tied/cs tied serve
```

Four seconds. The next `fs_read` the agent sent to that Mac was served by the binary it had just installed. TIE for Mac 0.2.0 then reached my own Mac as a [Sparkle](https://sparkle-project.org/) update, with the Rust `cs` inside the bundle.

## Shipping \[#shipping]

```bash
npx @corespeed/cs@latest --version
```

Everything that broke at release time was packaging, three times:

* `npm publish npm/x.tgz` failed with `EALLOWGIT`: current npm reads a relative path without `./` as the GitHub shorthand `github:npm/x.tgz`.
* One platform package's [trusted publisher](https://docs.npmjs.com/trusted-publishers) was set to stage new versions for manual approval. After the fix npm still answered `409 Cannot publish over previously staged version "0.11.0"`, so 0.11.1 went out instead. The launcher publishes last, after every platform package is visible, so `@corespeed/cs` stayed on the TypeScript 0.10.0 until the set was complete.
* The post-publish smoke test failed for five minutes on npm's local metadata cache. Every registry read now passes `--prefer-online`.

A machine tied with an older `cs` moves to the native daemon the next time you run `npx @corespeed/cs@latest tie`, keeping its device. Commands, flags and exit codes are unchanged.

## Stats \[#stats]

| UTC       |                                                   |
| --------- | ------------------------------------------------- |
| Tue 16:50 | First question                                    |
| Tue 18:59 | Design document                                   |
| Tue 22:19 | First agent PR merged into the integration branch |
| Wed 02:06 | 19 PRs merged as one stack                        |
| Wed 02:57 | Rust tree on `main`                               |
| Wed 20:17 | npm release pipeline merged                       |
| Wed 22:30 | TIE for Mac 0.2.0 released                        |
| Wed 22:44 | `@corespeed/cs@0.11.1` on npm `latest`            |
| Wed 23:39 | TypeScript daemon deleted from `main`             |

| Crate, lines                | What it holds                                                               |
| --------------------------- | --------------------------------------------------------------------------- |
| `cs-tied`, 27,063           | the daemon: link, control socket, shell/file/MCP/computer-use providers     |
| `cs-cli`, 14,663            | every `cs` command, including `cs tie`                                      |
| `cs-core`, 5,766            | config, credential stores, paths, logs, the JavaScript-compatibility module |
| `cs`, 2,608                 | the binary and its argument parser                                          |
| `cs-wire`, 779              | the Worker's HTTP types                                                     |
| harness (TypeScript), 4,185 | the black-box suites and the fake Worker                                    |

About 50,900 lines of Rust, tests included, replacing about 16,000 lines of TypeScript in the CLI and 9,900 non-test lines in the daemon. 501 Rust tests and 113 black-box cases. 45 merged pull requests and 76 formal approvals. Ten agents, one coordinating session, one human.

## What's next \[#whats-next]

**`cs tie user@server`.** One command from your laptop: copy the 17 MB static binary over the SSH connection you already have, pair it, install the systemd unit. No Node on the server, no package manager. That's the next `cs` release.

**Windows, properly.** A signed x64 binary, and an Arm build as a sixth platform package.

**One `cs` on every Mac.** TIE for Mac already carries it. Next it puts it on your PATH, so the app and the CLI are the same binary and the same version, always.

**Our own fleet for our own CI.** The Linux arm64 jobs run on GCP machines we control today; x64 and Windows follow.

Taking the runtime out of the app took a day and a half of my time, and most of that was deciding. One person can do a lot more this week than they could last month.