Skip to content

Runtime architecture refactor - #744

Open
Anton-Horn wants to merge 118 commits into
mainfrom
refactor/runtime-architecture
Open

Anton-Horn wants to merge 118 commits into
mainfrom
refactor/runtime-architecture

Conversation

@Anton-Horn

@Anton-Horn Anton-Horn commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Implements docs/architecture.md (plan and progress in docs/refactor-plan.md). Each workspace now runs in its own runtime daemon that holds its state and does its work; the desktop app is just a client of it over the runtime protocol. Other devices can pair and connect to the same runtime (same network or Cate Connect).

Structure

  • src/ is organised by layer (kernel, runtime, workspace, services, client, panels, shells) and by side (contract, runtime, node, client, desktop). npm run lint:deps checks the rules on every file, with fixtures.
  • One client core both shells run. The desktop UI lives entirely in src/shells/desktop/; shared code asks for client features, never a client kind.
  • Capabilities, cate API methods, settings, actions and panel types are each declared once and consumed generically. The CLI is generated from the API specs.
  • Panels are one folder each: a pure definition, a runtime session that owns all behaviour, and a view per shell. Views ask, sessions act (the discard answer travels in the op).
  • State lives in one place each: document, session, workspace data, device, client. All persisted files go through kernel/state.

Runtime and networking

  • Pairing with pinned keys, Noise handshake on every network connection, same-network WebSocket + mDNS, WebRTC through Cate Connect with TURN relay.
  • Remote machines (SSH, askpass), connection status, presence.
  • Pushes through Cate Connect.
  • Hardening: per-transport budget for unproven connections, pairing proof failures no longer burn every code, devices removed from pairings.json are revoked, network id derived from the runtime key.
  • Workspaces are closed instead of kept as recents.

iOS app

New SwiftUI app in ios/ running the client core headless: workspaces, places, terminals, agent chats with steps and a shared chat timeline.

Agents

  • Agents run as CLIs in terminals; T3 Code is removed.
  • cate.agent.start replaces missions; agents read as chats on both clients.
  • The agents service is the only owner of agent state (resume stamps, recorded changes, titles, checkouts). Notifications belong to the workspace.

Fixes along the way

Two daemons serving one workspace, dropped ops after reopen, a crashed runtime rewinding the document sequence, closes dropping unsaved edits, undoing a close losing session state, revoking trust leaving processes running, the review running git without the trust gate, worktrees left half created by a crash, watcher errors ending every subscriber, late watcher echoes overwriting newer edits, daemon.out.log growing forever, the server reaper killing unrelated processes, and more (see commits).

Also:

  • Terminal link detection moved into the terminal service.
  • Hermes history fix: node:sqlite on Node 22 truncates text at NUL, so \x00json: rows came back empty. Now read as BLOB.
  • e2e cleanup no longer flakes on ENOTEMPTY from late .zsh_history writes.
  • npm run dev exposes CDP on 9222 and works on Windows.
  • Dependabot bumps.

Checked on Node 22: typecheck, lint, lint:deps, build, unit tests, electron smoke, full e2e (146 passed, 5 skipped). Not rerun after the latest commit.

The Cate Connect service lives in its own repo (0-AI-UG/cate-connect).

Anton-Horn and others added 9 commits September 30, 2026 16:13
Implements docs/architecture.md: one runtime daemon per workspace that owns
all workspace state and work, a portable client talking to it only through
the runtime protocol, and src/ reorganised by layer and side (kernel,
runtime, workspace, services, client, panels, shells) with the rules
enforced by dependency-cruiser.

Also:
- Cate Connect client, pairing, Noise security layer and network
  transports (same network, WebRTC)
- cate CLI generated from the declared API specs, plus `cate serve`
- Hermes history: read message content as a BLOB, since node:sqlite on
  Node 22 truncates TEXT at the NUL of `\x00json:` rows
- e2e cleanup: remove the temp HOME with async rm, which retries while the
  killed runtime's shells still write .zsh_history
Move actions into kernel/ui, add file refs and file server, SSH runtime
support, runtime mismatch dialog, generated menu model, agent hook CI,
and shared-workspace e2e fixtures.
Move the desktop UI under src/shells/desktop/ui, add the iOS app and its
headless client core (src/shells/mobile, npm run build:mobile), move
pairing join into client/workspaces, rework the runtime mismatch flow,
and add shared-workspace e2e coverage (lifecycle, relations, startup,
watching) plus nested-workspace and empty-sidebar specs.
…shimmer

Port of #747 to the runtime architecture layout. The running-agent title
shimmer animated background-position on clipped text, which can't be
composited, so the window repainted every frame while any agent ran.
Running is now a static dashed ring next to the title, and the waiting dot
takes its colour from the theme instead of a hardcoded value.
File rows now take drops into their own folder, and that folder's row
highlights while the cursor is over any of its files. The explorer only
moves a drop to the root on the tree's empty space; anywhere else in the
panel nothing drops. The explorer marks itself as a file drop host, so the
dock's "Drop to open here" overlay no longer shows over it.
Every change on main since the branch point (b0dcf23) is already in the
new layout here, so the tree stays as it is. Checked commit by commit:

- Ported on this branch: #747 (static running indicator, 608541b) and
  1bf1301 (shared-agent label removed from the editor toolbar).
- Already present in the new layout: settings hooks page, skill sources and
  frontmatter, approval detection, agent hook CI and live tests, terminal and
  hook resource cleanup, provider sign-in, T3 blurry text and connected
  before first prompt, relation menu pan, Hermes on Node 22, the agent hook
  refactor (interrupts, sessions, cate agent read, agentId keying), editor,
  files, review, agent and browser panel fixes, stale PTY spawns, orphaned
  canvases, worktree drafts, Open in browser, the 2.0.5 review follow-ups,
  2.0.5 release prep, dependency bumps and the skills index.
- No longer apply to the new design: cross-window drop snapshots and detach
  transfers, saved-layout wording, dirty dots in saved titles, old-layout
  test fixes, and the explorer state saved across restarts (selection is
  client state).
- Left out: the opt-in e2e specs panel-lifecycle-memory and
  session-soak-perf (with codex-hook-workload.mjs and profile-cate-lag.mjs),
  which use e2e helpers the refactor removed, the provider sign-in e2e
  fixture, and the live agentHookContracts suite.
… so far

The iOS app gets an agents home across workspaces, a session view, a task
composer, review and ship (commit, push, pull request, task apply/keep/
discard), keep-awake per computer and notifications, with a notification
service extension that opens pushes sealed for the device.

The runtime gets a push capability: a paired device registers an opaque
delivery target and a key, and every notification event is sealed for it
and handed to Cate Connect. Clients consume notification events through one
shared consumer (attachAgentNotifications) on desktop and mobile. Tasks go
through cate.codingAgent.*, where a client caller acts as the person.

The iOS app is com.0ai.cate.ios on team F7PH54XQ6J.
Missions and cate.codingAgent.* are gone. cate.agent.start starts an
agent's CLI in a new terminal panel or a T3 thread in a new chat panel,
in the caller's checkout, an existing worktree or a new one, placed next
to the calling panel or where the call says. cate.agent.types lists the
agent CLIs, and cate.agent.interrupt stops a panel's turn with the CLI's
own key or a T3 turn interrupt. The review panel starts its reviewers
the same way.

The runtime streams live conversations on a conversation channel (one
watch per panel, read with the agent's state), and the client core gets
watchAgentChat with prompts pending until the conversation shows them.

The iOS app shows each agent as a chat: a new agent starts from an empty
chat whose box picks a terminal agent or a T3 model and a worktree, and
new panels and agents go where you are (PlacementSheet on a canvas, the
dock otherwise). The agents home, session, task composer and ship views
are removed. Signing moves to Signing.xcconfig, with Local.xcconfig for
building on a personal team without push.

A stopping daemon now releases its socket before its files and exits
within 5 s; a starting daemon waits for the previous owner before
removing a stale socket.
@Anton-Horn
Anton-Horn force-pushed the refactor/runtime-architecture branch from 2051f6d to a9ee215 Compare October 7, 2026 08:49
Cate Connect hands both sides short-lived TURN credentials with each
session (the runtime in incoming, the client in opened), so ICE falls
back to the relay when a direct path fails. The relay only carries the
Noise channel. The runtime logs each session's path (direct or relay),
bytes and duration. "Try same network" now only shows when no path
works, relay included.

The same-network listener serves only private addresses (RFC 1918,
CGNAT, link-local, ULA, loopback) and closes any other connection
before reading it; pairing links and mDNS carry only private addresses,
so a cloud VM is reached through Cate Connect. Until a peer proves a
paired key it may send frames of at most 16 KiB and must pair within
10 s; at most 32 such connections, 4 per address. Refusals name no
version.
npm run lint failed with 2,029 errors from ios/build and the bundled core.js.
Five of six fixtures pass depcruise today (panel session.ts and
definition.ts, runtime/push, src/cli, agents/runners are outside every
rule); they are marked it.fails until R2.
The op counter restarted at 0 for each new document store and rpc client,
so after a reopen in the same launch the runtime answered the new ops as
duplicates (the test's op stayed pending: the rpc dedupe answered a stale
outcome). The counter now belongs to the client identity; an evicted
duplicate fails with the duplicate code, and a reply without a status is a
failure.
With a recorded owner pid that is alive but not a daemon, two starts
100 ms apart both bound (the second unlinked the first's socket after its
wait). The lock now asks ps first and does not wait on a non-daemon, probes
again right before unlinking, and on EADDRINUSE returns running only when
the socket answers. Dials waiting on a pending local start now share its
failure instead of each starting again (three calls before, one now).
Two simultaneous starts with no socket both unlinked before binding; now
they both try to bind and only the loser of the bind looks for a stale
socket.
reapOrphanServers SIGKILLed a reused pid (a sleep with a different
command). servers.json now records the command and start time ps reports
for each child; a pid is reaped only when both still match. The file is
written atomically through kernel/state.
close() marked the terminal dead before node-pty's exit callback, so exit
observers never fired (the test's observer saw no exit) and the agents
runner kept the terminal; a spawn that failed after the env contributors
ran left the runner pointing at a terminal that never existed. One
endTerminal now ends a terminal for an exit and a close, and a failed
spawn (including the trust re-check) tells exit observers too.
The session host deleted sessions/<id>.json on removal, so undo re-added
the panel with empty state (the test's review note was gone). The file
is now set aside in sessions/removed/ and brought back when a panel with
that id is added; removed entries older than a day are pruned at start.
Retiring a store awaits its write in flight, which also closes the race
where a late write's cleanup deleted the next session's file.
A client at seq 10 resuming on a runtime restarted from a document.json
saved at seq 7 (and moved on to 10 with other ops) got no ops and kept a
diverged mirror. The sequencer now has an epoch per start; a resume names
the epoch of its seq, and another epoch gets the full document.
… key

The path-hash runtime id was the network identity, so the first key to
register an id on Cate Connect owned it forever: the same path on two
machines, or a runtime whose secrets.json was lost (the test's restarted
runtime was refused with key-mismatch), could never register again, and
anyone who guessed a path could claim it. It also leaked the path hash on
mDNS. Pairing re-pinned any key a code answered with (the test's pairing
under an id its key does not derive succeeded).

A runtime is now on the network under networkIdOf(its static key): Cate
Connect, mDNS, pairing codes, pushes and paired clients use it; clients
check that the key derives the id at pairing and on every connect, and a
pin is never replaced by another key. The path id names only the data dir
and socket. CONNECT_PROTOCOL is 2; the stand-in keeps no bindings.
The review session got the repository's raw git; only the vcs capability
checked trust, so an untrusted workspace's review committed (the test's
commit ran and failed only on the missing git identity). The repository
runtime now hands out a git and gh whose every call needs trust, with
writes in its queue, and its monitors run no git until trusted. The vcs
capability and the review take that git as is; the capability's own checks
and the review's write adapter are gone. The review passes an RpcError's
code through instead of rewriting it to rejected.
B10 did not reproduce: the debounced write lands within the stop deadline,
and a stop during an in-flight write keeps the newest document. The tests
stay as guards.
A record still creating or removing after a daemon stopped midway was
refused by every later operation (the test's half-made worktree stayed).
Reconciling now finishes them inside the write queue: a checkout git lists
becomes ready, a missing one is dropped, and a removal is completed.
B12 did not reproduce: the client's session subscription recovers once the
restore finished. Adds a beforeStart test seam to serveWorkspace.
The settings mirror subscribed without resume and ignored the stream's
end, so after a dropped connection it never saw another change (the test
timed out waiting for B's edit). It now resumes, and starts over when the
runtime ends the stream.
Every opened LAN connection is checked by the security handshake before
it wins: one where another key answers is closed and the remaining
addresses still count, then Cate Connect is tried. dialSameNetwork takes
the check (and frees results that come in after another won).
openExternal refuses loopback URLs in main, where every caller passes
(D10): such a page belongs to the runtime's machine and opens in a
browser panel. The Windows pipe ACL is noted as not fixable through
Node's net and untested.
…dline

A session probe's until resolves on the session's own events (and the
document's until the panel is known), within the test's budget, instead
of polling against a ten second deadline a loaded machine overran.
Comments in runtime, contract and client-core code that described clients
by kind (mobile, desktop) now say what they mean: a client, a shell's web
view, Node. No runtime or core behaviour branched on a client kind.
isAgentFallbackTitle and agentPanelTitle move to the agents client, so
iOS shows a terminal running an agent under the agent's name as the
desktop does. Where an agent works (its session, its panel's worktree,
the workspace root) is agentCheckout in the review client, and
openAgentChanges finds it itself, so neither app resolves it.
openTrusted (client/workspaces) opens a workspace when it is not open,
asks the trust question once its runtime answers and closes it again
when declined; the desktop's navigation and the iOS core call it instead
of each writing those steps.
… the UI

The tab follower drops blank pages and turns load state into the
reportLoad op (ending a followed load), so the desktop page host and the
iOS core no longer wrap it the same way. The page host moves to
panels/browser's desktop side; a client's local downloads become an
instance created with the installed page bridge instead of module-level
maps in the UI folder.
The chat page controller opens a page's links with openUrlFor itself; its
port loses openLink. The iOS core installs a ClientUi whose openExternal
goes to the app's system browser, so it no longer repeats the loopback
rule.
createWorktree (a branch name from what was typed, the typed name as its
label) and checkoutPullRequest move from a desktop React hook to
workspace/repository/client; opening a pull request uses the latter.
The notification gate reads its settings slice from the client's settings
store itself, so neither app maps the two keys by hand. Centring a new
canvas panel on a point is originCentredOn in workspace/canvas, which the
desktop toolbar drag and the iOS placement both use.
Creating them when the page bridge is installed subscribed to downloads
on every bridge, including partial ones a settings page installs.
SSH serving becomes a machine folder flow (runtime/daemon/desktop/machine,
MachineFolderDialog, machineIpc); pairings are kept as devices
(devicesFile). Connection status, workspace list and join move logic into
client/connections and client/workspaces. The Windows pipe name carries a
per-user key. The iOS app gets place views, worktrees, a review panel and
relations, with agent and T3 logos, backed by new mobile core modules.
The architecture doc follows.
@Anton-Horn
Anton-Horn force-pushed the refactor/runtime-architecture branch from 0b45228 to 26725d1 Compare October 9, 2026 21:37
Anton-Horn and others added 6 commits October 10, 2026 17:47
A connection shares one session channel per panel and closes it a while
(SESSION_LINGER_MS) after the last handle is released, so reopening a
panel starts from its snapshot. The iOS core warms the sessions and
buffers of panels likely to open next (panel.warm, buffer.warm), and
buffers send versioned deltas instead of the whole text on every change.
A terminal attach can ask for at most its own scrollback.
code.run and code.reset were addressed to the browser panel, but the client
serves them only on the panel-less surface, so every run failed with
"unknown page operation code.run" once a browser panel existed. They now go
to the panel-less surface of the client that drives the target panel.
The T3 chat panel, services/t3, the t3 runner, the server host
(runtime/server) and the T3 patches are gone, along with their e2e specs,
iOS chat view and settings UI. The agents runtime keeps only the terminal
runner (runners/terminal folded into agents/runtime), and cate agent start
drops --runner, --instance and --model.

Alongside: browser page driver and code session fixes, observation text
moved into the browser contract, skills catalog in a contract, file
search runner tests, and the docs, CLI skill and changelog follow.
* Fix `npm run dev` on Windows: skip bash electron patch there

On Windows, `bash` in `predev` and `postinstall` resolves to WSL's
bash.exe, which ran patch-electron-name.sh with Linux node. Its
`node electron/install.js` installed the Linux Electron into the Windows
node_modules (path.txt `electron`, Linux files in dist), so every
`npm install` or `npm run dev` left an Electron that hard-crashed at
startup (exit 0x80000003, no JS error).

Run the script through a small node wrapper that skips it on win32.

* Keep package.json non-executable

---------

Co-authored-by: Anton <contact@cero-ai.com>
* Show the machine folder dialog above Settings

"Open a folder" on a WSL distro or SSH machine is on the Remote machines
settings page, an overlay at z-[100001]. The dialog is a Modal at the
default z-[100000], so it opened behind Settings: it was mounted and had
listed the folders, but could not be seen or clicked, and the page looked
frozen.

Give the dialog z-[100002] through Modal's zClassName.

* Fix the machine folder dialog under StrictMode

In dev, StrictMode mounts, unmounts and remounts the dialog. The unmount
cleanup cancels the machine's running commands, which rejected the first
ensureRuntime with "Cancelled." (logged by main as an IPC failure), and set
`mounted` to false without the remount setting it back. The second check
then finished but its result was dropped, leaving the dialog on "Checking
the Cate runtime there" forever.

Run the check in an effect that owns it: each run has its own `ignore`
flag, and its cleanup sets it and cancels the machine's commands. A run
cancelled by StrictMode or a close can no longer report its error or its
result, and the run after the remount reports normally. "Try again" bumps
an `attempt` counter in the effect's deps to run it again. `mounted` is
reset on every mount for the folder calls that still read it.

A regression test mounts the dialog under StrictMode with a setup that
rejects on cancel.
Bump simple-git to 4.0.2 and allow the GIT_* variables the git host
forwards on purpose, which v4 otherwise blocks. Override lodash-es,
postcss-selector-parser and katex to patched versions, and pick up
dompurify, sharp, source-map-js and http-cache-semantics fixes via
npm audit fix.
@H3mul

H3mul commented Oct 11, 2026

Copy link
Copy Markdown

Can we reduce the friction of reaching recent workspaces / new remote "connect to another machine" workspaces from the main window view?

Suggestions - Zed model:

image
  • A quick filter "search" box to find a long-lost recent somewhere at the bottom of the list
  • Add "Open Local Folder" and "Open remote folder" to the sidebar menu (either as separate buttons, or as a dropdown on the current "open" button)
  • Also, would be cool to have a remote source indicator in the workspace row like Zed (none - local, otherwise WSL distro or ssh remote name and 3 different icons)

@Anton-Horn

Copy link
Copy Markdown
Contributor Author

Can we reduce the friction of reaching recent workspaces / new remote "connect to another machine" workspaces from the main window view?

already done something very similar locally. Moved the connected out of the settings into a modal accessible through the workspace overview sidebar. I'll push it later once i'm back home. I plan to merge the architechture rework pr soon (even though it probably still contains a few bugs/issues) and build a beta version so the dev process is more transparent and not hidden behind a huge pr. Appreciate the feedback, ideas and help on cate!

Anton-Horn and others added 7 commits October 11, 2026 23:36
- SSH machines: password/passphrase prompts via SSH_ASKPASS helper,
  shared ControlMaster connection, ~/.ssh/config hosts, runtime tarball
  streamed to the machine instead of install.sh
- Desktop: Open dialog with workspace picker, Machines page replaces
  Remote machines, Share dialog, What's New, runtime install progress
- Settings window split into Device and Workspace tabs
- Agents: conversation steps (runtime, contract, iOS), composer updates
- Terminal service and process scan cleanups
- A withdrawn SSH prompt no longer leaves its typed answer for the next
  machine's prompt.
- Cancelling a machine's folder dialog withdraws only the prompts of the
  ssh it stops: each ssh owns its prompts and withdraws them on exit,
  instead of every prompt for the machine's label.
- The runtime release downloads into a private temp directory.
- ssh is not started when the file to stream is missing, and the file is
  let go once ssh exits.
- A queued agent prompt the agent took is not sent again after a failed
  send; a slash command does not wait for a turn that may never start.
- Agent steps go with their panel and are trimmed in chunks, so watchers
  do not get all of them on every step.
- An install that ended before the installs snapshot stays ended.
- Point two tests at the renamed settings sections.
The sidebar lists the workspaces open on this device, across restarts,
until they are closed. Closing disconnects a workspace and drops it from
the list; a paired one also loses its pinned key and asks first.
Remove from Sidebar and Forget become Close Workspace, the Open picker
loses its Recent section, and the welcome page becomes two buttons and
what is new.

- Declining trust only disconnects a workspace: it stays in the list and
  a paired one keeps its key.
- Closing several workspaces at once asks once and shows the nearest
  workspace left.
- A new workspace joins at the end of a list whose file had no order.
- One close remedy replaces remove and forget.
…itecture

# Conflicts:
#	package-lock.json
#	package.json
#	src/services/agents/runtime/live/hookLifecycle.itest.ts
#	src/workspace/repository/runtime/git.fetch.test.ts
#	src/workspace/repository/runtime/git.remotes.test.ts
The channel's snapshot is null once it ends at teardown; the listener
read it unchecked and left an unhandled rejection that failed the run.
- Terminal link detection moves to services/terminal (contract + runtime);
  the desktop view keeps only an xterm link provider, and opening links
  goes through client/host and the desktop app links module.
- iOS agent chat gets a shared chat timeline in the mobile core and a
  ChatPresentation layer in the app.
- Loopback proxy, cate serve, pairing and network dialer fixes, with
  transport failures classified in one place.
- Keep-awake button, canvas toolbar and agent spawn flyout updates.
- docs/architecture.md and CLAUDE.md brought in line with the code.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants