Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8891435
feat(stream): a capture tool that streams one simulator's screen over…
V3RON Oct 14, 2026
ec45e94
feat(stream): a lease's screen, streamed to whoever is looking at it
V3RON Oct 14, 2026
639d699
chore(vendor): one home for third-party code, with its licensing prov…
V3RON Oct 15, 2026
bfc2e14
feat(core): a driver may offer its devices' screens, in one shape
V3RON Oct 15, 2026
8a74000
refactor(stream): the stream module names no platform; each driver ca…
V3RON Oct 15, 2026
9922b35
chore(vendor): correct the licensing of what the scrcpy server and th…
V3RON Oct 15, 2026
26c613b
fix(gateway): a relayed body is bounded by silence, not by the 330 s …
V3RON Oct 15, 2026
1150c44
docs(stream): the docs say what streams on each platform and where th…
V3RON Oct 15, 2026
67c95b7
fix(ui): the Screen pane names physical phones as what cannot stream,…
V3RON Oct 15, 2026
f4cf988
feat(android): stream an emulator's screen through our own scrcpy client
V3RON Oct 15, 2026
713f43a
feat(android): a still screen stays a live stream, and the daemon wir…
V3RON Oct 15, 2026
c0c8674
test(stream): a slow-lane test streams a real simulator and proves bo…
V3RON Oct 15, 2026
707064f
fix(stream): a capture that finishes opening after its viewer left is…
V3RON Oct 15, 2026
9728bb7
fix(ui): the Screen pane's rate and bandwidth are the last two second…
V3RON Oct 15, 2026
2304a98
refactor(stream): what Fallow found before the push
V3RON Oct 15, 2026
22b7af7
refactor(ui): the Screen pane's reader and pane fall under Fallow's c…
V3RON Oct 15, 2026
e52d7b6
test(console): the Screen spec's colour average passes the e2e typecheck
V3RON Oct 15, 2026
d9e894d
fix(stream): a gateway answers an unreachable worker, or one with no …
V3RON Oct 15, 2026
0bd8266
fix(stream): a lease that ends mid-open waits for the device to be le…
V3RON Oct 15, 2026
2c9d074
refactor(android): the screen session reads its first size in its own…
V3RON Oct 15, 2026
ae858d4
docs(android): the manual says what the live check proved about stopp…
V3RON Oct 15, 2026
6d33cb8
fix(gateway): the relay's idle bound runs on the Clock port; the stre…
V3RON Oct 15, 2026
e30c087
fix(stream): a viewer that leaves mid-open keeps its slot until the o…
V3RON Oct 15, 2026
d4f4c33
docs(android): the screen client gets its own manual, and every manua…
V3RON Oct 15, 2026
f68c1c5
test(stream): a viewer leaving a gateway and a still screen are prove…
V3RON Oct 15, 2026
9358191
test(console): the screen pane's figures are read by splitting out ta…
V3RON Oct 15, 2026
12943e4
fix(stream): the console waits for a stream as long as a worker does;…
V3RON Oct 15, 2026
3d38786
fix(stream): Stop works while a stream is starting; review round 2's …
V3RON Oct 15, 2026
c561347
merge main: the stream becomes an optional module on ADR 0030's contract
V3RON Oct 15, 2026
239ad19
fix(vendor): the package ships each vendored artifact with its NOTICE…
V3RON Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions .dependency-cruiser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ export default {
),
direction(
"no-fs-or-process",
"core, leasing, agent-device and host-load reach the filesystem and processes only through injected ports",
"^src/(core|leasing|agent-device|host-load)/",
"core, leasing, agent-device, host-load and stream reach the filesystem and processes only through injected ports",
"^src/(core|leasing|agent-device|host-load|stream)/",
{ dependencyTypes: ["core"], path: fsOrProcess },
),
direction(
Expand All @@ -113,6 +113,18 @@ export default {
"^src/(core|leasing|drivers)/",
{ path: "^src/agent-device/" },
),
direction(
"not-stream",
"core, leasing, the drivers, the gateway and the contract import nothing from the stream module",
"^src/(core|leasing|drivers|gateway|contract)/",
{ path: "^src/stream/" },
),
direction(
"stream-not-edges",
"the stream module is a module the host loads: it imports no frontend, gateway, driver or leasing",
"^src/stream/",
{ path: "^src/(http|gateway|drivers|daemon|cli|mcp|leasing)/" },
),
direction(
"host-load-imports-ports-only",
"host-load reads the machine through the HostLoadProbe port and imports no other component",
Expand Down
5 changes: 4 additions & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
// them (budget-guard, comment-lint) import from their neighbours (usage, comments).
// .dependency-cruiser.cycles.mjs is named only by src/lint-rules.test.ts, which runs depcruise
// with it (ADR 0027).
// vendor/screen-capture/scripts/unframe.mjs is a diagnostic a person runs by hand against a
// captured file, the way the .agents scripts are.
"entry": [
"src/cli/main.ts",
"src/daemon/main.ts",
Expand All @@ -20,7 +22,8 @@
"e2e/console/*.spec.ts",
"vitest.mutate.config.ts",
".agents/scripts/*.mjs",
".dependency-cruiser.cycles.mjs"
".dependency-cruiser.cycles.mjs",
"vendor/screen-capture/scripts/*.mjs"
],
"dynamicallyLoaded": ["e2e/fake-driver/index.ts"],
// The web console's libraries are bundled into dist/ui at build time and ship as files, so
Expand Down
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ changes in this repo:
agents run and the checks they leave to hooks and CI; the slow lane
(real devices); project-only checks. Agents and skills take every command
from here.
- [vendor-and-third-party-code.md](docs/internal/agent-rules/vendor-and-third-party-code.md)
— third-party code lives under `vendor/`, one directory per upstream, each
with its license text and a `NOTICE.md`; source vendoring records the commit,
the deviations and how to diff against upstream; binaries are vendored
byte-identical with a checksum; nothing is fetched at runtime.
- [always-in-scope.md](docs/internal/agent-rules/always-in-scope.md) — what
every change includes without its spec asking (docs and strings it makes
false, both `EVENTS.md` files, a test for every new path, Fallow entries),
Expand Down Expand Up @@ -96,4 +101,6 @@ Maintainer/agent docs live under [docs/internal/](docs/internal/):
spec session writes, and the module manual template
- [EVENTS.md](docs/internal/EVENTS.md) — the same catalog with rationale and ADR references
- [IDEAS.md](docs/internal/IDEAS.md) — post-v1 ideas; don't implement these unless asked
- [VENDORED-CODE.md](docs/internal/VENDORED-CODE.md) — every third-party artifact in the
repo: upstream, version, license, where it lives, who consumes it, how to re-verify
- [adr/](docs/internal/adr/) — architecture decision records and their status
51 changes: 49 additions & 2 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ edit yourself keeps the mode you give it until the next `config set`; run
| `capabilities.agent-device.enabled` | **Both sides.** On a worker: run one agent-device daemon on this worker, started and watched by Simlock. It sees Simlock's devices and no others, refuses `boot` and `shutdown`, and keeps its files in `~/.simlock/agent-device`. If agent-device is missing, older than 0.21.28, speaks another RPC protocol or needs a newer Node than Simlock runs on, it is not started and Simlock runs on without it. A daemon a killed Simlock left behind is stopped at the next start with `capabilities.agent-device.enabled` `true`; with it off, Simlock does not look for one. With `http.enabled` also `true`, the agent-device CLI reaches it at `/v1/agent-device` (see [HTTP-API.md](HTTP-API.md#v1agent-device)). On a gateway: serve `/v1/agent-device` and send each agent to a worker whose agent-device is available; the gateway runs no daemon of its own. | `false` |
| `capabilities.agent-device.leaseTtlCapMs` | **Both sides.** Caps the TTL of the leases the agent-device CLI makes and renews through Simlock: a longer `ttlMs` is lowered to it, and a lease that sends none gets it. A whole number of milliseconds from `5000` to `lease.maxTtlMs`; a value outside that range stops the daemon at start, on a gateway too. Used only when `capabilities.agent-device.enabled` is `true`. | unset (no cap) |
| `capabilities.agent-device.command` | **Worker side.** The agent-device executable: a name looked up on `PATH`, or a path. A gateway ignores it and logs a warning. Used only when `capabilities.agent-device.enabled` is `true`. | `agent-device` |
| `capabilities.stream.enabled` | **Both sides.** Offer a live view of a leased simulator's or emulator's screen. On a worker: answer `GET /v1/stream/leases/{leaseId}` (see [HTTP-API.md](HTTP-API.md#get-v1streamleasesleaseid)) by capturing the screen of the device that lease holds. On a gateway: serve the same URL and relay each request to the worker that holds the lease. Off by default because a stream is a capture pinned to a leased device — with it off, nothing is running until someone asks to look, and nothing is running when they stop. Read at start; changing it needs a restart. | `false` |
| `capabilities.stream.fps` | **Worker side.** The frame rate the encoder is asked for. A capture sends full frames only when the device's screen changes, and on a still screen only small keep-alive frames, so this is a ceiling and not a promise: an untouched home screen stays far below it, which is what keeps an idle stream cheap. A whole number from `1` to `60`. | `30` |
| `capabilities.stream.maxDimension` | **Worker side.** Cap on the encoded picture's longest side, in pixels; the other side follows the device's aspect ratio. This is the lever that costs the most bandwidth — a device that encodes at about 6 Mbit/s native is nearer 1.4 Mbit/s at `720`. A whole number from `96` to `4096`. | `720` |
| `capabilities.stream.bitrateKbps` | **Worker side.** Target bitrate in kilobits per second, handed to the encoder as an average rather than a ceiling; a frame that needs more takes it. A whole number from `100` to `40000`; the iOS encoder takes nothing lower. | `1500` |
| `exec.timeoutMs` | **Worker side.** How long one `device.exec` command (`simlock simctl` / `simlock adb` against a gateway or over HTTP) may run before the worker kills it and the operation fails with `EXEC_TIMEOUT`. Authoritative: it bounds the process that actually runs. | `10 minutes` |
| `gateway.routing` | **Gateway side.** Which routing policy places a queued request on a worker. `warm-then-free` is the only policy in v1. Among the workers that can serve the request, in order: a worker whose health is not `running`, or that has requests of its own waiting, is left out; a worker holding a ready device that fits goes first (the device's mode must fit the request, a request with no mode fits a device that serves the worker's default mode, and its runtime must be the one requested or, with none requested, the one the worker would pick: the newest runtime the model pairs with); otherwise a worker with no free running slot for the platform or overall is left out (a running device that is not leased counts as free: the worker shuts it down to make room), a worker under its RAM budget for the platform is preferred over one at it (a worker at its budget is still asked when it is the only one left), and the one with the most free running capacity for the platform wins. A worker that is left out is busy, not unable: the request waits for it. Ties go to the lowest worker id. | `warm-then-free` |
| `gateway.disconnectedRetentionMs` | **Gateway side.** How long a disconnected worker is kept (greyed, never dispatched to) before the gateway forgets it. The clock is held while the gateway still knows of gateway-issued leases on that worker, and that hold ends when the last of those leases passes its deadline. | `24 hours` |
Expand Down Expand Up @@ -73,6 +77,7 @@ edit yourself keeps the mode you give it until the next `config set`; run
| `stalledTransition.thresholdMultiplier` | Factor applied to a driver's own `provision + boot` (for `provisioning`) or `reclaim` (for `reclaiming`) estimate to get the stall threshold: past it, `simlock doctor` reports a `stalled-transition` finding and `simlock status` marks the device `stalled`. | `3` |
| `stalledTransition.minimumThresholdMs` | Floor under the multiplied estimate, for a driver whose estimate is near zero. | `1 minute` |
| `drivers.ios.deviceRoot` | The CoreSimulator device set Simlock owns and scopes every `simctl` call to. See [Device roots](#device-roots). | `${SIMLOCK_HOME}/devices/ios` |
| `drivers.ios.screenCaptureCommand` | The program that captures a simulator's screen for `capabilities.stream.enabled`. Unset, the one `pnpm build` put beside the daemon. | unset |
| `drivers.android.deviceRoot` | The AVD home Simlock owns; exported as `ANDROID_AVD_HOME` to every `avdmanager`/`emulator` call. See [Device roots](#device-roots). | `${SIMLOCK_HOME}/devices/android` |
| `drivers.android.adbServerPort` | TCP port for Simlock's own adb server. Must not be the shared server's `5037`. Startup fails closed if it is occupied. | `5038` |
| `ios.defaultMode` | The device mode an iOS lease request gets when it names none: `slim` or `full`. Every worker makes both kinds whatever this says. See [Device mode: slim and full](#device-mode-slim-and-full). | `full` |
Expand Down Expand Up @@ -166,8 +171,9 @@ off by default.
capacity, drivers, downloads, idle, warmPool, health, ios, android, stalledTransition —
configures devices, which a gateway does not have; each one present in a
gateway's config is reported with a warning and ignored, the same treatment an
unknown key gets. The worker-side `gateway.url`/`token`/`label` and
`capabilities.agent-device.command` are warned about there too. Nothing is stripped: the same file can be copied between a
unknown key gets. The worker-side `gateway.url`/`token`/`label`,
`capabilities.agent-device.command` and `capabilities.stream.fps`/`maxDimension`/`bitrateKbps`
are warned about there too. Nothing is stripped: the same file can be copied between a
worker and a gateway with only `mode` differing, and the warning is what makes
the difference visible rather than silent.

Expand Down Expand Up @@ -568,6 +574,47 @@ Only these four keys exist. There is no way to pass other emulator
arguments from config, so nothing here can move an emulator's port or AVD
home.

## Screen streaming

`capabilities.stream.enabled` turns a leased device's screen into something you can look at:
a worker captures the screen of the device a lease holds, encodes it as
H.264, and the bytes reach a browser as the body of one ordinary
`GET /v1/stream/leases/{leaseId}` (see
[HTTP-API.md](HTTP-API.md#get-v1streamleasesleaseid)). On a gateway the same URL
relays the request to the worker that holds the lease, and the gateway forwards
the bytes without reading one of them.

Nothing is captured until someone asks to look, and asking is the only
on-command there is: the request itself keeps the capture running, and closing
it — pressing Stop, navigating away, closing the tab — is the off-command. A
lease that ends by any other route stops the capture too, so a device never
goes on being filmed after it has been handed back, and no stream outlives a
viewer or a lease.

What it costs is mostly `capabilities.stream.maxDimension` and `capabilities.stream.bitrateKbps`. Against
a device whose screen is being actively redrawn on an Apple silicon Mac, the
encoder's native settings produce about 6 Mbit/s; at `maxDimension` `720` and
`bitrateKbps` `1500` the same motion is about 1.4 Mbit/s, and a device nobody is
touching sits between 25 and 90 kbit/s because a screen that has not changed
sends almost nothing. `capabilities.stream.fps` is a ceiling rather than a rate for the same
reason. On a Mac an iOS capture costs about 50-80 MB of memory per device being
watched.

An iOS simulator's screen and an Android emulator's screen can be streamed. A
lease on a physical iPhone or Android phone is refused with
`STREAM_NOT_SUPPORTED`, naming that as the reason rather than failing for one
the reader has to guess.

- **iOS** needs the capture program `pnpm build` builds on a Mac with Xcode
selected. Without it an iOS stream is refused with
`STREAM_UNAVAILABLE`, saying why; `simlock status` says why too when no
platform on the worker can stream. `drivers.ios.screenCaptureCommand` names a
program at another path.
- **Android** needs nothing more than the SDK the emulators already use. Each
stream copies a small screen server into the emulator, and removes it when
the stream ends. An emulator encodes in software unless its GPU mode gives it
a hardware encoder, so expect about 20 frames a second while the screen moves.

## Capacity strategies

How many devices Simlock lets exist and run at once is decided by a capacity
Expand Down
23 changes: 23 additions & 0 deletions docs/CONSOLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,29 @@ and the id of the request that was granted the lease, while the daemon still
keeps it. A lease that has been released or has
expired says "Lease not found".

### Watching a lease's screen

A lease's page ends with a **Screen** panel: one button, **Show screen**, and
while it is on, the device's picture with the facts beside it — its size and
codec, the frames painted, the rate they arrive at, the bandwidth they cost, and
how many were dropped rather than drawn behind an earlier one.

Nothing runs while it is off, and the button is the only control. Pressing
**Stop**, navigating away, closing the tab and signing out all stop the capture
on the worker the same way, and a lease that ends — released, expired, or lost
its device — ends the picture with it rather than leaving a frame that looks
live on the page. What you are looking at is either moving or gone.

The panel is on every lease's page, and streams wherever the worker the lease
runs on has screen streaming turned on (see [`capabilities.stream.enabled`](CONFIGURATION.md)).
Where it is off, the panel says so; where the lease is on a device a screen cannot be
captured from — a physical iPhone or Android phone — it says that a live screen
comes only from a simulator or an emulator. Either way it starts nothing.

The first picture can take a while on a slow emulator. The panel waits up to 90
seconds for it, longer than a worker takes to give up, so a capture that cannot
start shows the worker's reason rather than a timeout.

### Who holds a lease

A lease taken over the HTTP API belongs to the token that took it. The console
Expand Down
3 changes: 3 additions & 0 deletions docs/EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ authorizes it already emitted `lease.granted`, the device's own state is
untouched, and a fleet where every `adb shell` command produced a bus event
would push everything else out of the ring buffer within minutes.

**A screen stream emits no event either.** Watching a device's screen changes
no lease and no device; the daemon log records each stream opened and refused.

**Every worker's own business events are republished on the gateway's bus**
with `workerId` added to the payload, under their original names — the fact
happened in that worker's lease handling or reaper, and rewriting either would
Expand Down
Loading
Loading