Repository navigation
One Simlock instance per host #700
Description
Request: none
Problem
Today every SIMLOCK_HOME is a separate Simlock. A user can run several workers on one machine, each with its own registry, devices and adb server. None of them can see the others:
component removecounts only its own devices, so it can remove a runtime another worker still uses.- Two workers can enrol the same physical device, and each release uninstalls the apps the other installed.
- A console port one worker handed out but has not booted yet reads free to another.
iOS runtimes belong to the whole machine (CoreSimulator keeps one copy in /Library/Developer/CoreSimulator), so no per-folder setup can isolate them. The planned disk lifecycle feature (garbage collection of runtimes, system images and devices with a disk budget) needs one owner of what Simlock installs.
The gateway and the worker are told apart by a mode config key, and running both on one Mac needs two SIMLOCK_HOMEs.
Who it is for
A developer or CI operator who runs a Simlock worker for coding agents on a machine, and sometimes a gateway in front of several workers.
Outcome
- A user runs one worker, always in
~/.simlock. A second worker for the same user cannot start. - A gateway always lives in
~/.simlock/gateway. The folder decides what a daemon is; there is nomodekey. - On a machine with one daemon, every command reaches it with no flag. On a machine with both, plain commands reach the worker and
--gatewayreaches the gateway.--localalways reaches the worker, which is how a worker is added to a machine that got its gateway first. - A worker's HTTP listens on 4701 by default and a gateway's on 4700, so both fit on one machine with defaults.
How it works for the user
flowchart TD
A["simlock <command>"] --> B{--gateway or --local?}
B -- "--gateway" --> G["gateway in ~/.simlock/gateway"]
B -- "--local" --> W
B -- no --> C{"a gateway is present<br/>and ~/.simlock/instance.json does not exist?"}
C -- yes --> G
C -- no --> W["worker in ~/.simlock<br/>(started if not running)"]
W --> D{"worker refuses: only a gateway does this<br/>and a gateway is present?"}
D -- yes --> E["same error, plus a hint to add --gateway"]
A gateway and a worker on one Mac, before and after:
# today
SIMLOCK_HOME=~/.simlock-gw simlock config set mode gateway
SIMLOCK_HOME=~/.simlock-gw simlock daemon start
SIMLOCK_HOME=~/.simlock-gw simlock token create --role worker
# after
simlock --gateway daemon start
simlock --gateway token create --role worker
simlock worker drain 3f2a…
{"error":{"code":"UNSUPPORTED_IN_WORKER_MODE","message":"worker.drain acts on a gateway's workers, and this daemon is not a gateway. A gateway also runs on this machine: run the command again with --gateway."}}
A machine that runs only a gateway:
simlock --gateway config set http.port 4800
simlock daemon start # no flag: only a gateway lives here, so this starts it
Daemon running
Console: http://127.0.0.1:4800/
simlock status --json | jq .daemon.mode
"gateway"
The old key, after:
simlock config set mode gateway
{"error":{"code":"USAGE","message":"Invalid config after setting mode: \"mode\" is no longer a config key. A daemon started with --gateway is a gateway; any other is a worker. Remove \"mode\" from config.json."}}
Examples
Which daemon a command reaches, with no --gateway flag:
~/.simlock/instance.json |
gateway present (~/.simlock/gateway/instance.json or config.json) |
Today | After |
|---|---|---|---|
| no | no | worker in ~/.simlock |
worker in ~/.simlock |
| yes | no | worker | worker |
| no | yes | worker (the folder means nothing) | gateway |
| yes | yes | worker | worker; a gateway-only command adds the hint |
| no | no, but ~/.simlock/gateway/ exists empty |
worker | worker |
With --gateway:
| Command | Gateway present | After |
|---|---|---|
simlock --gateway daemon start |
no | creates and starts the gateway in ~/.simlock/gateway |
simlock --gateway daemon start |
yes | starts it, or answers "Daemon running" |
simlock --gateway status (any command that would start a daemon) |
yes, not running | starts it, then answers |
simlock --gateway status, or any other --gateway command but daemon start and config set |
no | exit 2, code GATEWAY_NOT_PRESENT: No gateway on this machine. Start one with: simlock --gateway daemon start |
simlock --local daemon start |
yes, no worker yet | starts a worker in ~/.simlock; plain commands reach it from then on |
simlock --gateway config set http.port 4800 |
no | writes ~/.simlock/gateway/config.json; the gateway is now present |
What could go wrong
- A command reaches the wrong daemon: the user sees a worker's answer where they expected the gateway's. Unlikely; the flag always wins.
- On a gateway-only machine, a plain command starts an empty worker. Unlikely; the table above prevents it.
- A setup that relied on
SIMLOCK_HOMEormodestops working. Certain, accepted: Simlock has no users yet. - Two macOS users on one machine each run a worker and share iOS runtimes. Not prevented; written down as a known gap.
Words used
- worker: the daemon that owns devices, e.g. the one in
~/.simlock. - gateway: the daemon that fronts several workers and owns no devices, e.g. the one in
~/.simlock/gateway. - root:
~/.simlock, or$SIMLOCK_HOMEin tests. The worker lives in the root, the gateway in<root>/gateway. - worker marker:
<root>/instance.json. A worker writes it on its first start;config setalone never does. - present gateway:
<root>/gateway/instance.jsonor<root>/gateway/config.jsonexists. The gateway writes the first on its first start;simlock --gateway config setwrites the second. An emptygatewayfolder is not one. - gateway-only command: one a worker refuses with
UNSUPPORTED_IN_WORKER_MODE, e.g.simlock worker drain.
Non-goals
- Several OS users on one machine, each with a worker (known gap).
- One daemon that is both gateway and worker.
- Moving data from an old
SIMLOCK_HOMEor an oldmodekey. No migration. - The disk lifecycle feature itself. It depends on this one.
- A new lock: the existing socket claim is what refuses a second worker.
Completion conditions
- A second worker for the same user cannot start: it finds the running one at
~/.simlock/daemon.sockand says so. simlock --gateway daemon startstarts a gateway in~/.simlock/gatewaywhile the worker in~/.simlockkeeps running, andsimlock status/simlock --gateway statusreportworker/gateway.- A gateway becomes present only through
simlock --gateway daemon startorsimlock --gateway config set; with no gateway present, any other--gatewaycommand exits 2 withGATEWAY_NOT_PRESENT, and a--gateway mcptool call fails with the same code. - On a machine that got its gateway first,
simlock --local daemon startstarts a worker, and plain commands reach it afterwards. - With defaults, a worker's HTTP listens on 4701 and a gateway's on 4700.
- A config that sets
modeis rejected at load and byconfig set, with a message that says what replaces it. - On a gateway-only machine, plain
simlock daemon startandsimlock statusreach the gateway; no worker starts. - With both, a gateway-only command without
--gatewayfails with the worker's refusal plus the--gatewayhint. simlock --gateway mcpserves the gateway; plainsimlock mcpfollows the same folder choice as the CLI.- The user docs no longer mention
SIMLOCK_HOME, themodekey, or several instances on one machine.
Open questions
Decisions
- ADR 0034 — One worker per user, and the folder says what a daemon is: fixes the folders, removes
mode, adds--gateway, the folder choice and the hint; keepsSIMLOCK_HOMEfor tests only. Draft in docs(adr): 0034 one worker per user, and the folder says what a daemon is #702.
Shared values
| Value or state | Rule | Written by | Read by | Source |
|---|---|---|---|---|
--gateway |
A bare global flag, anywhere in the argv, like --token. No value; --gateway=<x> is a usage error. It routes the command to <root>/gateway. |
#708 | #708, #709 | ADR 0034 §3; src/cli/index.ts:40-61 |
--local |
The mirror of --gateway, named so it cannot clash with component install --worker <id>: routes to <root>, auto-starts a worker. --local=<x> and --local with --gateway are usage errors. |
#709 | #709 | ADR 0034 §3; src/cli/index.ts:40-61 |
| Gateway folder | join(<root>, "gateway"). Every gateway file (config.json, daemon.sock, daemon.log, instance.json, tokens.json, admin.token, workers.json, and the event history: events.jsonl, or history.db once #629 lands) lives there. |
#708 | #708, #709 | ADR 0034 §2 |
| Root | resolveSimlockHome(): $SIMLOCK_HOME, else ~/.simlock. Unchanged. |
— | #708, #709 | src/ports/paths.ts:17-20 |
| Role → folder | CLI and MCP only: async resolveDaemonHome({ root, flag, filesystem }) in src/ports/paths.ts returns { role, dataDirectory, gatewayPresent }. #708: gateway: boolean input, the flag alone decides the role. #709: the input becomes flag: "gateway" | "local" | undefined; with no flag the role is gateway when gatewayPresent and <root>/instance.json does not exist. The daemon uses the pure daemonDataDirectory(root, role) and never stats the other role's folder. |
#708 (creates), #709 (extends) | CLI, MCP; daemon uses daemonDataDirectory |
ADR 0034 §2, §3 |
| Daemon role at start | The launcher passes --gateway to daemon/main.js for a gateway and sets SIMLOCK_HOME=<root> for both roles (never the gateway folder); the entry starts startDaemon({ role: "gateway" }). StartDaemonOptions.role defaults to "worker". |
#708 | #708, #709 | src/ports/daemon-launcher.ts:45-51; src/daemon/main.ts:1442 |
loadConfig role |
loadConfig({ role }), default "worker". config.mode equals role. A mode key in the file or the overrides throws ConfigError with the message below. |
#708 | daemon, CLI | src/core/config.ts:381-430 |
mode rejection message |
"mode" is no longer a config key. A daemon started with --gateway is a gateway; any other is a worker. Remove "mode" from config.json. |
#708 | #708 tests, docs | this spec |
| Present gateway | exists(<root>/gateway/instance.json) || exists(<root>/gateway/config.json). ENOENT and ENOTDIR count as absent; any other error propagates. |
#708 | #708, #709 | ADR 0034 §3; src/ports/filesystem.ts:232-242 |
| No-gateway refusal | With --gateway, no gateway present, a command other than daemon start, config set or mcp, and no --help/-h: SimlockError("GATEWAY_NOT_PRESENT", "domain", "No gateway on this machine. Start one with: simlock --gateway daemon start") before the command runs; MCP runs resolveDaemonHome on every connect and throws the same error from a tool call's connect while no gateway is present. New code in src/contract/errors.ts: exit 2, HTTP 404, no details; raised only by the CLI and MCP, so no protocol bump. |
#708 | #708, #709 | ADR 0034 §3 |
CommandContext role flag |
#708: gateway: boolean. #709: replaced by flag: "gateway" | "local" | undefined. |
#708, #709 | CLI commands, mcp |
src/cli/tree.ts:10 |
| MCP launcher argument | McpStdioLauncher takes { gateway: boolean } (#708), then the flag (#709). |
#708, #709 | src/cli/commands/mcp.ts, src/mcp/main.ts |
src/cli/types.ts:27 |
| e2e harness role | WithDaemonOptions.role?: "gateway"; TestEnv.dataDirectory is home for a worker, <home>/gateway for a gateway; a gateway env's CLI spawns put --gateway first. |
#708 | #708, #709 tests | e2e/helpers/env.ts |
| HTTP default port | Gateway role 4700, worker role 4701 (http.port still overrides). |
#708 | daemon, docs | src/core/config.ts:764 |
| Worker marker | <root>/instance.json exists. Written by the worker's loadInstanceId (unchanged). |
existing | #709 | src/daemon/main.ts:244-249 |
| Hint text | Shown when the chosen role is worker, --gateway was not given, a gateway is present, and the worker replied UNSUPPORTED_IN_WORKER_MODE. The worker's message, then . , then: A gateway also runs on this machine: run the command again with --gateway. Code and exit code unchanged (UNSUPPORTED_IN_WORKER_MODE, 2). |
#709 | #709 tests, docs | src/contract/errors.ts:357-362 |
daemon.mode on status.get, mode on config.get |
Unchanged on the wire; the value is the role. No protocol bump. | — | CLI passthrough, console, HTTP | src/daemon/dispatcher.ts:358; src/contract/schemas.ts:838-842 |
Tasks
flowchart LR
T1["#708 The folder sets the role"] --> T2["#709 Commands find the daemon by folder"]
- The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 The folder sets the role: gateway in
<root>/gateway,--gateway,moderemoved — after it, a user starts a gateway beside their worker withsimlock --gateway daemon start, and every command takes--gateway. Risk: every gateway test and doc changes at once; a missed spot leaves a gateway reading the worker's folder. - Commands find the daemon by folder, and a gateway-only command sent to the worker says to add --gateway #709 Commands find the daemon by folder, and the
--gatewayhint — after it, a gateway-only machine needs no flag, and a gateway-only command sent to a worker says to add--gateway. Risk: a command on a gateway-only machine starts an empty worker.
Completion conditions → tasks:
- Second worker cannot start: already true (
src/daemon/connection-host.test.ts:57); The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708's docs state it. --gateway daemon startbeside a worker; status reports roles: The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708.moderejected: The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708.- Only
--gateway daemon startcreates a gateway;GATEWAY_NOT_PRESENTin CLI and MCP: The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708. - HTTP defaults 4700 / 4701: The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708.
- Gateway-only machine needs no flag;
--localadds a worker there: Commands find the daemon by folder, and a gateway-only command sent to the worker says to add --gateway #709. - Hint on a gateway-only command: Commands find the daemon by folder, and a gateway-only command sent to the worker says to add --gateway #709.
--gateway mcp: The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708; plainmcpfollows the folder choice: Commands find the daemon by folder, and a gateway-only command sent to the worker says to add --gateway #709.- Docs: The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 (flags, folders,
mode,SIMLOCK_HOME, the new code, ports), Commands find the daemon by folder, and a gateway-only command sent to the worker says to add --gateway #709 (the folder choice,--local, the hint).
Open work in the same area:
- daemon start reports DAEMON_CONNECTION_LOST for a daemon that never came up #684 (bug:ready)
daemon startreportsDAEMON_CONNECTION_LOSTfor a daemon that never came up: its fix classifies failures inAutoLaunchIpcConnector.connect, the function The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 changes. The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 Depends on daemon start reports DAEMON_CONNECTION_LOST for a daemon that never came up #684. - The event history is a SQLite file, history.db #629 / PR feat(history): the event history is a SQLite file, history.db #694 (history.db): adds a line directly above
void startDaemon()in the daemon entry, which The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 rewrites; the history file is per data directory, so a gateway's lands in<root>/gatewaywith no extra work. The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 Depends on The event history is a SQLite file, history.db #629. - Projections: request, minute and device fact rows #630 / PR feat(history): project requests, minutes and device facts into history.db #695 (metrics config): rewrites
loadConfig's defaults call and itsmode === "gateway"branch, which The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 rekeys on the role, and addsmode: "gateway"config tests The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 must convert. The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 Depends on Projections: request, minute and device fact rows #630. usage.getandsimlock statsfrom the projections #631–Console Usage page #634 (task:ready, the rest of Operational metrics kept over hours and shown in the console #623's stack; PR feat(stats): usage.get and simlock stats from the projections #711 forusage.getandsimlock statsfrom the projections #631): add gateway tests and console fixtures that setmode: "gateway", which The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 refuses. The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 Depends onusage.getandsimlock statsfrom the projections #631,GET /v1/statsand thesimlock stats --csvledger #632, Overview and Waiting: a range picker, wait p95, leases granted and three charts #633 and Console Usage page #634, and converts them once.- Auth checks live in one module, src/auth #712 (task:draft, feature Sign in through a sign-in proxy, and know who did what #701, auth module): rewrites
startDaemonaround the mode branch The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 rekeys. Auth checks live in one module, src/auth #712 should list The folder sets the role: a gateway lives in <root>/gateway, --gateway reaches it, and the mode key is gone #708 under Depends on; noted on Auth checks live in one module, src/auth #712 for its spec session. - PR docs(adr): 0033 a sign-in proxy is a second way to sign in, and auth is one module #704 also proposes an ADR numbered 0033; this feature's ADR is renumbered 0034 in docs(adr): 0034 one worker per user, and the folder says what a daemon is #702.
- Leftover .tmp files and .staging directories from a crashed write are never cleaned up #699 (bug:triage) leftover
.tmp/.stagingfiles: a fix that sweeps the root must also sweep<root>/gateway. Noted on Leftover .tmp files and .staging directories from a crashed write are never cleaned up #699; no Depends on. - The Android startup sweep attaches another Simlock instance's emulators to this instance's adb server #264, After a restart, Android shutdown and reclaim can send emu kill or a snapshot load to an emulator that is not the device's #284 (bug:new) Android cross-instance bugs: still possible for test roots and a second OS user. No Depends on.
Written by an agent.
Activity
V3RON commentedon Oct 19, 2026
Spec updated: every section filled in (how it works, examples, completion conditions, decisions, shared values, tasks); open questions answered; tasks #708 and #709 added; ADR 0033 drafted in #702.
Written by an agent.
V3RON commentedon Oct 19, 2026
Spec updated after the first spec check:
--workerflag,GATEWAY_NOT_PRESENTfor every--gatewaycommand butdaemon start/config set, worker HTTP default 4701, #708 now depends on #684, #629 and #630, ADR renumbered to 0034. Shared values, examples, completion conditions and open work changed.Written by an agent.
V3RON commentedon Oct 19, 2026
Spec updated after the second spec check: the worker flag is
--local(no clash withcomponent install --worker), #708 also depends on #631–#634, #712 to follow #708, MCP re-checks per call, harness and console-lane fixes, help before the no-gateway refusal.Written by an agent.
chore(agents): spec sessions approve once, source facts while asking,…
docs(adr): 0033 one worker per user, and the folder says what a daemo…
docs(adr): 0034 one worker per user, and the folder says what a daemo…
github-actions commentedon Oct 20, 2026
All 2 sub-issues are closed. If the completion conditions hold on
main, close this feature and flip its ADRs to Accepted.github-actions commentedon Oct 20, 2026
All 2 sub-issues are closed. If the completion conditions hold on
main, close this feature and flip its ADRs to Accepted.