Jinwoo Hong d5dc7b9cf8 feat(mobile): budget the terminal snapshot on serialized bytes and hold live output instead of ending the stream (OTA phase C, C7.3) (#21785)
* fix(mobile): budget the mobile terminal snapshot on the bytes it serializes to (OTA phase C, C7.3, ruling 1)

The desktop trims a mobile snapshot to 512 KiB of raw terminal text. A client
reading it through the page bridge measures the serialized event against a
640 KiB frame cap, and an ANSI snapshot is mostly ESC bytes, each of which JSON
spends six on. Measured here on a colour-dense 80-column screen: the raw budget
hands back 465,766 bytes that serialize to 669,268 — 102.1% of the cap — so
`deliver` answers `cancel(id, 'overflow')` and the terminal is dead before its
first live byte, with no recovery that does not reproduce it.

`terminal.subscribe` gains an optional `snapshotByteBudget`. A subscriber that
sends one is trimmed against the JSON its payload will really cost: the escaped
text, plus the metadata it cannot bound from its own side — a path, the OSC-link
list, the pending escape tail. A subscriber that sends none, which is every
socket client and every older page, keeps the raw byte rule exactly.

No negotiation, and none is needed: the field is additive and optional, so an
older desktop ignores it and trims as it always did. The page then still has a
snapshot over its cap, the shell still ends the stream with `overflow` (C0.3
stands), and the terminal renders its stream-error state rather than a blank
pane. The page derives the number from the cap less the event envelope rather
than writing it down, so a cap that moves takes the budget with it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* feat(mobile): hold and coalesce terminal output instead of ending the stream on the window (OTA phase C, C7.3, ruling 2)

The shell's backpressure window ends a stream when the page falls 4 MiB behind.
That is right for a stream whose reader can survive a gap and wrong for a
terminal, whose reader cannot see the hole a dropped chunk leaves — and the
window does not wait for a page to go wrong. Measured by the design: the host
produces 70.3 MiB/s of JSON and real xterm applies 2.2 MiB/s, so an ordinary
`cat` crosses the window in 62 ms. Replayed here through the real ledger against
a page draining at that rate, a 5 MB transcript ends the stream after 85 of 107
chunks plain and after 40 of 107 under `grep --color`.

Keyed by method on the shell, since the page cannot pick its own window,
`terminal.subscribe` now holds what it cannot send, merges consecutive output in
escaped bytes under the frame cap, and delivers as the page acks. Nothing is
dropped: merging concatenates, and the only exit that loses bytes is ending the
stream, which the page is told about. Both transcripts now arrive whole and in
order, in 104 and 81 frames, with the largest frame at 622,551 bytes against the
655,360-byte cap.

It ends only on the two things that are not slowness: a page that has acked
nothing for 20 s, an order of magnitude above the 1.9 s a full window takes to
drain, and a backlog past 32 MiB, which at that drain is about 15 s of catching
up. Both reach the page as `overflow`, because the shell is the installed app
and its page comes from the desktop, so a reason the page's reader has never
heard of is a frame it drops rather than an end it acts on. Which one fired, the
coalesced-frame count and the peak pending bytes go to the diagnostic log, which
is the device proof's only oracle for any of this.

Every other stream keeps the byte window exactly, and an event over the frame cap
still ends any stream, terminal or not (C0.3). The landed window cases now name a
stream the window still governs, so the two rules are never read off each other.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): narrow the event arm the backlog replay reads

A binary event carries no `payload`, so the tests-typecheck ratchet refused the
reach into it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): narrow the snapshot serializer to the buffer source it reads

The changed-code casting gate refused the test's stub runtime, and it was right
to: a service-wide type for a function that calls one method is what made the
stub need an assertion. The parameter now says what it needs, and the fixture
path is no longer one a machine-path grep reads as a leaked local checkout.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): measure the snapshot budget by building the payload, not by summing fields (OTA phase C, C7.3, ruling 14)

Round one summed the escaped text and four metadata fields. The payload a
bridged client assembles carries nine more — `kind`, `cols`, `rows`,
`requestId`, `displayMode`, `reason`, `seq` and both truncation flags — plus the
`type` and `streamId` it adds, the `serialized` key and the object's own braces.
So a snapshot this host accepted at exactly the budget, with
`truncatedByByteBudget` false because nothing had trimmed it, published over the
cap and the stream ended with `overflow` before a byte was painted.

Measured here on a screen sized to land exactly on round one's budget: the
published payload is 655,446 bytes against a 655,273-byte budget, 173 over, and
the frame it makes is over the 640 KiB cap by the same amount.

The metadata is now built by one function that `sendSnapshotFrames` and the
budget both call, and the budget stringifies the payload that function produces.
Nothing is summed and nothing is estimated, so a field added to the frame is paid
for by the budget the moment it is sent. Where a value is not yet known — the
truncation flags, and `seq` or `requestId` at a site that has not fixed them —
it is measured at the widest `JSON.stringify` can write it, which is a bound
rather than a guess, and forcing `seq` to a number also opens the three fields it
gates so those are counted too.

The budget therefore travels with the publication fields, because the payload
cannot be built without them.

On the page, the event envelope is now derived in one place in the protocol
module and read by both the snapshot budget and the shell's own merge budget, so
the two cannot drift; the page pins the number it sends and the host's cases name
that pin, since the two programs cannot import from each other.

The case that re-implemented the host's measure is gone: it could not have seen
this, because it was the same arithmetic twice.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): arm a held terminal's silence clock only while something is pending (OTA phase C, C7.3)

The invariant is "armed implies waiting on the page", and round one broke it in
the one direction that kills: an ack re-armed the clock and the drain that
followed emptied the queue without clearing it. A terminal that had delivered
every byte and gone quiet — which is what a terminal does between commands —
would die on `overflow` twenty seconds later.

The clock is now synchronised after every change to the queue, so it is armed
exactly while something is held. A rule that only ever arms is a rule that only
ever ends more streams.

Red-first: with round one's arming, an idle stream whose queue has drained still
reports its clock armed, and firing it ends a healthy terminal.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin the held-stream cases the rulings name (OTA phase C, C7.3)

Six cases nothing covered. Two subscriptions on one shell keep separate backlogs,
so a busy terminal cannot end a quiet one. A stream the page unsubscribed mid-
backlog posts nothing after, and neither does one that has already ended, however
much was still held. A payload that is not output breaks a merge run and keeps
its place, because a resize is state the reader applies in order. And the budget
boundary is checked on the side that enforces it: a payload at exactly the number
the page asks the desktop for is delivered inside the cap, and one the cap cannot
hold ends the stream under C0.3.

The replay no longer acks unconditionally in its catch-up loop. That was the page
behaving better than a page can — it acks on reading frames — and it is what hid
the silence clock left armed over an empty queue. The held-stream cases close the
window on its frame count rather than on four megabytes of string work.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): give the event-envelope derivation its own module (OTA phase C, C7.3)

`bridge-envelope.ts` is at its line cap and is the protocol's schemas; what a
frame costs around its payload is a derivation over them, and two budgets read
it — the snapshot the page asks the desktop for, and the output the shell merges.
One module, so they cannot drift and neither file is pushed over its limit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test: narrow the budget fixtures instead of asserting them

The changed-code casting gate refused six `as NonNullable<...>` in the new
budget cases, and it was right to: a fixture that serialized nothing is a broken
case rather than a null to assert away.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor: give the snapshot payload shape its own module (OTA phase C, C7.3)

`terminal-snapshot-publication.ts` crossed the root config's 300-line cap, which
mobile's own lint does not apply and CI does. The frame's shape and what it costs
a client reading it as one payload is a description the budget and the sender
both need, so it is the part that leaves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix: empty a snapshot the budget cannot fit instead of posting it over (OTA phase C, C7.3, ruling 15)

Both trimming loops published the zero-row candidate whatever it measured, and
zero scrollback is not a small screen: a wide colour-dense viewport still carries
its 24 live rows. A capped subscriber could get one frame over its cap, end the
stream on `overflow` and paint nothing — worse than a blank terminal, because a
blank one repaints on the next byte of output and a stream that never opened does
not reopen.

Ruling 15: a budgeted subscriber gets that frame with its text emptied and
`truncatedByByteBudget` true, never over and never refused. The raw rule keeps its
fallback, so an older page and every socket client are served exactly what they
were before. Below the metadata the frame must carry there is nothing left to give
up, and that boundary is pinned rather than claimed away.

The renderer loop is the same walk reached by a different caller and had no test
at all; its runtime parameter is narrowed to the two methods it reads so a case
can stub it without a cast.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): report what a held terminal stream did instead of calling it an outlived view

The backlog report had no branch in the reporter, so it fell through to the "a
view outlived its host" warn and every field it exists to carry was discarded.
The key made it worse: keyed by kind alone, one backlog per host was ever logged,
and a shell holds one stream per open terminal.

That report is the only oracle the coalescing rule has. Nothing crosses to the
page saying how much was held or how many frames its bytes arrived inside, and
both ways a held stream dies reach the page as `overflow`, because a reason its
reader has never heard of is a frame it drops. In production the two rules were
indistinguishable. They are now a line each, per stream.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test: give the renderer fixture the source its serializer returns

`serializeRendererTerminalBuffer` answers `renderer`, and vitest does not
typecheck, so the stub's `headless` passed every run and failed the node
typecheck instead.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix: budget the frame the publication actually sends (OTA phase C, C7.3)

The budget and the publication were written out twice, five lines apart, and had
drifted at every site: a budget for `{kind:'scrollback'}` approved a frame sent as
`kind:'resized'` with a `reason` beside it, and the live module budgeted
`pending-output-overflow` while sending `renderer-mount-ready`. It held only
because the padded `requestId` and `seq` are absent from those frames and more
than covered the difference. Each site now builds one object and hands it to
both.

`displayMode` cannot travel that way and was a third under-measure nobody had
named: the subscribe flow re-reads it from the runtime after the snapshot is
serialized and before the frame is sent, so no caller can tell the budget which
mode the publication will carry. It joins `seq`, `requestId` and the truncation
flags as a field taken at its widest. The mode list resolves the constant to
`never` if the runtime gains a mode it does not carry, so a new one is weighed
here rather than found on a phone.

Red-first needed a second attempt: the first fixture had trimming slack, so three
extra bytes fit and the probe could not see the defect it was written for. The
case now budgets a fixed screen at exactly its `auto` measure, where the margin
is the whole of the test.

One figure for the overshoot everywhere, with its basis: 169 bytes over the
655,360-byte cap on a frame carrying an 8-character request id, 247 with a
24-character one. Three places said 169 and one said 173.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): delete two backlog guards no input can reach

Both survived mutation because neither is reachable, and neither became
reachable when I tried to write a case for it.

`next` narrowed the merge ceiling to one frame, but its only caller,
`drainTerminalBacklog`, has already narrowed it: the parameter is what one
payload may occupy, not what the window holds, so the second narrowing could
never change the answer. The parameter now says so and the class no longer needs
the frame size at all. The bound still lives in the caller and is still covered:
removing it there reds a delivery case.

The merge run also compared stream ids, but a backlog belongs to one subscription
and every `data` payload on it carries that subscription's single stream id, so
the comparison could not fail. The run still stops at anything that is not
output, which is reachable and pinned.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): record the invariant the deleted stream-id guard rested on

The merge run compares no stream ids because it cannot need to: a backlog belongs
to one subscription and every `data` payload reaching it carries that
subscription's single stream id. Written down where the run is, because the thing
that would break it is a change made somewhere else — multiplexing two streams
onto one record would merge their output into one payload under the first id.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 10:19:41 -04:00
2026-05-04 20:42:03 -07:00
2026-03-16 22:27:51 -07:00
2026-03-28 10:19:14 -07:00

Orca Orca

GitHub stars Total downloads across all releases License: MIT Join the Orca Discord Follow Orca on X Supported platforms: macOS, Windows, and Linux

中文 · 日本語 · 한국어 · Español · Français · Português

The AI Orchestrator for 100x builders.
Run Codex, ClaudeCode, OpenCode or Pi side-by-side — each in its own worktree, tracked in one place.

Download Orca

Orca desktop app running agents in parallel worktrees, with the Orca mobile companion app in the corner

Features

Mobile Companion

Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.

iOS App Store · TestFlight · Android APK 0.0.50 · Docs →

Orca desktop with the mobile companion app

Parallel Worktrees

Fan one prompt across five agents, each in its own isolated git worktree — compare the results and merge the winner.

Docs →

Parallel worktree orchestration

Terminal Splits

Ghostty-class terminals with WebGL rendering, infinite splits, and scrollback that survives restarts.

Docs →

Terminal splits

Design Mode

Click any UI element in a real Chromium window to send its HTML, CSS, and a cropped screenshot straight into your agent's prompt.

Docs →

Embedded browser and Design Mode

GitHub & Linear, Native

Browse PRs, issues, and project boards in-app — open a worktree from any task and review without a context switch.

Docs →

GitHub and Linear task workflows in Orca

SSH Worktrees

Run agents on a beefy remote box with full file editing, git, and terminals — auto-reconnect and port forwarding included.

Docs →

Remote worktrees over SSH

Annotate AI Diffs

Drop comments on any diff line and ship them back to the agent — review, edit, and commit without leaving Orca.

Docs →

Annotate AI-generated diffs

Drag Files to Agents

VS Code's editor with autosave everywhere — drag files or images straight into an agent prompt.

Docs →

Drag files and images into an agent prompt

Orca CLI

Agents drive Orca too — script every workflow with orca worktree create, snapshot, click, and fill.

Docs →

Script Orca from the CLI

Also in the box:

  • Quick open — Search across worktrees, files, agents, commands, and repo context without leaving your flow.
  • Account switcher & usage tracking — See Claude and Codex usage and rate-limit resets, and hot-swap accounts without re-logging in.
  • Rich repo previews — Preview Markdown, images, PDFs, and repo docs in the workspace.
  • Computer Use — Let agents operate desktop apps and visible UI when a workflow needs real interaction.
  • Notifications and unread state — Know when an agent finishes or needs attention, then mark threads unread to come back later.
  • And many, many more — we ship daily, so this list is perpetually behind. The changelog is the real feature list.

Supported Agents

Works with any CLI agent — if it runs in a terminal, it runs in Orca.

Claude Code logo Claude Code   Codex logo Codex   Grok logo Grok   Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   OpenCode logo OpenCode   MiMo Code logo MiMo Code   Amp logo Amp   OpenClaude logo OpenClaude   Antigravity logo Antigravity   Pi logo Pi   oh-my-pi logo oh-my-pi   Hermes Agent logo Hermes Agent   Devin logo Devin   Goose logo Goose   Auggie logo Auggie   Autohand Code logo Autohand Code   Charm logo Charm   Cline logo Cline   Codebuff logo Codebuff   Command Code logo Command Code   Continue logo Continue   Droid logo Droid   Kilocode logo Kilocode   Kimi logo Kimi   Kiro logo Kiro   Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   Rovo Dev logo Rovo Dev   + any CLI agent


Install

Desktop — macOS, Windows, Linux

Or via a package manager:

# macOS (Homebrew)
brew install --cask stablyai/orca/orca

# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin

Mobile Companion — iOS, Android

Pair with your desktop app to monitor and steer your agents from your phone.


Community & Support

  • Discord: Join the community on Discord.

  • Twitter / X: Follow @orca_build for updates and announcements.

  • WeChat: Scan to join the Orca community WeChat group 8. Group 8 may be full; if so, scan the Group 9 QR code instead.

    WeChat group 8 QR code for the Orca community  WeChat group 9 QR code for the Orca community

  • Feedback & Ideas: We ship fast. Missing something? Request a new feature.

  • Privacy: See the privacy & telemetry docs for what anonymous usage data Orca collects and how to opt out.

  • Show Support: Star this repo to follow along with our daily ships.


Developing

Want to contribute or run locally? See our CONTRIBUTING.md guide.

The relay that pairs the mobile app with a desktop host is also in this repository under cloud/, with a separate pnpm workspace and setup guide.

Orca contributors

GitHub star history chart for stablyai/orca

Signed Builds

Windows code signing sponored/provided by SignPath.io, certificate by SignPath Foundation.

License

Orca is free and open source under the MIT License.

S
Description
Orca is the ADE for working with a fleet of parallel agents. Run any coding agent with your own subscription. Available on desktop, mobile and remote runtime.
Readme MIT
1.5 GiB
Languages
TypeScript 95.2%
JavaScript 4%
Swift 0.2%
CSS 0.2%
HCL 0.1%