* feat(mobile): add the media verb rows to the shell's native table (OTA phase C, C7.4)
`native.media.pick`, `native.media.readChunk` and `native.media.release` join
`BRIDGE_NATIVE_VERB_NAMES`, so each becomes a grant name `init.grants.native` can
carry and each gets a strict zod contract in `bridge-media-verbs.ts`. A chunk read
is held to the upload path's own budget, imported rather than restated: the leaf
module `mobile-clipboard-image-upload-chunk.ts` now declares
`MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS` so the bridge can read it
without pulling the upload path's RPC operations into every page closure.
Inert: no page calls the verbs and no route declares them, so an older page never
names them and an older shell refuses them as `unknown-verb`.
`native.clipboard.read`'s `{ mime: 'image' }` refusal is retired rather than left
pointing at nothing. The broad shape existed so a later build could serve an image
without a contract change; that build is `pick { source: 'clipboard' }`, which
stages the image behind a handle instead of inlining 24 MiB of base64 through an
8 MiB reply. The mime enum is text only, the handler's out-of-scope arm is gone,
and the refusal's test flips to the new answer: `invalid-params`, before dispatch.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): give the shell the staged-media handle registry (OTA phase C, C7.4)
A handle names a file this shell copied into its own cache, and such a file has no
owner otherwise: the page that asked for it is a document that can navigate, fault
or be swiped away without telling anyone. So the lifetime is bounded four ways and
all four are here — the page's own `release`, `MEDIA_HANDLE_TTL_MS`, and
`releaseAll`, which both the session's end and the page's unmount reach through
`useMediaHandleRegistry`. The TTL is measured from the last touch, not the mint, so
a page reading a large file one chunk at a time cannot have it swept out from under
it; its five minutes is sized for the page that picked and then stopped, not for
the read, which is 48 round trips on the largest item a pick may stage.
`BRIDGE_MEDIA_MAX_LIVE_HANDLES` caps what one session holds. A pick that would pass
it is refused whole and discards what it staged: half a multi-select is an answer a
page cannot tell from a user who picked fewer.
Three refusals join the seam's vocabulary. `native_media_handle_unknown` covers
never-minted, released and swept alike — which of the three it was is a fact about
another page's pick. `native_media_range` refuses a read at or past the end of an
item that had bytes, because the previous chunk already said `eof` and an empty
answer would let a page loop instead of failing where the bug is.
`native_media_handle_cap` is the cap above.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): serve the media verbs on the device, behind staged handles (OTA phase C, C7.4)
`pick` runs the OS permission prompt inside the shell — `expo-image-picker` for the
library, `expo-document-picker` for Files, the pasteboard for `clipboard` — stages
each item as a file this shell owns, and answers handles. `readChunk` reads the
byte range the registry hands it and base64-encodes it through the upload path's
own accumulator; `release` deletes the file.
The device calls live in `native-media-device.ts` and nothing else. Importing
`expo-image-picker` imports React Native, so a module naming it cannot be driven in
a unit test at all, and the arms worth pinning are exactly the ones a simulator
makes expensive: a denied permission, a cancel, an item over the staging ceiling.
`native-media.ts` takes those seven calls as dependencies and is tested whole.
Two refusals join the vocabulary, each because a page acts differently on it.
`native_media_permission_denied` is a permission the user can still grant in
Settings, not a library the shell could not read. `native_media_too_large` is an
item over `MEDIA_STAGED_MAX_BYTES`, weighed from the staged file rather than from
what the picker declared, since a picker's own size is optional on both platforms.
A pick refused that way discards every file it staged.
Each chunk is base64 on its own, so a page concatenates decoded bytes and never
strings: only the last chunk of a read ends on a partial group.
Measured: the largest reply this verb can produce is a full
`MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS` chunk, 524,427 bytes on the
wire with its envelope — one frame, 80.0% of the 640 KiB frame cap and 6.3% of the
8 MiB reply ceiling. Pinned in `native-media.test.ts`, so a cap or an envelope
field that moves shows up as a diff.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): only mint a media handle over a file this shell owns (OTA phase C, C7.4)
The Android arm of the same handler. Both pickers are configured to hand back a
copy in this app's own cache — `expo-image-picker` always copies, and
`expo-document-picker` does under `copyToCacheDirectory` — and the whole handle
contract rests on it: `release` is a delete, and so is the TTL sweep. A provider
that answered `content://media/...` instead would mint a handle over a file this
shell can neither size nor delete, and every sweep would be a silent no-op that
leaves the cache growing. `ownsStagedMediaUri` refuses that where the assumption
is made rather than letting it surface as a cache that never empties.
`native-media-device.test.ts` pins the options that make the assumption true: the
document picker's `copyToCacheDirectory`, the library picker's `base64: false` and
its selection limit, the pasteboard's png, and the cache file `stageBase64` writes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): move a picked image across the real pair, chunk by chunk (OTA phase C, C7.4)
Through `createFakeBridgePortPair` rather than the host alone, because a chunk is
the one reply this seam produces that is within a rounding error of the frame cap:
a case that called the server directly would never serialize one, and serializing
is where a reply that fits and a reply that splits part ways.
A granted page picks an 18 MiB item, reads all 48 chunks in order to `eof` and
releases it; the decoded length is the file's own and both ends are the fixture's,
which a reader that dropped or reordered a chunk would not have. Nothing reaches
the desktop client. A page granted only `navigate` and `storage` is refused
`native_verb_ungranted` with no picker run — the refusal is the host's, because
the page side of these verbs is C7.6's. A released handle, a swept handle, a read
past the end and a pick over the live-handle cap each come back under their own
code.
The pair's default verb handler answered a clipboard shape for every verb, so a
media call through an unconfigured pair came back as `native_verb_result` — a
shell bug's code for a harness that was never told about the verb. It now answers
one shape per row of the table.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): drop the four type assertions the changed-code gate found (OTA phase C, C7.4)
`new Array<T>()` for the two hoisted mock ledgers, and the probe holds its handler
in a record it null-checks rather than asserting one that a render might not have
produced.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): rename the chunk verb to native.media.read, which a manifest may name (OTA phase C, C7.4)
`native.media.readChunk` is not a legal grant name. `GRANT_NAME_PATTERN` holds every
`native.` segment to `[a-z][a-z0-9]*`, so a route declaring it fails
`MobileWebBundleRouteSchema` — and `bundled-mobile-web-bundle.ts` parses the manifest
whole, so the camel-cased segment is not a route that degrades to its native screen.
It is a bundle the phone rejects entire. Params are unchanged.
The test that would have caught it now reads the schema itself rather than a copy of
its pattern: every entry of `MOBILE_WEB_SHELL_GRANTS`, and every name in
`BRIDGE_NATIVE_VERB_NAMES`, parsed as the grants of a manifest route. Both lists,
because the spread is what makes them agree today and a build that stopped spreading
would leave this the only thing that noticed. Red on the old name with the exact
message: `native.media.readChunk: expected false to be true`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): exercise a short read, which nothing separated from the chunk cap (OTA phase C, C7.4)
Every other read in the suite asks for a whole chunk of a file shorter than one, so
a handler that ignored `length` and read to the end passed all of them: the fake
file clamps at its own size. Replacing the range with the cap left 74/74 green.
A 16-byte read at offset 400 of a 1000-byte item now pins the length, the offset and
`eof: false`. Under that same mutation it reds with `expected 600 to be 16`, and it
is the only case that does.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): kill the four device arms that survived mutation (OTA phase C, C7.4)
Each was green with the arm removed; each now reds with it removed and nothing else
does.
The data-url strip: `getImageAsync` answers `data:image/png;base64,...`, which is
what an `<Image>` source wants and not what a file wants. Staged unstripped, every
byte is shifted by the prefix and the page decodes a corrupt image with no error
anywhere. The old fixture had no prefix at all.
The cancel flag: both pickers answer `assets: null` beside `canceled: true` today,
so a handler keyed on the list alone passed every fixture here. The new case sends
the flag with a populated list, which is what a picker version that changed its mind
would send.
The unknown mime: an empty string is not a mime the result schema takes, so the
alternative to the floor is `native_verb_result` — a shell bug's code for a document
picker doing what it may do.
The handle close: a ledger per opened handle, asserted on the way out and on the way
through a reader that throws. A file handle a shell leaks is invisible on a fake and
a file descriptor on a phone.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the stated constants as literals (OTA phase C, C7.4)
The TTL, the live-handle cap, the handle and mime lengths, and the 48 chunks an
18 MiB item reads in. Literals, not the constants restated: every one is a number a
body claims and a reviewer checked, and read through its own name the assertion
would hold whatever it became — which is what the 524,427-byte reply pin already
does for the frame it measures.
Each reds when its number moves: 8 to 12, 64 to 32, 128 to 256, five minutes to ten.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): name the fixture by its size, assert the clipboard refusal, reach the clamp (OTA phase C, C7.4)
Three minors from round 1.
The case that said "18 MiB" staged a million bytes. Renamed to what it stages, with
a line saying the largest item a pick may hold runs in the port-pair suite, where
the frames are serialized and the size is the one that matters.
The clipboard image case had been weakened to a bare `.rejects.toThrow()`, which
would pass for a handler that reached the pasteboard and failed there — the one
outcome it exists to rule out. It now asserts a `ZodError` naming the `mime` path,
and reds when the parse is taken out.
The registry's chunk clamp was unreachable behind the params schema. Kept and
reached rather than deleted: the two bounds are different promises, one saying what
a page may ask for and one saying what the registry will hand any reader, and
`read` is a public method its own suite already calls directly. A case asks for
three chunks at once and gets one; it reds when the clamp goes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): copy a provider uri into the cache instead of refusing the pick (OTA phase C, C7.4)
`MediaHandler.readExtras` in expo-image-picker 55.0.24 has a reachable arm: when
`toMediaType` cannot resolve a MIME it answers
`ImagePickerAsset(type = null, uri = uri.toString())`, the provider's own
`content://` uri, uncopied. The OS completed that pick, so refusing it as
`native_verb_failed` loses a photo the user chose — and the comment above the guard
claimed both pickers "always" answer `file:`, which the same file disproves.
The pick now copies what it does not own into the shell's cache and mints the handle
over the copy, which is what the body already claimed. The guard stays for anything
the copy could not adopt, and that arm still fails closed: a sweep that can never
delete anything must not look like one that did. The failure path discards what this
shell owns rather than the uris the picker answered, since a provider's uri was
never ours to unlink.
Copied through `bytes()` rather than `copy()`: `FileSystemPath.copy` goes to
`javaFile.copyRecursively`, a `java.io.File` with nothing to open for a provider uri,
while the read path goes through the unified file and does. A source that cannot be
read takes the empty destination with it, because the caller never learns that name.
Red-first: a fixture answering `content://media/external/images/media/42` asserting a
handle over a `file:` copy, its 300 bytes read back whole, and the copy being what
`release` deletes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): bound a multi-select to the room the registry has left (OTA phase C, C7.4)
`selectionLimit: 0` means unlimited to the OS picker, and `mint` then refuses the
whole pick past `BRIDGE_MEDIA_MAX_LIVE_HANDLES`. A user who chose nine photos waited
through the OS copying every one of them into the cache to be told none were taken.
`pick` now reads `remainingCapacity()` before it launches anything: an empty room is
`native_media_handle_cap` up front, for every source, with no picker run and no byte
copied. What room is left becomes the picker's `selectionLimit`, so the selection
cannot exceed what the registry will accept and the refusal after the fact is only
reachable by a page that never released what it holds.
`getDocumentAsync` takes no selection limit, so for Files the room is the up-front
refusal only; `mint` remains the bound that cannot be skipped.
Each pin reds on its own mutation: dropping the up-front refusal, putting
`selectionLimit` back to 0, and fixing the limit at the cap instead of the room.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): ask for the library permission on iOS only (OTA phase C, C7.4)
Read from expo-image-picker 55.0.24, not from the docs. Neither platform's
`launchImageLibraryAsync` gates on a permission: `launchCameraAsync` calls
`ensureCameraPermissionsAreGranted` on Android and `hasGrantedPermission` on iOS, and
the library arm goes straight to its contract on both. On Android
`getMediaLibraryPermissions` answers an empty array from API 33, so the request
prompts nothing and always resolves granted; below 33 it asks for
`READ_EXTERNAL_STORAGE` and `WRITE_EXTERNAL_STORAGE`, which the system picker never
reads, and a denial there reached `native_media_permission_denied` for a pick the OS
would have completed. Android no longer asks.
iOS still does, because the prompt inside `pick` is what ruling 6 asked the shell to
own and the photo-library dialog is a real thing a user sees there.
One finding for the lead rather than a silent change: with `allowsEditing: false`
the iOS path is `launchMultiSelectPicker`, a `PHPickerViewController`, which also
opens without authorization. So the iOS request is the ruling's and not the SDK's,
and a denial refuses a pick that would have worked. Dropping it is a ruling-6
amendment, not mine to make.
Both arms pinned and both red under their mutations: asking everywhere fails three
cases, asking nowhere fails two.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): mock react-native in the device-verb hook suite (OTA phase C, C7.4)
The hook reaches `native-media-device.ts`, which now imports `Platform`, and reaching
the real module imports React Native — which this suite has no runtime for, so it
failed to load and ran zero tests while the run still reported every other file
passing. The device half's own platform arms are `native-media-device.test.ts`; here
the OS only has to be one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): ask for no library permission on either platform (OTA phase C, C7.4)
Ruling 6b. `launchImageLibraryAsync` gates on nothing in expo-image-picker 55.0.24:
Android goes straight to its contract and iOS to `launchImagePicker(.photoLibrary)`,
and only the camera arm checks a permission. The prompt "inside the shell" that
ruling 6 asked for is the OS picker's own, so `pick { source: 'library' }` adds
none. `requestLibraryPermission` is gone from the deps, from the device calls and
from `pickFrom`, and with it the last way a denial could refuse a pick the OS would
have completed — Android below API 33 for storage permissions the picker never
reads, iOS for a `PHPickerViewController` that opens without authorization.
`native_media_permission_denied` stays in the refusal vocabulary, documented for the
first source that needs one rather than for a producer this build has.
Red-first, and driven through the whole verb rather than through `launchLibrary`
alone, since the request was `pick`'s and a case that only called the picker would
have passed either way: with the request restored, the pick on iOS calls the
permission API once and the deps still carry the member, and both cases red.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): sweep before release, and name one basis per ratio (OTA phase C, C7.4)
Two lows from round 2.
`release` was the only lifetime path that did not sweep first, so an expired handle
was still in the map when it looked and the page was told `released: true` for a file
the next sweep would have taken anyway — the opposite of what
`mediaReleaseParamsSchema`'s docstring promises it. It sweeps now, like every other
path that reads the map. Red-first with the reviewer's own probe: expired, then
released, answered true. The exact boundary is pinned while we are here, because the
sweep's comparison is inclusive and nothing said so: at exactly `MEDIA_HANDLE_TTL_MS`
the handle is gone, one millisecond earlier it is not.
The raw ceiling was called "three times the reply ceiling" in two places.
`CLIPBOARD_IMAGE_MAX_SOURCE_BYTES` is 18,874,368 against 8,388,608, which is 2.25;
three is the ratio after base64 expands it. Both sentences now name which basis they
are on, and say that the two differ.
Each pin reds on its own mutation: dropping the sweep from `release`, and loosening
the sweep's comparison to exclusive.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): never sweep a uri this shell does not own, and weigh before copying (OTA phase C, C7.4)
Round 3's five items.
(1) `owned.push(uri)` ran before the ownership guard, so a copy that could not be
adopted sent the provider's own `content://` uri to the failure path's discard — the
one thing the array's docstring says it never holds. The push now happens below the
throw, inside a named `adopt` that either hands back a uri this shell owns or throws
having claimed nothing. Red-first: the refusal case discarded exactly
`["content://media/external/images/media/42"]`.
(2) The copy's docstring claimed the in-memory read was bounded by a ceiling checked
"right after", which ran after the whole `bytesSync()`. The source is now weighed
before the copy, which is real: `FileSystemFile.size` routes a `content:` uri to
`SAFDocumentFile.length()`. A provider reporting nothing answers 0 there, so the
docstring also says what is true for that case — the read is bounded only by
`mediaTypes: ['images']`, and the copy is weighed after the fact instead. The copy's
own size stays authoritative for the handle.
(3) Both `react-native` mocks went inert when `324c4093be` dropped `Platform`. Gone,
along with the two-platform loop that had become one code path run twice; the 6b
guard now reads "never calls `requestMediaLibraryPermissionsAsync`", which is what it
was checking.
(4) The hook case no longer claims a permission prompt.
(5) The 150-character comment is wrapped, along with the three other comments over
100 that I had authored in this file and the two platform modules.
Each behavioural pin reds on its own mutation: pushing to `owned` above the throw,
and dropping the pre-copy ceiling check.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): refuse what cannot be weighed, and count a Files pick before staging it (OTA phase C, C7.4)
(a) The pre-copy weigh is now a mechanism rather than a docstring, and the branch it
takes is the one the SDK allows. Verified in expo-file-system 55.0.26: a `content:`
uri reads its size through `SAFDocumentFile.length()`, so the common case is weighed
before `bytesSync()` materializes anything. There is no bounded read to fall back on
for the rest — `File.open()`, `readableStream()` and `writableStream()` all reach
`FileSystemPath.javaFile`, which throws `This method cannot be used with content
URIs` outright, leaving `bytesSync()` as the only read a provider uri has and it is
all or nothing. So an item whose provider reports no size, which answers 0 and is
indistinguishable from an empty file, is refused rather than read at an unknown
size. An empty pick was nothing to stage either way.
(b) `getDocumentAsync` takes no selection limit, so the room `pick` hands it was
advisory and a user could return more than the registry holds. Counted before
staging: previously all nine assets were copied into the cache and `mint` refused the
lot, which the red run showed as nine discards. The refusal is
`native_media_handle_cap` and it sweeps nothing, because nothing of this shell's
existed yet.
Three pins, each red on its own mutation: dropping the unweighable refusal, making
the room check a no-op, and taking it off the Files arm.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): drop the last two traces of the library permission prompt from the media tests
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Orca
中文 · 日本語 · 한국어 · 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
Features
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
Codex
Grok
Cursor
GitHub Copilot
OpenCode
MiMo Code
Amp
OpenClaude
Antigravity
Pi
oh-my-pi
Hermes Agent
Devin
Goose
Auggie
Autohand Code
Charm
Cline
Codebuff
Command Code
Continue
Droid
Kilocode
Kimi
Kiro
Mistral Vibe
Qwen Code
Rovo Dev
+ any CLI agent
Install
Desktop — macOS, Windows, Linux
- Download from onOrca.dev
- Or grab a build directly: macOS Apple Silicon · macOS Intel · Windows (.exe) · Linux AppImage · All builds
- Running
orca serveon a headless Linux server? See the headless Linux server guide.
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.
- iOS: Download on the App Store or join TestFlight
- Android: Download APK 0.0.50 · Install guide
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.
-
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.
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.












