Rewriting cs in Rust
The cs CLI and the TIE daemon are now one Rust binary. Here's how ten coding agents rewrote them in thirty hours.
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. 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 until this week, so both needed a JavaScript runtime: cs ran on Node 20 or Bun, tied on Node 22. For TIE for Mac, a Swift 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 binary.
npx @corespeed/cs@latestinstalls a nativecsfor macOS, Linux and Windows. The binary starts in 3 ms, where the old one took about a second (throughnpxyou 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
Until October 22, Tie.app shipped tied as a Bun 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?
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?
No runtime means a compiled language, and Rust has the client parts: tokio, reqwest with rustls, tokio-tungstenite for the device link, rmcp for the MCP servers the daemon hosts, a crate for each OS's credential store, and a static musl 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
| What | Used for |
|---|---|
| Rust 1.99 | the language; one binary, five targets |
| tokio | the async runtime under the daemon and the CLI |
| reqwest + rustls | HTTP, TLS, proxies; the WebSocket upgrade starts as a reqwest request |
| tokio-tungstenite | the device link, once reqwest has upgraded it |
| rmcp | the MCP client for the local servers tied hosts |
serde_json (float_roundtrip), ryu-js, regress | JSON, number text and regexes that behave like V8's |
| musl | static Linux binaries that run on any distribution |
| Node.js 20 or Bun | only to run the 3.7 KB npm launcher; the binary needs neither |
| npm | @corespeed/cs plus five platform packages, published by trusted publishing |
| Swift | TIE for Mac, which now carries the Rust cs and runs cs tied serve |
| GitHub Actions | build, sign, notarize, test and publish; Linux arm64 jobs on our GCP fleet |
| Claude Opus 5.5 · Codex | wrote the port · reviewed every pull request |
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
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, tests, push, open a PR, get reviewed, fix, repeat. One coordinating session talked to me, routed decisions, merged, and wrote no code.
Adversarial review
Claude (Opus 5.5) sessions wrote. A 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
stopcould hit the app's own new daemon in its first five seconds, when nothing restarts it. Fixed by claiming the pid before anyawaitand re-checking it over the control socket. - P1, release workflow. The publish step checked npm's
latestso 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.dirnameneeds Node 20.11; the docs said Node 20. Replaced withfileURLToPath. - P2, recorded CLI cases. The macOS cases stubbed
securitythroughPATH, but the Rust credential store calls/usr/bin/securityby 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
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
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
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:
//! 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'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
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.
/// 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
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.
rust: 437/437 passed
ts: 438/438 passedRust 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 directly. - Windows process kill. TypeScript:
taskkill /T /F. Rust: a Job Object. 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: always1,234.5. No ICU data either. - A cut inside a surrogate pair. TypeScript: a lone surrogate. Rust: U+FFFD. A Rust
Stringcan't hold half a surrogate. cs --version. TypeScript:Unknown command, exit 1. Rust: prints the version. Installers need it.
CI runs fmt, 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
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
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
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:
go: rust/cli-core-accounts @ 0e34b9bfWith all 19 approved on their exact heads, gh stack merge 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
-
A Python
str.replacemeant to fill in a Windows prompt ran aftercargo fmthad 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 --backgroundhung 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
cskept running, 6 failures in 200 runs. The fix is ordering:- 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
childis always assigned by the time one fires. 0 in 400 after.
cs is better in Rust
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
| 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
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
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
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.
[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 serveFour 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 update, with the Rust cs inside the bundle.
Shipping
npx @corespeed/cs@latest --versionEverything that broke at release time was packaging, three times:
npm publish npm/x.tgzfailed withEALLOWGIT: current npm reads a relative path without./as the GitHub shorthandgithub:npm/x.tgz.- One platform package's trusted publisher 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/csstayed 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
| 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
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.