@vibelord/lanes 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +3 -0
  2. package/package.json +1 -1
  3. package/payload/lanes/README.md +58 -21
  4. package/payload/lanes/VERSION +1 -1
  5. package/payload/lanes/bin/lanes +21 -8
  6. package/payload/lanes/container/env.sh +1 -1
  7. package/payload/lanes/container/lane +5 -5
  8. package/payload/lanes/container/lane-signal +4 -4
  9. package/payload/lanes/docs/ARCHITECTURE.md +16 -8
  10. package/payload/lanes/docs/DESIGN.md +3 -3
  11. package/payload/lanes/docs/INSTALL.md +38 -2
  12. package/payload/lanes/docs/RUNBOOK.md +57 -27
  13. package/payload/lanes/docs/SHARING.md +239 -4
  14. package/payload/lanes/docs/USERGUIDE.md +1239 -0
  15. package/payload/lanes/holders.lock +10 -0
  16. package/payload/lanes/install/enable-studio-host.sh +14 -0
  17. package/payload/lanes/install/host-install.sh +5 -2
  18. package/payload/lanes/install/services.py +101 -4
  19. package/payload/lanes/install.sh +1 -1
  20. package/payload/lanes/libexec/agent_state.py +1 -1
  21. package/payload/lanes/libexec/cursor_sync.py +2 -2
  22. package/payload/lanes/libexec/economy_poll.py +3 -3
  23. package/payload/lanes/libexec/lanes_lib.py +1 -1
  24. package/payload/lanes/libexec/notify.py +3 -3
  25. package/payload/lanes/libexec/registrations.py +1 -1
  26. package/payload/lanes/libexec/session_actions.py +1 -1
  27. package/payload/lanes/libexec/sidebar.py +7 -7
  28. package/payload/lanes/libexec/statusd.py +25 -10
  29. package/payload/lanes/libexec/studio_host.py +1608 -0
  30. package/payload/lanes/systemd/lanes-economy.service +1 -1
  31. package/payload/lanes/systemd/lanes-studio-host-enrol.service +11 -0
  32. package/payload/lanes/systemd/lanes-studio-host-enrol.timer +10 -0
  33. package/payload/lanes/systemd/lanes-studio-host.service +37 -0
  34. package/payload/lanes/web/README.md +2 -2
  35. package/payload/lanes/zellij/config.kdl +1 -1
  36. package/payload/manifest.json +4 -3
@@ -1,9 +1,10 @@
1
1
  # lanes — runbook
2
2
 
3
3
  `lanes` is the host-level workspace server for a deployment's container terminals. The canonical source is
4
- `bw-tools/lanes`; [INSTALL.md](INSTALL.md) covers the full setup and [ARCHITECTURE.md](ARCHITECTURE.md) explains
5
- its parts. Run host commands as the workspace account. Deployment-specific incidents below retain their
6
- original names and measurements; substitute your account, containers and paths in operational commands.
4
+ `bw-tools/lanes`; [INSTALL.md](INSTALL.md) covers the full setup, [ARCHITECTURE.md](ARCHITECTURE.md) explains
5
+ its parts, and the [user guide](USERGUIDE.md) is the day-to-day reference for every surface, key and command.
6
+ Run host commands as the workspace account. Deployment-specific incidents below retain their original names and
7
+ measurements; substitute your account, containers and paths in operational commands.
7
8
 
8
9
  ## Daily use (client)
9
10
 
@@ -15,7 +16,7 @@ original names and measurements; substitute your account, containers and paths i
15
16
  | One lane, no multiplexer, native Ghostty scrollback | `lanes-launch lane <lane-id>` (ids: `lanes-launch ls`) |
16
17
  | Switch workspace | `Ctrl-Alt-←/→` (zellij tabs) |
17
18
  | Next / previous lane | `Ctrl-Alt-↓` / `Ctrl-Alt-↑` (works from inside the lane: `lanes next\|prev --focused` in a floating pane that closes at once points the FOCUSED viewport — the first one when the sidebar has the focus — at the next / previous lane of its workspace in registry order, wrapping; nothing is typed and the focus stays where it was; on a tab without a workspace, e.g. Host, it does nothing). In the sidebar `J` / `K` still step through the list as it is drawn |
18
- | Lock the view | `Ctrl-Alt-g` locks (every key reaches the lane; the mode ribbon shows LOCKED in on-accent-urgent) and unlocks. `Ctrl-g` is never bound: it always reaches the lane |
19
+ | Lock the view | `Ctrl-Alt-g` locks (the lane gets every key except the `shared` chords that stay live while locked — the Ctrl-Alt arrows, `/`, `u`, `c`, `s`, PageUp/PageDown/End; the mode ribbon shows LOCKED in on-accent-urgent) and unlocks. `Ctrl-g` is never bound: it always reaches the lane |
19
20
  | Find a lane by name | `Ctrl-Alt-/`, type a few letters, `Enter` |
20
21
  | Browse the sidebar | click it (or `Ctrl-Alt-p` then `←`), `↑/↓` or `j/k`, `Enter`; selecting a lane focuses it again |
21
22
  | Go to the next lane that needs you | `Tab` in the sidebar opens the next lane in **Needs you** (`Shift-Tab` the previous), cycling from the lane this pane shows — or from the cursor lane when the shown one does not need you; ` nothing needs you` when the section is empty |
@@ -50,7 +51,7 @@ Since plan Task 26 the phone's picker pane has no fixed layout name, so Zellij c
50
51
  session laid out after Task 26: an existing phone device keeps the old picker pane until `lanes rebuild <device>`
51
52
  (`upgrade-session` and `ensure` add missing tabs and rename titles, they never re-lay-out a tab; `lanes doctor` names
52
53
  such a device). Whether it
53
- reaches the phone is still unchecked in a browser (DEF-1450). A lane that
54
+ reaches the phone is still unchecked in a browser (DEF-1531). A lane that
54
55
  STARTS needing you (`◆` `✗` `◇`) raises one desktop notification, `name: task`, through OSC 9 — once per transition,
55
56
  never for the lane this pane shows, never on a phone, and not at all under `LANES_NOTIFY=0`. A sidebar that starts
56
57
  (or restarts) while lanes already need you announces each of them once. Both are bytes in the frame the sidebar
@@ -120,13 +121,16 @@ lanes rebuild <device> recreate a view session lanes ensure recrea
120
121
  lanes invariant save|check prove no live pane changed (pid + window size)
121
122
  lanes device list the devices ensure keeps lanes device forget <name> stop recreating one
122
123
  systemctl --user status lanes-statusd lanes-sync.timer lanes-ensure.timer lanes-web
124
+ lanes studio-host status [--check] the optional co-located Studio Host
125
+ lanes studio-host re-key --yes after the owner revoked the Host: a new machine key for the next deploy
126
+ (refused while lanes-sharing-host or throttle-rc-studio-host still runs)
123
127
  ```
124
128
 
125
129
  - **What `lanes doctor` says about the boards' recipes (plan Task 18):** one line counts the tmux lanes per create
126
130
  recipe class — `idempotent` (a `tmux new -A…`: creates, or attaches when the session exists; the canonical recipe),
127
131
  `attach-only` (`attach-session` and no `new`), `null` (no recipe), `external` (a script or anything else, run as
128
132
  written) and `retired-path` (names the retired in-container `~/.config/coder-launch`). One line says how many of
129
- them create with their identity (`-e LANE_ID=…`, DEF-1478). Warnings (`⚠`, never a `✗`, the exit code is
133
+ them create with their identity (`-e LANE_ID=…`, DEF-1559). Warnings (`⚠`, never a `✗`, the exit code is
130
134
  unchanged): lanes that `lane open` cannot recreate once they end (attach-only, null, retired-path — fix with
131
135
  `lanes cursor-sync --canonicalize`), lanes bound to a numeric session name (tmux numbers unnamed sessions itself,
132
136
  so a recreated one gets another number), more than 4 lanes on one container's tmux socket `default` (the server every
@@ -137,7 +141,7 @@ systemctl --user status lanes-statusd lanes-sync.timer lanes-ensure.timer lanes-
137
141
  file is there, nobody listens) unlinks it, printing each path; a server that answers (even with no session), does
138
142
  not answer within 5 s, or refuses for another reason (EACCES, …) keeps its file. The errno decides, never tmux's
139
143
  wording: tmux 3.4 prints `no server running on <path>` for ECONNREFUSED and `error connecting to <path>
140
- (<strerror>)` only for other errnos (DEF-1459).
144
+ (<strerror>)` only for other errnos (DEF-1540).
141
145
  - **A device you no longer use (an old laptop, a phone you replaced):** `lanes device forget <name>` removes
142
146
  `devices/<name>.json`, so `lanes ensure` (`lanes-ensure.timer`) stops recreating its view session and `sync` /
143
147
  `upgrade-session` stop updating it. A view session that is running is left alone and says so; once it ends, nothing
@@ -179,6 +183,30 @@ systemctl --user status lanes-statusd lanes-sync.timer lanes-ensure.timer lanes-
179
183
  - **A new workspace:** add it to `~/.config/lanes/workspaces/catalog.json` + a manifest, `lanes sync`, then
180
184
  `lanes rebuild <device>` (the only time a layout changes).
181
185
  - **Registry invalid:** the workspace keeps its previous lanes; the sidebar footer and `lanes doctor` show why.
186
+ - **The Studio Host (optional, `lanes-studio-host.service`):** `lanes studio-host status` names the state —
187
+ `running`, `waiting-for-enrolment` (the deploy has not written a receipt yet; the unit is enabled and the enrol
188
+ timer active, so it installs the receipt within 5 minutes), `receipt-refused` (a receipt is on disk and the
189
+ installer refused it — the code, when, and the next retry are named; the timer's one line per tick is in
190
+ `journalctl --user -u lanes-studio-host-enrol.service`), `key-refused` (the registration key was tried and
191
+ `studio enroll` refused it — code, exit, when, next retry), `credential-for-gone-key` (a receipt on disk for a
192
+ machine key this machine no longer holds, for a row it has NOT measured revoked: revoke the row in the console,
193
+ then `lanes studio-host re-key --yes`; the installer never enrols a second machine beside it by itself),
194
+ `not-enabled` (not enrolled and the unit or the enrol
195
+ timer is not enabled, so nothing retries: re-run `sh install/enable-studio-host.sh`),
196
+ `stopped` (exit 3 is the Host's typed refusal: `journalctl --user -u lanes-studio-host.service -n 30`, then
197
+ `systemctl --user start lanes-studio-host.service` or re-run `sh install/enable-studio-host.sh` — a check red only on
198
+ that run's own recorded refusal no longer blocks the start; the retry timer does NOT restart it on the same
199
+ enrolment),
200
+ `receipt-unreadable` / `key-unreadable` (the file exists but this account cannot read it — e.g. a receipt a deploy
201
+ run as another account wrote 0600: give it to this account), `conflict` (another Host unit runs here: one Host per
202
+ machine), `not-installed`, or `unknown` (no answer from the user manager; a shell without `XDG_RUNTIME_DIR` falls
203
+ back to `/run/user/<uid>`). `status --check` exits 1 (`running-check-failed`) when the unit runs but its own
204
+ `studio host --check` does not pass. A receipt refused `receipt_server_mismatch`: fix the Host's `server` in
205
+ `~/.config/throttle-rc/studio-host.toml` — the timer retries the same receipt when the config changes, a manual
206
+ `sh install/enable-studio-host.sh` retries it at once. A new binary: `lanes studio-host install --from FILE --sha256 HEX`, then re-run
207
+ `sh install/enable-studio-host.sh` (checks, then restarts). Revoked by the owner (`journalctl` says revoked; the
208
+ deploy warns `host_revoked`): `lanes studio-host re-key --yes`, then deploy again — see
209
+ [Sharing](SHARING.md#after-the-owner-revokes-this-machine). See [Sharing](SHARING.md#co-located-studio-host-one-host-per-lanes-server).
182
210
 
183
211
  ## When the host is overloaded
184
212
 
@@ -402,7 +430,7 @@ The sidebar's own colours and glyphs come from one token table (`libexec/tokens.
402
430
  and the `say` line names it. A plain `./install.sh` re-run without `--palette` removes the include and all palette
403
431
  files: inherit-first is the default. NOT yet looked at on a real Ghostty (2026-10-04: no Mac or Omarchy client was
404
432
  used; the installer was exercised against a fake `ghostty` that reports a version, fake everyday configs and a
405
- model of Ghostty's load order read from its source). DEF-1466 stays open until that is seen: run `--palette` on a
433
+ model of Ghostty's load order read from its source). DEF-1547 stays open until that is seen: run `--palette` on a
406
434
  Mac (Ghostty ≥ 1.1) and on Omarchy, and the window background must be `#101318` (the dark `surface` token).
407
435
  - OSC 11 on a real Ghostty view is NOT yet verified (2026-10-04: Task 22 ran on the server with no Mac client
408
436
  attached, and no live session was touched). To check: with the Mac in light mode, `lanes attach mac` from the Mac,
@@ -436,17 +464,17 @@ only normalizes HOW a terminal reaches its lane:
436
464
  are additionally projected into the file Cursor reads (`"cursor"` in `workspaces/*.json`).
437
465
  - `lanes cursor-sync <ws> --canonicalize` (dry run; add `--write` to apply) replaces every tmux lane's recorded
438
466
  create recipe that cannot create the lane — none, attach-only, the retired `~/.config/coder-launch` path (the three
439
- classes `lanes doctor` warns about, DEF-1460), or a script missing in the container — with the
467
+ classes `lanes doctor` warns about, DEF-1541), or a script missing in the container — with the
440
468
  canonical `tmux new -ADs … -e LANE_ID=… -e LANE_WS=… bash -l` recipe (old value kept as `createPrevious`).
441
469
  `--identity` adds the same `-e` flags to a working plain `tmux -L S new -ADs N [-c D] CMD` recipe, keeping CMD's
442
470
  argv; any other working recipe is left alone and its lane carries no identity. These `-e` flags are the ONLY way a
443
- tmux lane gets `LANE_ID`/the trace: `lane open` deliberately exports none (DEF-1478 — the first client on a shared
471
+ tmux lane gets `LANE_ID`/the trace: `lane open` deliberately exports none (DEF-1559 — the first client on a shared
444
472
  board socket would otherwise hand its identity to every later lane there). Both change the file only: a RUNNING
445
473
  lane keeps its environment and gains the identity when it is next created. Before enabling it on a live board,
446
- read DEF-1479: a `lanes/<ws>/<id>` trace makes the whole lane — main session, subagents, fan-out — one proxy
474
+ read DEF-1560: a `lanes/<ws>/<id>` trace makes the whole lane — main session, subagents, fan-out — one proxy
447
475
  fairness bucket. Applying `--canonicalize --write` or `--identity --write` to a live workspace is an operator gate
448
476
  (plan "Operator gates"): it rewrites operator-declared recipes in the repo's `.vscode/terminals.json` (old value kept
449
- as `createPrevious`, file backup under `cursor-sync-backups/<ts>/` as above) and produces the first live `lanes/<ws>/<id>` trace — decide DEF-1479 lane-level
477
+ as `createPrevious`, file backup under `cursor-sync-backups/<ts>/` as above) and produces the first live `lanes/<ws>/<id>` trace — decide DEF-1560 lane-level
450
478
  vs per-session fairness first.
451
479
  - Size policy "the lanes view wins": lanes-view tmux clients run with `TERM=xterm-lanesview` (a user-level terminfo
452
480
  alias of xterm-256color). While such a client shows a lane, every other client of that lane (Cursor terminals,
@@ -462,7 +490,7 @@ event-driven: Claude Code or Codex runs `~/.lz/bin/lane-signal <status>` on a tu
462
490
  `~/.lz/state/hooks/<lane id>.json` in the container, and `lanes-statusd` prefers that over the other signals while
463
491
  its agent runs and its last event is under an hour old. Nothing installs a hook for you. (`~/.lz/state/<name>.json`
464
492
  itself is `lane-run`'s phase record of a zmx lane; the hook never writes there. A hooked agent in a zmx lane writes
465
- its sidecar under the zmx session name, which the sidebar does not show yet — DEF-1437.)
493
+ its sidecar under the zmx session name, which the sidebar does not show yet — DEF-1573.)
466
494
 
467
495
  1. `lanes install-container <name>` — puts `lane-signal` in `~/.lz/bin/` and the definitions in `~/.lz/hooks/`.
468
496
  2. `lanes hooks install --agent claude --container <name>` — prints the unified diff it would make to that
@@ -504,7 +532,7 @@ Codex 0.160 has no `codex --help hooks`; the names come from the binary. Not yet
504
532
  loads a USER-level `~/.codex/hooks.json` the way it loads a project's (the only hooks.json files measured here are
505
533
  project files), and what a hook's parent process is under each harness (lane-signal skips one shell either way).
506
534
 
507
- Known limit (DEF-1434): after you approve a permission prompt, the lane shows `needs_approval` until the approved
535
+ Known limit (DEF-1515): after you approve a permission prompt, the lane shows `needs_approval` until the approved
508
536
  tool call returns (`PostToolUse`), because neither harness signals "approved". A long approved command therefore
509
537
  reads as waiting for you until it finishes. A lane whose agent crashed loses its sidecar at once (dead pid); one
510
538
  whose agent is alive but silent keeps the last word for up to an hour.
@@ -535,7 +563,7 @@ in priority order and whole parts dropped from the end to fit 34 cells:
535
563
 
536
564
  A leading `?` and an all-dim line mean the figures are frozen: the poller kept its last fleet strip because `/status`
537
565
  failed (`"stale": true`), or the poller stopped — the poller rewrites `economy.json` at least every 60 s, and a sidebar
538
- treats a document more than 180 s old by its own clock as frozen (DEF-1447). A lane's tail cell follows the same rule:
566
+ treats a document more than 180 s old by its own clock as frozen (DEF-1528). A lane's tail cell follows the same rule:
539
567
  a `⚠` or `↓` drawn dim (not amber) is the last known figure, not a live alarm — the poller kept that lane's record
540
568
  because `/sessions` failed (`"stale": true` on the lane), or the whole document is frozen. With
541
569
  `LANES_THROTTLE_CONTROL=none` every fleet figure is `null`: the strip row then shows fleet attention exactly as without
@@ -553,7 +581,7 @@ default reaches them from the host:
553
581
  (`[server] host = "127.0.0.1"`, measured 2026-10-04 in the `work` workspace container) is not reachable from the host at all, and
554
582
  `http://127.0.0.1:19181` on the host is a different loopback. Until the control port is reachable from the host —
555
583
  an operator change to the daemon's bind or forwarding, outside lanes — set `LANES_THROTTLE_CONTROL=none`: the
556
- fleet strip and live per-lane figures stay `null`, the log fold still works (DEF-1444, open).
584
+ fleet strip and live per-lane figures stay `null`, the log fold still works (DEF-1525, open).
557
585
  - The log lives in the container's home. Set `LANES_THROTTLE_CONTAINER=<container name>`: the poller reads
558
586
  `/home/coder/.local/share/throttle` through that container's home volume as `lanes sync` recorded it in
559
587
  `~/.local/state/lanes/containers.json` (the container must be on a board). The host user's own
@@ -565,7 +593,7 @@ Check what the poller can see before you enable it — `--once` names every sour
565
593
  volume sits under the docker data root (`docker info -f '{{.DockerRootDir}}'`: `/var/lib/docker` by default,
566
594
  `/data/docker` on ceobox, where the workspace homes are named volumes at `<root>/volumes/coder-<id>-home/_data`),
567
595
  which only root normally traverses. The log half then needs the same operator decision as the control half
568
- (DEF-1444): point `LANES_THROTTLE_LOG_DIR` at a directory this user can traverse, or run the poller where the daemon
596
+ (DEF-1525): point `LANES_THROTTLE_LOG_DIR` at a directory this user can traverse, or run the poller where the daemon
569
597
  runs:
570
598
 
571
599
  ```sh
@@ -575,10 +603,11 @@ systemctl --user enable --now lanes-economy.service
575
603
  journalctl --user -u lanes-economy.service # one line each time a source goes down or comes back
576
604
  ```
577
605
 
578
- Before you enable it, settle DEF-1479 (the lane-stable trace's fairness consequence) if any lane was created with
606
+ Before you enable it, settle DEF-1560 (the lane-stable trace's fairness consequence) if any lane was created with
579
607
  the identity recipe; lanes without it are joined to their requests by `agent.session_id` alone.
580
608
 
581
- Settings (environment of the installer — `install/services.py` renders them into this unit only — or a
609
+ Settings (environment of the installer — `install/services.py` renders them into this unit only, from
610
+ `./install.sh --server` and from `install/enable-studio-host.sh` alike, which re-renders every lanes unit — or a
582
611
  `systemctl --user edit lanes-economy.service` drop-in):
583
612
 
584
613
  | Variable | Default | Meaning |
@@ -620,17 +649,18 @@ When nothing is sent:
620
649
  (`clients/<device>.<client>.pid`), or a lane opened from the sidebar of a desk device
621
650
  (`clients/<device>.seen.json`, counted only for a device that has a `.pid` — one `lanes attach` has run for —
622
651
  and whose `devices/<device>.json` is not `mobile: true`) that recently. Typing in a lane refreshes neither, so after two minutes at the keyboard without attaching or
623
- opening a lane the pushes resume (DEF-1451). A suppressed transition is dropped, not sent later. Phone presence
652
+ opening a lane the pushes resume (DEF-1532). A suppressed transition is dropped, not sent later. Phone presence
624
653
  is decided by the device's mobile layout, not by how it attached: a Safari phone writes no `.pid`, and an
625
654
  SSH-only phone that ran `lanes attach phone --mobile` (below) is still a mobile device, so opening a lane from
626
- the phone's picker never mutes pushes — you keep getting them while you are away from every desk (DEF-1452).
655
+ the phone's picker never mutes pushes — you keep getting them while you are away from every desk (DEF-1533).
627
656
  Only a phone's own `lanes attach` in the last two minutes counts, like any attach.
628
657
 
629
658
  A failed send (server down, `HTTP 429`, no network) is one journal line naming the lane and the failure class — the
630
659
  topic and the token are never logged — and is not retried.
631
660
 
632
- Config `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` in the server installer's environment renders another
633
- path into this unit only), `key=value` lines, `#` comments, re-read on change:
661
+ Config `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` in the server installer's environment — or
662
+ `install/enable-studio-host.sh`'s, which re-renders every lanes unit — renders another path into this unit only),
663
+ `key=value` lines, `#` comments, re-read on change:
634
664
 
635
665
  | Key | Default | Meaning |
636
666
  |---|---|---|
@@ -763,12 +793,12 @@ what the agent needs and since when with its harness (`Needs you · 25m · claud
763
793
  agent's task. Its first line counts the lanes that need you (`◆1 need approval · ◇2 need you · ▸6 working`). The same
764
794
  counts come from the picker pane's title (see "Title and notifications" above): the header subtitle on the picker,
765
795
  the page title, a number on the **Lanes** button for lanes needing approval or input, and `◆ 1 · ◇ 2` on each
766
- workspace card. Nothing is fetched or polled for them. Not yet confirmed (DEF-1450): until plan Task 26 (2026-10-04)
796
+ workspace card. Nothing is fetched or polled for them. Not yet confirmed (DEF-1531): until plan Task 26 (2026-10-04)
767
797
  the phone's picker pane had the fixed layout name `Lanes`, which Zellij reports instead of the sidebar's title, so the
768
798
  phone showed no counts. Task 26 removed that name from the generated layout, which reaches a phone only when its
769
799
  session is laid out again (`lanes rebuild <device>`; see the device check below); whether the title
770
800
  now reaches the phone is still unchecked in a browser — until then only the picker's own first line is known to
771
- show them (DEF-1450).
801
+ show them (DEF-1531).
772
802
 
773
803
  The phone's light/dark toggle repaints the whole terminal, not only the shell: the page applies its own sixteen-colour
774
804
  palette on attach, on every toggle and after every Zellij config reload (DEF-1413; not yet looked at on a device).
@@ -780,9 +810,9 @@ sends its text as UTF-8, so a filter like `ø` matches.
780
810
 
781
811
  Device check (plan Task 13 step 3, not yet run): on a real iPhone in Safari and from the Home Screen, tap each quick
782
812
  key on a lane tab and confirm that the keyboard does NOT open, that `y`/`⏎`/arrows arrive in the lane, that a short tap
783
- on `n`/`^C` sends nothing and a hold sends it, and (plan Task 26 has landed; DEF-1450 waits on this) that the Workspaces
813
+ on `n`/`^C` sends nothing and a hold sends it, and (plan Task 26 has landed; DEF-1531 waits on this) that the Workspaces
784
814
  sheet shows each picker's counts. A phone device created before Task 26 keeps the old picker pane (`upgrade-session`
785
- does not re-lay-out a tab): run `lanes rebuild <device>` first (`lanes doctor` must not list it under DEF-1450), or no
815
+ does not re-lay-out a tab): run `lanes rebuild <device>` first (`lanes doctor` must not list it under DEF-1531), or no
786
816
  counts appear and the check says nothing about the title path. Record the
787
817
  device, iOS version and result here. The unit suite covers only the picker's text, not the browser.
788
818
 
@@ -30,9 +30,10 @@ An existing in-container Studio Host reads `~/.lz/registry.json` by default.
30
30
  That is the registered-lane projection written by `lanes sync`. No additional
31
31
  configuration is needed for this source.
32
32
 
33
- For a Lanes server hosting several workspace containers, run Studio Host on
34
- that server and add these settings to its
35
- `~/.config/throttle-rc/studio-host.toml`:
33
+ For a Lanes server hosting several workspace containers, prefer the installer-owned
34
+ [co-located Studio Host](#co-located-studio-host-one-host-per-lanes-server) below. Hand-run, the server's
35
+ `~/.config/throttle-rc/studio-host.toml` reads (add `mode = "lanes-server"` under `[inventory]` for the
36
+ interactive adapter; without it the Host serves the read-only sharing capture only):
36
37
 
37
38
  ```toml
38
39
  server = "https://throttle.example.com"
@@ -72,6 +73,236 @@ eight-hour Host session. If you deployed that candidate, use the
72
73
  before deploying current builds. The normal migrator correctly refuses that
73
74
  journal; do not edit its digest or reset the database.
74
75
 
76
+ ## Co-located Studio Host (one Host per lanes server)
77
+
78
+ On a lanes server — the account that runs `lanes-statusd` and reaches every workspace container's tmux sockets
79
+ through `/proc/<container-pid>/root/tmp/tmux-<uid>/` — ONE Studio Host serves every board, installed and supervised
80
+ by the lanes installer as `lanes-studio-host.service` (design: `docs/design/throttle-studio.md` §8.10). It replaces
81
+ one Host per workspace container and the hand-built `lanes-sharing-host.service`. Laptops keep the in-container Host
82
+ and `throttle-rc studio service`; never run both on one machine (the enable step refuses).
83
+
84
+ **Status of the pieces.** Assembled on the integrated tree (2026-10-04): the installer below
85
+ (`tests/unit/test_studio_host.py`), the Host's interactive `lanes-server` adapter, `studio publish-key` /
86
+ `adopt-receipt` and the platform's `server studio-adopt-host`. Only a `throttle-rc` built before 2026-10-04 refuses
87
+ those verbs `not_assembled`, and the enable step then stops with exit 1 before enabling anything. A live lanes
88
+ server and a live deploy are operator-gated; see "Deploy-provisioned enrolment" for the handoff's precondition.
89
+
90
+ ### 1. Install the binary
91
+
92
+ No `node/<sha12>` release that carries the co-location CLI is pinned yet, so install a locally built binary. It is
93
+ hashed, copied to `~/.local/share/lanes/bin/throttle-rc-local-<sha12>`, re-hashed, and selected through the link
94
+ `~/.local/share/lanes/bin/throttle-rc` that the unit runs; `throttle-rc.pin.json` beside it records the digest:
95
+
96
+ ```sh
97
+ cargo build --release --locked -p throttle-rc # on a build machine; copy the file to the server
98
+ lanes studio-host install --from ./throttle-rc --sha256 <the sha256 you computed where you built it>
99
+ ```
100
+
101
+ Once a release is tagged, its line goes into `holders.lock` as an `opt-in` pin (the file's comment has the
102
+ template) and `lanes studio-host install` with no `--from` downloads it — `gh release download` for the private
103
+ repository, else `curl` — and refuses unless BOTH the tarball and the binary inside it match their digests.
104
+ `./install.sh --server` never downloads an opt-in pin. Nothing is restarted by `install`.
105
+
106
+ ### 2. Enable
107
+
108
+ ```sh
109
+ sh install/enable-studio-host.sh --server https://throttle.example.com --dry-run # the plan; changes nothing
110
+ sh install/enable-studio-host.sh --server https://throttle.example.com
111
+ ```
112
+
113
+ In order, and refusing (exit 3) before anything changes when another Host unit is enabled or running
114
+ (`another_host_unit`), the hand-built unit exists (`hand_built_unit_present`, below), no binary is installed
115
+ (`no_binary`) or an existing config is not in lanes-server mode (`config_mode`, fixed by `--set-mode`):
116
+
117
+ 1. writes `~/.config/throttle-rc/studio-host.toml` (0600) in lanes-server mode if there is none —
118
+ `[inventory] mode = "lanes-server"`, `interactive = true`, `sharing_state_dir = $LANES_STATE`,
119
+ `sharing_container_uid = 1000` (`--container-uid`), `machine_id` from `--machine-id` or `<hostname>-lanes`.
120
+ An existing file is never rewritten except, with `--set-mode`, its one `mode` key (a `.bak-<time>` is kept);
121
+ 2. runs `throttle-rc studio publish-key --out ~/.local/state/lanes/studio-host/public.jwk` — the machine key is
122
+ created HERE if absent and only its PUBLIC half (0644) is written for the deploy;
123
+ 3. enrols, if it can: a deploy receipt at `~/.local/state/lanes/studio-host/receipt.json` (`--receipt PATH`, which is
124
+ remembered in the enrolment record so the enrol timer and `status` watch that path too) is installed
125
+ with `throttle-rc studio adopt-receipt --config <the config>`, which refuses a receipt issued for another key
126
+ (`receipt_key_mismatch`) or by another server URL (`receipt_server_mismatch`); else a registration key
127
+ file at `~/.config/throttle-rc/registration.key` (`--registration-key-file`, 0600, remembered like `--receipt`) is
128
+ handed to `throttle-rc studio enroll <server> --registration-key-file` — the script names the file and never opens
129
+ it (its readability is access(2) and the timer's memory is the file's stat identity, never a digest of the secret;
130
+ review round 5). A refused or failed receipt/key does not stop the next step: the units are still rendered and enabled, and the
131
+ run exits with the refusal (3, or 1 for an operational failure) saying the unit is waiting;
132
+ 4. renders the lanes units (`install/services.py`), enables `lanes-studio-host.service` and
133
+ `lanes-studio-host-enrol.timer`;
134
+ 5. enrolled: runs `throttle-rc studio host --check`, then starts the Host (restarts it when it was running, so a new
135
+ config or binary is in force) and exits 0 only when it is still running `--settle` seconds (default 5) later.
136
+ Not enrolled (and nothing refused): exits 0 **waiting for enrolment** — the unit is enabled and exits 3 (a clean
137
+ stop, never a restart loop) until a receipt or key arrives.
138
+
139
+ `lanes-studio-host-enrol.timer` runs `enable-studio-host.sh --retry` every 5 minutes. It acts only on something
140
+ new — a receipt it has not adopted and has not refused (typed, exit 3) within the last hour under the same config, a
141
+ key file not tried in the last hour (a chmod/chown of it counts as new), a machine key that is gone (it publishes a
142
+ new one — over an existing `credential.json` only as described below), an enrolment the Host has not been started with — so a Host that stopped on a revoked enrolment is not restarted into the same refusal. The
143
+ hour applies to the timer only: a manual run of the script always tries the registration key and always tries a
144
+ refused receipt — enrolled or not (review round 4: an enrolled machine used to leave a refused NEW receipt alone for
145
+ ever). A receipt refused with `receipt_server_mismatch` is tried again by the timer as soon as `studio-host.toml`
146
+ changes (the deploy re-emits the SAME bytes, so fixing the Host's `server` is enough); an operational failure of a
147
+ RECEIPT (exit 1/2: a read-only disk, an unloadable config, a timeout) is never remembered. A registration key is
148
+ remembered for the hour after ANY attempt (each try may spend one of its uses), with what `studio enroll` answered:
149
+ `status` says `key-refused` (code, exit, time, next retry) instead of `waiting-for-enrolment`, and a refused key
150
+ SOURCE (`registration_key_source_refused`, the binary's exit 2) is the installer's typed exit 3. A new receipt for
151
+ THIS machine's key is adopted over an existing `credential.json` for the same key (a relabel, a new host row after a
152
+ platform reset); `adopt-receipt` refuses one that would replace a receipt issued for ANOTHER key
153
+ (`receipt_replaces_enrolment`) unless `--new-key` says so, and refuses a receipt for an enrolment this machine
154
+ MEASURED revoked (`receipt_for_revoked_key`). `status` says `receipt-refused` for a refused receipt on disk whether
155
+ or not an older credential exists. A `credential.json` whose machine key is gone, or is not the key this machine
156
+ published, is an enrolment only when it binds the published key (`status --json` `key_binding`). The installer
157
+ re-keys over it — publishes a new key, then `adopt-receipt --new-key` / `studio enroll --new-key` — ONLY when this
158
+ machine MEASURED that host row revoked (the Host's `state.json`); otherwise it refuses `credential_for_gone_key`,
159
+ publishes nothing, and `status` says `credential-for-gone-key`: revoke the row (console, or `throttle-rc studio revoke
160
+ --id <host>`), then `lanes studio-host re-key --yes` — the explicit decision to enrol this machine under a new key
161
+ (review round 6: the installer used to decide that itself). A receipt, key or config this account cannot read is a typed refusal
162
+ (`receipt_unreadable`/`key_unreadable`/`config_unreadable`, exit 3) naming the file's owner and mode — never a
163
+ traceback; `status` says `receipt-unreadable`/`key-unreadable`/`config-unreadable`. Both studio units ALWAYS pin `XDG_CONFIG_HOME`/`XDG_DATA_HOME` — the roots this enable step ran with
164
+ (recorded in `~/.local/state/lanes/studio-host/xdg-roots.json`; a later `./install.sh --server` renders the studio
165
+ units from that record, never from its own shell, and warns when they differ), else `%h/.config` and
166
+ `%h/.local/share` — and every `throttle-rc` child the installer runs gets the same two, so neither the user manager's
167
+ environment nor a reinstall from another shell points the supervised Host at another `credential.json`. A manual run
168
+ on an enrolled machine whose `studio host --check` is red ONLY on the refusal the Host's own last run recorded (the
169
+ owner's tunnel cap, a build mismatch fixed by `lanes studio-host install`, three displacements) starts the Host
170
+ anyway — its first phase replaces that record, and the settle window reports a refusal that still holds. The unit files are
171
+ written where the user MANAGER reads them: `$XDG_CONFIG_HOME/systemd/user` when `systemctl --user show-environment`
172
+ carries it, else `~/.config/systemd/user`. The adopt refusal's code is read from `throttle-rc studio adopt-receipt`'s
173
+ own line (`throttle-rc studio adopt-receipt: refused <code>: …`); a line with no code is remembered as `unknown`.
174
+
175
+ ### Deploy-provisioned enrolment (no human step)
176
+
177
+ The platform deploy binds the published PUBLIC key to its owner (`throttle-rc server studio-adopt-host`, design
178
+ §8.10 C4) and writes the receipt where this server finds it:
179
+
180
+ ```sh
181
+ THROTTLE_RC_STUDIO_ADOPT_JWK=/home/dev/.local/state/lanes/studio-host/public.jwk
182
+ THROTTLE_RC_STUDIO_ADOPT_OWNER=owner@example.com
183
+ THROTTLE_RC_STUDIO_ADOPT_RECEIPT=/home/dev/.local/state/lanes/studio-host/receipt.json
184
+ ```
185
+
186
+ The step is `deploy/studio-adopt-host.sh`, sourced by `deploy/deploy-ceobox-rc.sh`; a receipt path whose directory is
187
+ absent or not writable is refused `receipt_dir_unusable` (exit 2) before the platform is asked.
188
+
189
+ Run the deploy **as the lanes account**: `~/.local/state/lanes/studio-host/` is kept at 0700 and the deploy writes
190
+ the receipt 0600 as whoever ran it, so a deploy run by another account cannot read `public.jwk` and leaves a receipt
191
+ the Host cannot read (DEF-1429 in `docs/defect-ledger.md`; a group-readable handoff directory is the unbuilt
192
+ alternative). `--receipt PATH` points the installer at another path the lanes account can read; it is remembered,
193
+ so the enrol timer adopts a receipt that appears there later and `status` reports it (naming the default path again
194
+ forgets it). The receipt carries no secret.
195
+
196
+ **Precondition — one filesystem (DEF-1441).** The handoff is a file exchange, so the deploy must run where both
197
+ paths above exist: on the docker host, as the lanes account, from a checkout of this repository (the compose build
198
+ context), with an env file that account can read named by `THROTTLE_RC_ENV_FILE` — the script's default is the
199
+ literal `/home/coder/.config/throttle-rc/ceobox.env`, which a lanes account named anything else does not have:
200
+
201
+ ```sh
202
+ THROTTLE_RC_ENV_FILE=~/.config/throttle-rc/ceobox.env bash deploy/deploy-ceobox-rc.sh # as the lanes account
203
+ ```
204
+
205
+ Copying that env file (it holds the database password) to the lanes account is a secret-custody step the operator
206
+ owns. On ceobox the deploy is normally run from the Coder workspace container, which has neither `/home/dev` nor the
207
+ lanes account, so the zero-step path above does NOT execute there as-is (`[INFERRED-NOT-EXECUTABLE]`, design §8.10
208
+ C4 "Topology precondition"); until one of those two is arranged, use the registration-key path. An owner who has not signed in yet makes the adopt exit 3 and the deploy
209
+ retries next time; the timer picks the receipt up within 5 minutes of it appearing. `lanes studio-host status` then
210
+ says `enrolment: via deploy`.
211
+
212
+ ### After the owner revokes this machine
213
+
214
+ A revoked key is never adopted again: the Host stops (exit 3) and later deploys WARN `host_revoked` and finish. Give
215
+ the machine a new key:
216
+
217
+ ```sh
218
+ lanes studio-host re-key # the plan; changes nothing (exit 2)
219
+ lanes studio-host re-key --yes # stop the Host; remove the machine key, credential.json, public.jwk, receipt.json
220
+ # and the Host's refused state; publish a NEW public key
221
+ ```
222
+
223
+ Then deploy again: the deploy adopts the new key, and the timer installs the receipt and starts the Host. A new key
224
+ is a new machine in the console (design §8.7 "What a new key costs"); any `throttle-rc` login session on this
225
+ account was bound to the old key and must log in again.
226
+
227
+ ### Check it
228
+
229
+ ```sh
230
+ lanes studio-host status # state: running | running-unenrolled | waiting-for-enrolment | receipt-refused | key-refused | credential-for-gone-key | receipt-unreadable | key-unreadable | config-unreadable | not-enabled | stopped | failed | conflict | not-installed | unknown
231
+ lanes studio-host status --check # also runs `throttle-rc studio host --check`; its exit binds (below)
232
+ journalctl --user -u lanes-studio-host.service -n 30
233
+ journalctl --user -u lanes-studio-host-enrol.service -n 20 # the enrol timer's one line per tick
234
+ ```
235
+
236
+ `waiting-for-enrolment` is said only while the Host unit is enabled AND the enrol timer is active; otherwise the
237
+ machine is `not-enabled` (nothing retries: re-run the enable script). `receipt-refused` names a receipt on disk the
238
+ installer recorded as refused (its code, when, and when the timer tries it again — an hour, or at once after
239
+ `studio-host.toml` changes); its remedy is the code's (`receipt_server_mismatch`: the config's `server`;
240
+ `receipt_key_mismatch`: redeploy with this machine's public key), then `sh install/enable-studio-host.sh` to try now.
241
+ `key-refused` is the registration key's twin (`studio enroll`'s code and exit; fix the file, its mode or the key, then
242
+ re-run the script — the timer also retries a changed file on its next tick). `credential-for-gone-key` is a receipt
243
+ on disk for a key this machine no longer holds, for a row it has not measured revoked: revoke it, then
244
+ `lanes studio-host re-key --yes`.
245
+
246
+ `status` re-hashes the selected binary against its record, names the config's mode, the enrolment's `via`, both
247
+ units and any other Host unit, and says `unknown` — never a guess — when the user manager does not answer. It exits
248
+ 0 only when the Host runs enrolled (an active unit with no enrolment is `running-unenrolled`, exit 1; with
249
+ `--check`, an active unit whose `studio host --check` is non-zero is `running-check-failed`, exit 1). A caller without
250
+ `XDG_RUNTIME_DIR` (a `sudo -u` or cron shell) reaches the manager at `/run/user/<uid>` when that exists, and
251
+ `re-key` refuses `manager_unreachable` rather than remove the key under a Host whose state the manager did not
252
+ answer, and `another_host_running` (exit 3, nothing changed) while `lanes-sharing-host.service` or
253
+ `throttle-rc-studio-host.service` is active: every Host unit of this account signs with the same machine key, so
254
+ re-keying under the hand-built Host would end its sessions. Migrate (or stop) that unit first. It refuses
255
+ `rekey_path_unremovable` (exit 3, nothing changed) when a file it must remove — e.g. a remembered `--receipt` in the
256
+ deploying account's directory — sits in a directory this account cannot write, and it removes the machine key LAST.
257
+ `status` also reports an installed unit that has lost a load-bearing line
258
+ (`SuccessExitStatus=3`, `KillSignal=SIGINT`, `KillMode=process`, the slice, the statusd ordering).
259
+
260
+ ### Migrating the hand-built `lanes-sharing-host.service`
261
+
262
+ ceobox's first Host was a hand-written unit running `~/.local/bin/throttle-rc-lanes studio host` with a sharing-only
263
+ config (no `mode`, so in-container). That Host predates enrolment receipts: it authenticated with a stored human
264
+ login SESSION beside its machine key, and holds NO `credential.json` (docs/handoffs/2026-09-22-lanes-sharing.md).
265
+ The lanes unit never uses a session, so such a machine is enrolled as part of the migration. Read the unit before
266
+ replacing it:
267
+
268
+ ```sh
269
+ systemctl --user cat lanes-sharing-host.service
270
+ lanes studio-host install --from ./throttle-rc --sha256 <digest>
271
+ sh install/enable-studio-host.sh --migrate-hand-built --dry-run
272
+ sh install/enable-studio-host.sh --migrate-hand-built
273
+ ```
274
+
275
+ `--migrate-hand-built` refuses a unit that does not run `studio host`, or that names a `--config` other than
276
+ `~/.config/throttle-rc/studio-host.toml` (`%h`, `${HOME}`, `$HOME` and `~` are resolved to the account's home before
277
+ the comparison). Otherwise it sets `mode = "lanes-server"` in the existing config (keeping a `.bak-<time>`), runs the
278
+ replacement's own `throttle-rc studio host --check` on that config (an enrolled machine), and only when it passes
279
+ saves the unit's text to `~/.local/state/lanes/studio-host/migrated/`, runs
280
+ `systemctl --user disable --now lanes-sharing-host.service`, and then starts the new unit. A refusing check
281
+ (`host_check_failed`; e.g. a version-1 credential whose key binding is `unchecked`) leaves the hand-built unit
282
+ running and enabled, restores the config from the `.bak`, and names both — as does ANY failure before the
283
+ hand-built unit is stopped (a failed `publish-key`, an unreadable receipt or key, a failed `disable --now`; review
284
+ round 5: its older binary refuses the `mode` key at its next start). A RUNNING hand-built unit on a machine with no
285
+ `credential.json` (ceobox's shape) is enrolled first, and the **published public key is the first artifact**:
286
+
287
+ 1. `sh install/enable-studio-host.sh --migrate-hand-built` with nothing to enrol from installs the binary, publishes
288
+ this machine's PUBLIC key to `~/.local/state/lanes/studio-host/public.jwk` (the existing machine key is loaded,
289
+ never rewritten; the hand-built Host, its unit and its config are untouched) and refuses
290
+ `hand_built_host_unenrolled` (exit 3), naming the `THROTTLE_RC_STUDIO_ADOPT_*` values for the deploy;
291
+ 2. the deploy adopts that key and writes the receipt to the receipt path (see the runbook's one-filesystem
292
+ precondition; a deploy that runs before step 1 defers — "not published yet" — and finishes) — or put the owner's
293
+ registration key in `~/.config/throttle-rc/registration.key` (0600) instead;
294
+ 3. re-run `sh install/enable-studio-host.sh --migrate-hand-built`: it installs the receipt (or enrols) FIRST, with the
295
+ same machine key, while the hand-built Host still runs, and only then checks, stops and replaces it (a refused
296
+ enrolment leaves it running with its config restored). The machine key (`~/.local/share/throttle-rc/machine/key.json`)
297
+ is never read or moved, and an existing enrolment receipt is kept: the new Host serves as the same machine (the same
298
+ key, so the same thumbprint and machine id once enrolled). The old unit file and the old binary are left for
299
+ you to delete. A later run treats the disabled, stopped unit as migrated while the enrolment record under
300
+ `~/.local/state/lanes/studio-host/` survives; if that record is lost, the enrol timer still never refuses on the
301
+ stopped, disabled unit, and a manual run asks for `--migrate-hand-built` again (idempotent: it saves the text again).
302
+
303
+ **Rollback:** `systemctl --user disable --now lanes-studio-host.service lanes-studio-host-enrol.timer`, restore the
304
+ config's `.bak-<time>`, then `systemctl --user enable --now lanes-sharing-host.service`.
305
+
75
306
  ## Enable publishers, then expose workspaces
76
307
 
77
308
  1. Sign in to Throttle and open **Shared lanes → Manage sharing**.
@@ -112,7 +343,11 @@ interactive `/phone` endpoint.
112
343
 
113
344
  Focused regression coverage lives in the platform's `pg_http_studio` sharing
114
345
  tests, `pg_studio_schema`, the Host's `studio_host_sharing` real-tmux test, and
115
- `views/shared-lanes/view.test.mjs`. The Node tests run without an npm install:
346
+ `views/shared-lanes/view.test.mjs`. The co-located Host's installer pieces (the
347
+ rendered unit against `tests/unit/golden/lanes-studio-host.service`, the opt-in
348
+ pin, `lanes studio-host`, `install/enable-studio-host.sh` and the hand-built
349
+ migration) are `tests/unit/test_studio_host.py`, run with the other lanes unit
350
+ tests: `python3 -m unittest discover -s lanes/tests/unit`. The Node tests run without an npm install:
116
351
 
117
352
  ```sh
118
353
  node --test crates/throttle-platform/src/http/console/views/shared-lanes/view.test.mjs