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@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

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

WhatUsed for
Rust 1.99the language; one binary, five targets
tokiothe async runtime under the daemon and the CLI
reqwest + rustlsHTTP, TLS, proxies; the WebSocket upgrade starts as a reqwest request
tokio-tungstenitethe device link, once reqwest has upgraded it
rmcpthe MCP client for the local servers tied hosts
serde_json (float_roundtrip), ryu-js, regressJSON, number text and regexes that behave like V8's
muslstatic Linux binaries that run on any distribution
Node.js 20 or Bunonly to run the 3.7 KB npm launcher; the binary needs neither
npm@corespeed/cs plus five platform packages, published by trusted publishing
SwiftTIE for Mac, which now carries the Rust cs and runs cs tied serve
GitHub Actionsbuild, sign, notarize, test and publish; Linux arm64 jobs on our GCP fleet
Claude Opus 5.5 · Codexwrote 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 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

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, full9.60 MB
x86_64 Linux, daemon only9.25 MB
x86_64 Linux, full, opt-level = "z"6.32 MB
aarch64 macOS, full / daemon only7.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 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 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: 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, 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 @ 0e34b9bf

With 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.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:

    -  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

Smaller than what it replaced

TIE for Mac downloadSize
0.1.2, the app with a Bun runtime inside56.5 MB zip, a 128 MB executable inside
0.1.3 to 0.1.7, the app on your own Node 222.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 architectures13.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.0Rust 0.11.1
tied idle, not tied85 MB6.3 MB13× less
tied serving 46 tools for 3 hours–18 MB
cs --help68 MB8.9 MB7.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 --helpTypeScript 0.10.0Rust 0.11.1
Linux x64, binary~1,000 ms3 ms~300× faster
Linux x64, through npx~1,000 ms~900 msNode'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 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 update, with the Rust cs inside the bundle.

Shipping

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 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

UTC
Tue 16:50First question
Tue 18:59Design document
Tue 22:19First agent PR merged into the integration branch
Wed 02:0619 PRs merged as one stack
Wed 02:57Rust tree on main
Wed 20:17npm release pipeline merged
Wed 22:30TIE for Mac 0.2.0 released
Wed 22:44@corespeed/cs@0.11.1 on npm latest
Wed 23:39TypeScript daemon deleted from main
Crate, linesWhat it holds
cs-tied, 27,063the daemon: link, control socket, shell/file/MCP/computer-use providers
cs-cli, 14,663every cs command, including cs tie
cs-core, 5,766config, credential stores, paths, logs, the JavaScript-compatibility module
cs, 2,608the binary and its argument parser
cs-wire, 779the Worker's HTTP types
harness (TypeScript), 4,185the 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.