@vibelord/lanes 1.0.0

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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/bin/lanes-install.mjs +158 -0
  4. package/lib/plan.mjs +65 -0
  5. package/package.json +37 -0
  6. package/payload/lanes/README.md +152 -0
  7. package/payload/lanes/VERSION +1 -0
  8. package/payload/lanes/bin/lanes +1035 -0
  9. package/payload/lanes/client/ghostty/common.conf +22 -0
  10. package/payload/lanes/client/ghostty/macos.conf +6 -0
  11. package/payload/lanes/client/ghostty/omarchy.conf +19 -0
  12. package/payload/lanes/client/install.sh +16 -0
  13. package/payload/lanes/client/lanes-launch +315 -0
  14. package/payload/lanes/client/lanes-upload +147 -0
  15. package/payload/lanes/client/lanes-window +36 -0
  16. package/payload/lanes/client/macos-helper.applescript +14 -0
  17. package/payload/lanes/client/omarchy/discover.sh +56 -0
  18. package/payload/lanes/client/omarchy/lanes.conf +5 -0
  19. package/payload/lanes/client/omarchy/lanes.desktop +9 -0
  20. package/payload/lanes/client/omarchy/lanes.lua +14 -0
  21. package/payload/lanes/client/ssh.stanza.in +54 -0
  22. package/payload/lanes/container/env.sh +16 -0
  23. package/payload/lanes/container/lane +268 -0
  24. package/payload/lanes/container/lane-run +34 -0
  25. package/payload/lanes/docs/ARCHITECTURE.md +93 -0
  26. package/payload/lanes/docs/HOST-GUARD.md +108 -0
  27. package/payload/lanes/docs/INSTALL.md +192 -0
  28. package/payload/lanes/docs/RUNBOOK.md +385 -0
  29. package/payload/lanes/docs/SHARING.md +124 -0
  30. package/payload/lanes/docs/ZMX-RING0.md +51 -0
  31. package/payload/lanes/docs/images/desktop.png +0 -0
  32. package/payload/lanes/docs/images/iphone.png +0 -0
  33. package/payload/lanes/holders/exec.sh +21 -0
  34. package/payload/lanes/holders/tmux.sh +111 -0
  35. package/payload/lanes/holders/zmx.sh +22 -0
  36. package/payload/lanes/holders.lock +5 -0
  37. package/payload/lanes/install/enable-phone.sh +43 -0
  38. package/payload/lanes/install/enable-web.sh +16 -0
  39. package/payload/lanes/install/host-install.sh +72 -0
  40. package/payload/lanes/install/services.py +61 -0
  41. package/payload/lanes/install.sh +603 -0
  42. package/payload/lanes/libexec/cursor_commit.py +159 -0
  43. package/payload/lanes/libexec/cursor_sync.py +166 -0
  44. package/payload/lanes/libexec/guard.py +231 -0
  45. package/payload/lanes/libexec/lanes_lib.py +610 -0
  46. package/payload/lanes/libexec/legacy_sessions.py +130 -0
  47. package/payload/lanes/libexec/session_actions.py +276 -0
  48. package/payload/lanes/libexec/sidebar +8 -0
  49. package/payload/lanes/libexec/sidebar.py +872 -0
  50. package/payload/lanes/libexec/statusd.py +764 -0
  51. package/payload/lanes/libexec/tmpscan.py +921 -0
  52. package/payload/lanes/libexec/view.sh +179 -0
  53. package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
  54. package/payload/lanes/site/README.md +28 -0
  55. package/payload/lanes/site/beyondwork/known_hosts +1 -0
  56. package/payload/lanes/site/beyondwork/site.conf +11 -0
  57. package/payload/lanes/site/site.example.conf +10 -0
  58. package/payload/lanes/sudo/host-guard.sh +356 -0
  59. package/payload/lanes/sudo/one-time-root.sh +32 -0
  60. package/payload/lanes/systemd/lanes-ensure.service +7 -0
  61. package/payload/lanes/systemd/lanes-ensure.timer +10 -0
  62. package/payload/lanes/systemd/lanes-gc.service +7 -0
  63. package/payload/lanes/systemd/lanes-gc.timer +9 -0
  64. package/payload/lanes/systemd/lanes-guard.service +8 -0
  65. package/payload/lanes/systemd/lanes-guard.timer +10 -0
  66. package/payload/lanes/systemd/lanes-mobile.service +23 -0
  67. package/payload/lanes/systemd/lanes-statusd.service +15 -0
  68. package/payload/lanes/systemd/lanes-sync.service +10 -0
  69. package/payload/lanes/systemd/lanes-sync.timer +10 -0
  70. package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
  71. package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
  72. package/payload/lanes/systemd/lanes-web.service +13 -0
  73. package/payload/lanes/systemd/lanes.slice +11 -0
  74. package/payload/lanes/web/Caddyfile +37 -0
  75. package/payload/lanes/web/README.md +35 -0
  76. package/payload/lanes/web/icon-192.png +0 -0
  77. package/payload/lanes/web/icon.svg +4 -0
  78. package/payload/lanes/web/index.html +30 -0
  79. package/payload/lanes/web/manifest.webmanifest +15 -0
  80. package/payload/lanes/web/mobile.css +301 -0
  81. package/payload/lanes/web/mobile.js +367 -0
  82. package/payload/lanes/workspaces.example/README.md +27 -0
  83. package/payload/lanes/workspaces.example/catalog.json +7 -0
  84. package/payload/lanes/workspaces.example/commit-targets.json +8 -0
  85. package/payload/lanes/workspaces.example/terminals.example.json +19 -0
  86. package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
  87. package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
  88. package/payload/lanes/zellij/config.kdl +360 -0
  89. package/payload/manifest.json +31 -0
@@ -0,0 +1,385 @@
1
+ # lanes — runbook
2
+
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.
7
+
8
+ ## Daily use (client)
9
+
10
+ | Do | How |
11
+ |---|---|
12
+ | Open the lanes window | `lanes-window` (own Ghostty instance) — or `lanes-launch` inside any terminal |
13
+ | Second independent view (other monitor) | `lanes-window 2` |
14
+ | Wide view: 2 or 3 viewports side by side per workspace | `lanes-window w2` / `w3` (anarchy: `Super+Shift+Alt+L`). In the sidebar `Enter` opens in the first viewport, `2` / `3` open the lane under the cursor in the second / third; `²` `³` mark what they show |
15
+ | One lane, no multiplexer, native Ghostty scrollback | `lanes-launch lane <lane-id>` (ids: `lanes-launch ls`) |
16
+ | Switch workspace | `Ctrl-Alt-←/→` (zellij tabs) |
17
+ | Next / previous lane | `Ctrl-Alt-↓` / `Ctrl-Alt-↑` (works from inside the lane, in a wide view from every viewport; they move the FIRST viewport and focus ends there) |
18
+ | Find a lane by name | `Ctrl-Alt-/`, type a few letters, `Enter` |
19
+ | Browse the sidebar | click it (or `Ctrl-Alt-p` then `←`), `↑/↓` or `j/k`, `Enter`; selecting a lane focuses it again |
20
+ | Scroll a lane | mouse wheel / tmux copy-mode as before (history lives in the lane, not in the viewer) |
21
+ | Throttle monitor | its own full-size tab, `Throttle` (next to `Host`); it starts when you first open the tab and restarts itself if it exits. It is no longer an entry in every board's sidebar. |
22
+ | Detach | `Ctrl-Alt-o` then `d` — or just close the window. Nothing on the server stops. |
23
+
24
+ Sidebar glyphs: `●` output in the last 20 s · `○` quiet · `–` absent (no such session right now, e.g. a finished
25
+ orchestrator turn) · `!` pane dead · `✖` container down · `?` / a dimmed glyph = not observed right now, last known
26
+ state shown (see "When the host is overloaded") · `👻 unregistered` = an observed tmux session associated with this
27
+ workspace but absent from its registry — nothing alive is ever hidden, and a session whose NAME could not be
28
+ declared (parentheses, `/` and `,` are outside the lane grammar) is listed, opened and closed exactly like any
29
+ other ghost: the grammar governs what may be DECLARED, never what may be SEEN. (`status.json` carries a `note`
30
+ on such a row saying why it cannot be declared; no surface draws it yet.) A session on a socket SEVERAL boards
31
+ declare, whose name claims no board and whose directory is under nobody's roots, is listed by EVERY owner of
32
+ that socket rather than dropped — one id, one row, several sidebars.
33
+ A `·` after a lane's name (up to three) = other sessions VIEW that lane: any session GROUPED onto it, a Throttle
34
+ Studio browser attach (`view-<studio>-<ch>`) included — counted in the lane's `attached`/`viewers` in `status.json`,
35
+ never listed as unregistered (`statusd.viewer_of`; a ghost carries its viewers too, so a browser on an undeclared
36
+ lane is counted there). The `view-` NAME decides nothing: an ungrouped `view-notes`, and a `view-…` whose lane was
37
+ killed under it, are alive and ARE listed — but when a killed lane left SEVERAL grouped survivors, the oldest is
38
+ the lane and the later ones are its viewers, the same choice the Studio makes (`statusd.group_leaders`,
39
+ `inventory::group_leader`). A declared lane is always its own row, whatever it is grouped onto. A Studio lane the
40
+ Studio itself started is `st_<board>_<slug>` on the board's socket and IS shown as `👻` until declared, on a
41
+ socket some board declares or not: discovery probes every live socket in the container.
42
+
43
+ **Unregistered terminals:** every workspace has an **Unregistered** section, including sessions on shared and
44
+ otherwise unlisted tmux servers. Sessions are associated with their existing workspace or working directory;
45
+ changing directory later does not move a discovered session to another board. **New +** creates a persistent
46
+ shell in that workspace. Reopening it attaches to the same shell, preserving running programs and scrollback.
47
+ The old disposable **Remote Shell** entry has been removed. **×** at the right of an unregistered row (or `x`
48
+ with that row selected) permanently terminates that session and clears viewers showing it. A session other
49
+ sessions are GROUPED onto shares its windows with them, so `×` ends the Studio Host's own `view-…` members
50
+ first and otherwise REFUSES, naming what is grouped on: killing the group leader alone leaves every window and
51
+ process running under the survivor, which returns next cycle as a new `👻` (DEF-981). Registered lanes
52
+ have no close control. `+` creates a shell from the keyboard. New and Close are explicit user actions; discovery,
53
+ reattachment, and reconnects never create or close a session.
54
+
55
+ Old plain Docker shells recovered during the transition are labelled **Legacy**. They remain tracked,
56
+ including their existing running jobs. A still-connected viewer continues working; after leaving it, the old
57
+ Docker connection has no normal reattach operation. Selecting that entry explains its status, and × ends the
58
+ shell and its jobs. New + shells do not have this limitation.
59
+
60
+ ## Server (as the workspace account)
61
+
62
+ ```
63
+ lanes doctor full health check lanes status per-workspace counts
64
+ lanes sync re-read registries now lanes ls [--json] lanes with live status
65
+ lanes rebuild <device> recreate a view session lanes ensure recreate missing view sessions
66
+ lanes invariant save|check prove no live pane changed (pid + window size)
67
+ systemctl --user status lanes-statusd lanes-sync.timer lanes-ensure.timer lanes-web
68
+ ```
69
+
70
+ - **A view looks wrong / stuck:** `lanes rebuild mac`. Views are disposable; agents are untouched; the client reconnects.
71
+ - **A lane was added/renamed/removed in a repo's `.vscode/terminals.json`:** nothing to do. `lanes-sync.timer`
72
+ re-reads registries every 2 minutes and the sidebar follows. The zellij layout is never edited live.
73
+ - **A newer layout added a tab (e.g. `Throttle`):** `lanes upgrade-session` adds it to the live sessions without a
74
+ rebuild and leaves you on the tab you were on.
75
+ - **A new workspace:** add it to `~/.config/lanes/workspaces/catalog.json` + a manifest, `lanes sync`, then
76
+ `lanes rebuild <device>` (the only time a layout changes).
77
+ - **Registry invalid:** the workspace keeps its previous lanes; the sidebar footer and `lanes doctor` show why.
78
+
79
+ ## When the host is overloaded
80
+
81
+ 2026-09-19: ceobox thrashed (load 400+ on 64 cores, swap full, memory pressure 68 %); `coder-dev-work` sat at 207 of
82
+ its 208 GiB, 95 GiB of that in its RAM-backed `/tmp`. **No lane was lost** — every pane kept its pid — but the
83
+ harness timed out on its own questions, reported "gone", and the client window closed itself. The rule since then:
84
+ lanes live in the containers' tmux servers and the harness only LOOKS at them; under memory pressure the looking
85
+ fails first. A question that got no answer is never published as "the lane is gone".
86
+
87
+ ### What you see, and what it means
88
+
89
+ | You see | It means | Do |
90
+ |---|---|---|
91
+ | Sidebar footer `⚠ host under memory pressure · status 3 m old` | The one-line `~/.local/state/lanes/health` (rewritten by statusd every cycle) says `level=busy` or `critical`, or has not been rewritten for 30 s. The sidebar measures that age with its OWN clock, so a frozen statusd shows too. The list above it is the last known state, not a live one. | Nothing is lost. `lanes pressure`. |
92
+ | A lane's glyph dimmed, with `?` | That lane's tmux server did not answer in time: its LAST state is kept and marked stale. A bare `?` = never observed yet. `–` (absent) and `✖` (missing) are only ever shown when the server answered, or positively does not exist. | Wait. The lane is running. |
93
+ | Viewport: **THIS VIEWER DISCONNECTED — the lane's tmux server is still running** | The viewer's tmux client was dropped or could not attach in time; the lane's socket is still there. That is ALL this headline claims: the retry below it is what learns whether the session itself survived, and when the holder then answers `is not provisioned`, nothing says the lane is untouched (a lane killed elsewhere — a Throttle Studio `Stop`, an operator's `kill-session` — leaves the server up, and the old headline claimed the lane still ran, DEF-965). The reason and a health hint follow; it retries by itself. | Nothing, unless the retries keep saying `is not provisioned` — then the lane is gone: pick another in the sidebar. |
94
+ | Viewport status screen WITHOUT that line | The lane's tmux socket is really gone. | "A dead lane, or a blind harness?" below. |
95
+ | Client window: `connecting…`, later a hold screen with a countdown | `lanes-launch` never closes the window by itself. ssh failed, or the server dropped a slow client (zellij does that with exit 0), or the server refused (exit 64–78): it holds, re-attaches when the countdown ends, backs off up to 60 s. `Enter` = now; closing the window = quit. Log: `~/.local/state/lanes-client/launch-<target>.log`. | Leave it open. |
96
+ | Upload box `no upload helper` while windows were closing | The helper is a per-machine service now (launchd `ai.beyondwork.lanes-helper` / `systemctl --user status lanes-helper`) and survives the window. It counts as serving while `~/.local/state/lanes-client/helper.alive` is younger than 90 s. | `./install.sh --check` on the client. |
97
+ | A live test suite prints `SKIP host is busy: …`, exit 75 | `tests/*.py` refuse to start above load 2 × cores or memory pressure (some avg10) 20 %: they add load and would fail for the wrong reason. | Run it later (`LANES_TESTS_FORCE=1` only on purpose). |
98
+
99
+ ### Look, without adding load
100
+
101
+ ```
102
+ lanes pressure memory / io / cpu pressure, swap, load, per-container memory (current / max / RAM-backed
103
+ shmem) and /tmp fill. Plain files + statvfs: safe on a thrashing host. Exit 1 unless level ok.
104
+ cat ~/.local/state/lanes/health what statusd wrote last; at= and status_at= are epochs — compare with `date +%s`
105
+ lanes doctor every external call is bounded; "N shown with their last known state" = blind, not dead
106
+ lanes invariant check 1 s: every live pane's pid and size against the last `invariant save`
107
+ ```
108
+
109
+ Levels: `critical` = memory some avg10 ≥ 40 % or full avg10 ≥ 15 %; `busy` = memory some ≥ 10 %, io some ≥ 25 %, or
110
+ load ≥ 1.5 × cores. While the host is busy: no `docker exec`, no `du`, no test suites, no rebuilds, no server
111
+ rollout — one batched ssh instead of many.
112
+
113
+ ### Free the RAM: `lanes tmp`
114
+
115
+ A container's `/tmp` is tmpfs: every byte in it is RAM charged to the container's memory limit (`shmem` in
116
+ `lanes pressure`). `lanes tmp` walks it from the HOST through `/proc/<container-init-pid>/root/tmp` — no docker
117
+ exec, no `du`, nice 19, a 120 s budget per run, and the next run resumes where the last one stopped.
118
+
119
+ ```
120
+ lanes tmp plan [container…] table, largest first + the plan file. Reads only.
121
+ lanes tmp apply [container…] lists what the plan would delete (its STALE units)
122
+ lanes tmp apply [container…] --approve deletes them; each unit is judged AGAIN immediately before it goes
123
+ ```
124
+
125
+ A unit (a top-level `/tmp` entry, or one `claude-<uid>/<project>/<session>`) is kept when ANY of these holds: a
126
+ process has its cwd, an fd or a mapping in it; it holds a socket, fifo or device; something in it belongs to another
127
+ uid; its newest change is younger than 24 h; its Claude transcript was written in the last 48 h; its scan was cut
128
+ short. `tmux-*` and `.lz` are not even walked. A plan older than 6 h, or made for another container start, is
129
+ refused. Everything removed is logged in `~/.local/state/lanes/tmpscan/deleted.log`. The hourly
130
+ `lanes-tmpreap.timer` applies the same rules to what has been idle for 3 days — as a DRY RUN until you arm it with
131
+ `touch ~/.local/state/lanes/flags/tmpreap.armed`.
132
+
133
+ ### The guards
134
+
135
+ - **`lanes-guard.timer`** (user unit, every 60 s, `lanes guard --once`): raises `oom_score_adj` to 800 on compilers,
136
+ linkers and test runners inside the containers, matched by exact `comm` from an allow-list, so the next
137
+ in-container out-of-memory kill takes a build — a build can be re-run, a lane cannot. It only ever raises, never
138
+ touches node / claude / codex / tmux / shells, and is plain `/proc` writes. `lanes guard --dry-run` shows the pass.
139
+ - **`sudo sh ~/.local/share/lanes/current/sudo/host-guard.sh --user "$(id -un)"`** (root, by hand, once): without `--apply`, read-only status plus what `--apply`
140
+ would do. `--apply` sets a `MemoryLow` envelope on `user.slice` → `user-<uid>.slice` → `user@<uid>.service`, so the
141
+ ~1 GiB harness and the login path are not swapped out (a user manager cannot protect itself: its ancestors cap
142
+ it), and installs a 60 s root timer that LOWERS `oom_score_adj` for tmux servers, agents and zellij servers.
143
+ `--undo` puts the recorded values back. It never restarts, stops or signals anything. Reasons: `docs/HOST-GUARD.md`.
144
+ - The harness's own units run in `lanes.slice` with a high `CPUWeight`: tiny, but scheduled while the host thrashes.
145
+
146
+ ### NEVER
147
+
148
+ - Restart or stop a container, docker, or `user@<uid>.service`: every lane dies with its container, every view with
149
+ the user manager. No symptom on this page is fixed by a restart.
150
+ - Touch `/tmp/tmux-1000` inside a container — by hand, by a cleaner, by a "free space" one-liner. Those are the
151
+ lanes' sockets: without them every lane is alive and unreachable.
152
+ - `systemd-oomd` on docker scopes · `memory.oom.group=1` (one OOM then kills the whole container) · `swapoff` under
153
+ pressure · a `memory.max` below current usage (a live OOM sweep) · tightening ANY limit before usage has dropped.
154
+ - Live test suites, `docker exec`, `du` or a server rollout on the busy host.
155
+ - Recreate a lane because the sidebar shows `?`, or because a window showed `[terminated]`.
156
+
157
+ ### A dead lane, or a blind harness?
158
+
159
+ 1. `lanes invariant check`: all pane pids unchanged = nothing died, whatever any screen says.
160
+ 2. The glyph: dimmed / `?` = NOT OBSERVED. `–`, `!`, `✖` = observed — the server answered, or does not exist.
161
+ 3. `lanes doctor`: "N shown with their last known state (tmux server did not answer)" is the blind case. In
162
+ `status.json` those lanes carry `"stale": true` and `"observed": "timeout"` or `"error"`, and their server
163
+ `"outcome": "timeout"`; `absent` / `missing` are published only from `ok` / `noserver`.
164
+ 4. The socket: `ls -l /proc/$(cat ~/.local/state/lanes/containers/<name>.pid)/root/tmp/tmux-1000/` — look, never
165
+ touch. Socket there and the server's pid alive = the lane lives and only the harness is blind.
166
+ 5. `health` older than 30 s (`at=` against `date +%s`) = statusd itself is starved: the sidebar shows history.
167
+
168
+ A lane is dead only on a POSITIVE observation: its server answered without that session (`–`), its pane died (`!`),
169
+ or its container is down (`✖`).
170
+
171
+ ## Switch-over — DONE on this Mac, 2026-09-18
172
+
173
+ - `coder-launch` is the new client (`coder-launch worlds` opens your view on that workspace's tab). The previous
174
+ client is kept as `coder-launch-legacy`; `client/install.sh --rollback` (or `./install.sh --uninstall`) puts it back, and the old remote
175
+ `/home/coder/.config/coder-launch/attach <ws>` would recreate a `cl-<ws>` viewer. Old files are untouched
176
+ (`~/.config/zellij/coder-launch`, snapshot in `~/.local/state/lanes-migration/snapshots/`).
177
+ - Retired: 12 local zellij sessions + 10 stale ones, `wake-guard`, 13 Coder proxy processes; in the containers all
178
+ `cl-*` zellij viewers incl. two orphaned duplicates and one wedged client (SIGKILL needed), and `cl-slowfast`.
179
+ Container memory 212.9 GiB -> ~175 GiB of 208. Every lane pane kept its pid.
180
+ - Coexistence mode is OFF: the view attaches as a normal tmux client and sizes the lane you look at. Other clients
181
+ of a lane (e.g. Cursor terminals) still take part in tmux's `window-size latest`, as always.
182
+ - Still running on purpose: `/home/coder/.config/coder-launch/session-state.py daemon` (the conversation-id ledger).
183
+
184
+ Coexistence mode can be turned back on at any time with `touch ~/.local/state/lanes/flags/coexist`
185
+ (viewers then attach with `ignore-size`; tmux honours it only while another normal client is attached).
186
+
187
+ ## Omarchy / Hyprland client (anarchy)
188
+
189
+ - `Super+Shift+L` opens the lanes window, or focuses it if it is already open (Omarchy's launch-or-focus);
190
+ `Super+Shift+Alt+L` opens the WIDE view (`anarchy-w2`: two viewports per workspace, made for the 7680-px
191
+ monitor at full width; a narrow tiled window should use the normal view). Also in the app launcher as "Lanes".
192
+ - Window class `org.omarchy.lanes`: matched by Omarchy's `+terminal` tag, so `Super+C` / `Super+V` send
193
+ Ctrl+Insert / Shift+Insert (copy/paste) instead of Ctrl+C / Ctrl+V. Links: `Ctrl+Shift+click`.
194
+ - Terminal: Ghostty in a dedicated instance (`~/.config/lanes/ghostty.conf`, inherits `~/.config/ghostty/config`, so
195
+ Omarchy theme switches follow). Foot stays the system default; without Ghostty the window falls back to Foot with
196
+ the same class. Install Ghostty only: `omarchy-pkg-add ghostty` (`omarchy-default-terminal` is not touched).
197
+ - Hyprland: one line `require("hypr.lanes")` at the end of `~/.config/hypr/hyprland.lua` + `~/.config/hypr/lanes.lua`
198
+ (validated with `hyprctl configerrors`; backup `hyprland.lua.pre-lanes.*`). Pin it to a workspace by uncommenting
199
+ the `o.window` line in `lanes.lua`.
200
+ - Path: tailnet (`Host lanes`); after two immediate connect failures the client uses `lanes-public` (same sshd,
201
+ public address, key-only) until the next healthy session.
202
+ - Update: `git pull` in the checkout, then `./install.sh` again. Verify: `./install.sh --check`. Uninstall:
203
+ `./install.sh --uninstall` (removes the Hyprland line and bind file, the launcher entry, the commands, `~/.config/lanes`,
204
+ the uploads watcher and the `Include` line — each edited file is backed up first); then on the server
205
+ `zellij kill-session <device>`.
206
+ - New people: they run `./install.sh` from the package (see `README.md`); it prints their public key, and an admin
207
+ appends that line to `~dev/.ssh/authorized_keys` on the server. Site facts that should not live in the package
208
+ (the public fallback address) are served to authorized clients by `lanes client-config` from
209
+ `~/.config/lanes/site.conf` on the server.
210
+
211
+ ## TUIs and the terminal's colours (Codex's dark box, the "stars")
212
+
213
+ A program in a lane learns the terminal's background by asking (OSC 11); tmux answers with the colour reported by the
214
+ OLDEST attached client that has one — a Cursor terminal or the lanes window — and answers nothing when no client is
215
+ attached. `tests/colors.py` proves that the lanes path (direct and through the view) hands the real colour through.
216
+ Codex samples this ONCE, at its own startup: started under a dark client it paints a dark input box for the rest of
217
+ its life (unreadable once you look at it in light mode, and the other way round at night), plus — `tui.whimsy` — a
218
+ night sky of single-dot Braille "stars". Started with no client attached it paints no box at all.
219
+
220
+ - The stars are off: `[tui] whimsy = false` in `~/.codex/config.toml` of both containers (new/restarted sessions).
221
+ - Codex has no setting for the box. To fix ONE session: restart that Codex (`/quit`, then `codex resume`) while you
222
+ look at it from the window and theme you use. Never restart a working agent for this.
223
+ - It stops recurring when the clients agree on brightness: the same light/dark choice in Cursor's terminal and in the
224
+ lanes window (Ghostty `theme = light:…,dark:…` follows the OS and therefore flips twice a day).
225
+
226
+ ### "tmux server … is not running" on every lane at once (deleted sockets)
227
+
228
+ Seen 2026-09-20 04:24: something emptied `coder-dev-work:/tmp`, and with it `/tmp/tmux-1000` — the directory of every
229
+ lane's socket file. All tmux servers and agents kept running; nothing could attach. How to tell: `lanes sockets`
230
+ lists tmux servers without a socket file, the sidebar footer says *lane sockets deleted*, lanes keep their last
231
+ state with a `?`, and the viewport says the lane runs but its socket file was deleted. Fix (nothing restarts —
232
+ SIGUSR1 is tmux's own "recreate the server socket"): `lanes sockets repair`. Views reattach by themselves;
233
+ Cursor terminals need a reopen. Never `rm -rf /tmp/*` in a workspace container; to free its RAM-backed /tmp use
234
+ `lanes tmp plan` / `apply` / `caches`, which never touch sockets or anything a process has open.
235
+
236
+ ## Cursor / VS Code terminals = the same lanes
237
+
238
+ Cursor uses the Terminals Manager extension, which opens what `<folder>/.vscode/terminals.json` lists. Those files
239
+ stay the source of truth for WHICH lanes a board has (the lanes registry is built from them). `lanes cursor-sync`
240
+ only normalizes HOW a terminal reaches its lane:
241
+
242
+ - every `command` becomes `exec /home/coder/.lz/bin/lane open <lane-id>` — attach; if the lane is missing, run its
243
+ recorded create recipe (the repo's original command, kept in a top-level `lanes` block the extension ignores and
244
+ Throttle Studio's Host only reads, as its `lanes-block` source); refuses to nest inside tmux; holder-agnostic, so a
245
+ later move to zmx / tmux-per-lane changes no editor config.
246
+ - `lanes cursor-sync` = dry run for all boards (prints diffs); `lanes cursor-sync <ws> --diff` = full diff;
247
+ `lanes cursor-sync <ws> --write` applies (backup under `~/.local/state/lanes/cursor-sync-backups/`). The repo then
248
+ shows an ordinary git diff to review and commit. Boards that keep their registry outside a repository
249
+ are additionally projected into the file Cursor reads (`"cursor"` in `workspaces/*.json`).
250
+ - Size policy "the lanes view wins": lanes-view tmux clients run with `TERM=xterm-lanesview` (a user-level terminfo
251
+ alias of xterm-256color). While such a client shows a lane, every other client of that lane (Cursor terminals,
252
+ ad-hoc attaches) is in tmux `ignore-size`; `lanes-statusd` reconciles every few seconds and gives editors the size
253
+ back once no view shows the lane. This covers every socket `observe` reads, the ones no lane declares
254
+ included, and both halves apply there: an `ignore-size` an operator set by hand on such a socket is given
255
+ back like any other once no view shows the lane (DEF-982).
256
+
257
+ ## Throttle Studio as a client
258
+
259
+ Throttle Studio (`throttle-rc studio host` in a workspace container, the `/workspaces` view of the Throttle console)
260
+ is a second client of the SAME lanes — it owns none of ours. It reads `~/.lz/registry.json` first, then each repo's
261
+ `.vscode/terminals.json` (the `throttle.terminals` bindings and our `lanes` block), then `lane ls --json`, then the
262
+ live tmux servers with `-N`; it never writes any of them, never touches a zmx socket directly, and never sweeps
263
+ `/tmp/tmux-1000`. Its ids are ours (studio = board id, lane = `<board>--<slug>`), its status words are `statusd`'s
264
+ (`active` / `quiet` / `dead` / `absent` / `missing` / `unknown` — those six and no others, zmx lanes included;
265
+ `unknown` is `statusd`'s too: a `list-panes` that exits non-zero or hangs gives NO verdict and is never re-spelled
266
+ `absent`, and one wedged server no longer costs the other boards their `status.json` write. The two daemons SPELL
267
+ that no-verdict differently, and the difference is named: `statusd` republishes the lane's LAST entry marked
268
+ `stale` with `observed: timeout|error` — the sidebar draws the last glyph dimmed with a `?` — and reaches
269
+ `unknown` only with no last observation or after `STALE_MAX` 300 s, carrying `observed`, not a sentence; the
270
+ Studio Host publishes `unknown` at once, with the probe's own sentence. See DM-LANES-STATUS in
271
+ `docs/master-datamodel.md`),
272
+ and the two things it does to a board's tmux server are recognisable:
273
+
274
+ - **The viewer rule.** A browser attach is a session GROUPED onto the lane and named `view-<studio>-<ch>`.
275
+ `statusd.viewer_of` treats any UNDECLARED session grouped onto a DIFFERENT session that still LEADS its group
276
+ as a VIEWER of it, whatever the viewer is named: counted in its `attached`/`viewers`, drawn as the `·`
277
+ marks, never listed as `👻 unregistered` (`tests/unit/test_alignment.py`). A DECLARED session is always a lane
278
+ of its own, whatever it is grouped onto — an operator can bind two declared lanes with tmux's own
279
+ `new-session -t <other-lane> -s <name>`, and filing the second as a viewer published the registry's own row
280
+ `absent` while its session was alive (`split_viewers`, DEF-914). The Studio mirrors the same pure rule
281
+ (`tmux::viewer_of`) AND the same declared exemption (`inventory::attached_clients` takes the socket's declared
282
+ session names; it did not, and published one more client than the sidebar for that listing, DEF-961), so the
283
+ sidebar and the Studio never disagree about one session. It attaches `ignore-size`,
284
+ and while a lanes-view client (`TERM=xterm-lanesview`) shows the lane the browser's "take size" is refused with
285
+ "the lanes view owns the size" — the lanes view wins, from both sides: `reconcile_client_sizes` buckets clients
286
+ by the lane they SHOW (so a grouped viewer is flagged too) and never strips the `ignore-size` of a client that is
287
+ BOTH on a grouped session AND named the way the Host MINTS its own — the whole `WebSession::channel_of`
288
+ grammar (`view-<board>-<n>`, `web-<n>`), which `lanes_lib.is_host_view` mirrors, never the `view-`/`web-`
289
+ PREFIX: an operator's own grouped `scratch` client must get its size back like any other (DEF-852), and so
290
+ must a grouped `view-notes` the Host never minted and therefore never un-flags (DEF-980) — over every socket
291
+ `observe` reads, the DISCOVERED ones included (the reconcile used to visit only sockets registered lanes bind,
292
+ so a lanes view showing a `New +` shell or a Studio lane on a Host-owned socket never had its later-arriving
293
+ editor clients flagged, DEF-964; a discovered socket is governed by BOTH halves, so a client an operator had
294
+ put into `-f ignore-size` by hand on such a socket is un-flagged there too — DEF-964's "changes nothing" was
295
+ false, DEF-982) —
296
+ and the Host flags its OWN client and holds the channel in `ignore` when a lanes view arrives later — on ITS OWN
297
+ view tick, not only when the browser next resizes, and it gives its client the size back (`!ignore-size`) when
298
+ no lanes view shows the lane, which `reconcile_client_sizes` deliberately leaves to it. Closing the
299
+ browser tab destroys only its `view-*` session.
300
+ - **Studio-started lanes** are ordinary tmux sessions named `st_<board>_<slug>` on the board's own socket, so
301
+ they appear as `👻 unregistered` until someone declares them in the repo's `terminals.json` and `lanes cursor-sync`
302
+ runs. The Studio does not write that declaration. Stopping one from the browser kills its `view-*` members first,
303
+ then the exact session name — nothing else on the socket. The Stop cannot reach this host's view state (the
304
+ `!empty` target + `SIGUSR1` the sidebar's own close writes), so a viewport that was showing that lane does NOT
305
+ end: it falls back to its status screen and retries the gone session (`is not provisioned`, above) until the
306
+ operator picks another lane — or, with the operator's `detach-on-destroy off`, tmux moves the client to another
307
+ session on the same socket and the viewport silently shows a different lane. DEF-965.
308
+
309
+ The Studio's design of record is `docs/design/throttle-studio.md` (§1.1 "The lanes harness", §8.3, §8.5).
310
+
311
+ ## Uploads (the Cursor `uploads/` folder, without Cursor)
312
+
313
+ Files land in `<board folder>/uploads/` on the server — the same folder you drop into from Cursor. The folder is
314
+ created on first use and kept out of commits through the repo-local `.git/info/exclude` when the repo does not
315
+ already ignore it. Afterwards the in-container path is on your clipboard and in a notification.
316
+
317
+ | Way | How |
318
+ |---|---|
319
+ | Upload box in the sidebar | Click the box (or `Ctrl-Alt-u` from anywhere in the tab), then **drop the files anywhere on the window**. The file goes to the board of that tab and its path is typed into the lane, ready for the agent. `Esc` cancels. |
320
+ | Watch folder | `~/Lanes Uploads/` → the board your view shows; `~/Lanes Uploads/<board>/` → that board. Sent files move to `.sent/<date>/`. Point CleanShot's save folder here for instant screenshot upload. |
321
+ | Command | `lanes-upload [-w <board>] [--paste] <files…>` · `lanes-upload --clipboard [--paste]` (clipboard image or copied files) |
322
+ | macOS droplet | `~/Applications/Lanes Upload.app`: drop files on it (Dock); double-click uploads the clipboard screenshot |
323
+ | anarchy | `Super+Shift+U` uploads the clipboard and types the path into the lane |
324
+
325
+ Why click first: a terminal delivers a drop as a *paste of the local path* into the FOCUSED pane (not the pane under
326
+ the pointer), so the box has to hold the focus when you let go. And the server only ever receives that path string:
327
+ the file itself is sent by the **upload helper** that `lanes-launch` runs on your machine. The helper is the security
328
+ boundary — it reads only from folders listed in `~/.config/lanes/upload-allow` (defaults: Desktop, Downloads,
329
+ Pictures, Documents, Lanes Uploads, temp folders; macOS asks once per new folder; `lanes-upload --allow <dir>`), and
330
+ never from `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.kube`, `~/.config`, keychains or `.git`. A window opened before this
331
+ feature has no helper: reopen it once.
332
+
333
+ On macOS the background service runs as `~/Applications/Lanes Helper.app`. This gives it an identity for macOS
334
+ folder permissions: choose **Allow** when it asks for Downloads, Desktop or Documents, or enable the folder under
335
+ System Settings → Privacy & Security → Files and Folders → Lanes Helper. Full Disk Access is not needed. The
336
+ allowlist above and macOS's folder permission are separate checks. Older installs ran an anonymous `/bin/sh`
337
+ from launchd: macOS silently denied protected folders, tar produced an empty archive, and the box said "nothing
338
+ arrived" even for small files. Re-run `./install.sh` to install the app-backed helper.
339
+
340
+ The helper logs each transfer and the original error in `~/.local/state/lanes-client/helper-service.log` (Linux:
341
+ `journalctl --user -u lanes-helper`). The sidebar shows received MiB and times out after two minutes **without
342
+ incoming data**, so an active large upload can run longer. The service heartbeat also stays fresh while uploading
343
+ or waiting for a macOS permission prompt. Archive/SSH failures remain failures, and failed watch-folder uploads
344
+ keep their source files for a retry.
345
+
346
+ ## Web / phone
347
+
348
+ The [installation guide](INSTALL.md#4-optional-safari--home-screen-access) owns setup. After the server has
349
+ Caddy, Tailscale and operator permission, run `sh install/enable-phone.sh --host lanes` from an installed
350
+ client, or `sh install/enable-phone.sh --local` on the host. The remote path uses the selected SSH key and
351
+ the installed checkout shortcut, with no password fallback or agent forwarding. The token is printed only
352
+ in the interactive caller's terminal. Existing tokens remain valid.
353
+
354
+ On the phone, connect Tailscale and open the printed URL. Paste the token into the sign-in screen and choose
355
+ **Share → Add to Home Screen**. Use **Workspaces**, **Lanes** and **Panes** to navigate. The keyboard opens only
356
+ when tapping **Type here** or another text input; swipes, live updates and reconnects leave it closed.
357
+ **More → Text size** remembers your preferred terminal font. The phone has its own view selection of the same agents.
358
+
359
+ Tailscale Serve forwards HTTPS 8443 to `lanes-mobile.service` on `127.0.0.1:8083`. The separate unprivileged
360
+ Caddy serves `web/` and forwards native authentication/assets/WebSockets to Zellij on `127.0.0.1:8082`.
361
+ The Mac is a client; serving happens on the workspace host. See [frontend maintenance](../web/README.md).
362
+
363
+ `lanes prepare phone --mobile` creates the view without attaching a terminal. Hidden mobile views wait for
364
+ a real client before attaching; visible views accept 20 columns / 4 rows for the on-screen keyboard.
365
+ Revoke a named token with `~/.local/share/lanes/bin/zellij-0.45.1 web --revoke-token NAME`.
366
+ An SSH-only phone client can run `lanes attach phone --mobile` instead.
367
+
368
+ ## Safety rules baked into the code (do not remove)
369
+
370
+ - tmux is always called with `-N` (`attach-session`, `new-session` and `start-server` on a missing socket start
371
+ a phantom server; the read-only commands exit 1) and `env -i`.
372
+ - In-container sockets are reached through `/proc/<container-init-pid>/root/…`, verified to be a docker cgroup.
373
+ - The holder `exec`s the tmux client, so a lane switch detaches it instead of orphaning it on the pty.
374
+ - pty-name collision guard: the lanes' `client-attached` hook detaches a client whose tty *name* equals the
375
+ lane's `pane_tty`; host and container pty numbers collide, so the holder re-wraps itself on a fresh pty.
376
+ - Views attach only from a pane zellij has really sized (unviewed tabs are 14x49) and only after a first client.
377
+ - The sidebar binds in `zellij/config.kdl` walk `MoveFocus "left"` once per possible viewport before `WriteChars`:
378
+ with a single left, `Ctrl-Alt-↓` pressed in the second viewport typed a `J` into a lane.
379
+ - Tests: `python3 -m unittest discover -s tests/unit` needs no server (runs on the Mac) and carries the harness's
380
+ pure-function pins — the viewer rule, the client-size bucketing, the status keying, the shared-socket
381
+ attribution and the zmx word (`tests/unit/test_alignment.py`). Live suites, on a QUIET host only:
382
+ `tests/guards.py`, `tests/view.py` (incl. the wide-view key walk), `tests/upload_box.py`,
383
+ `tests/smoke_real.py` (read-only, real lanes), `tests/latency.py` (from a client). All use disposable lanes except
384
+ the explicitly read-only smoke test; every one refuses to start on a busy host (exit 75) and removes its lanes and
385
+ sessions when it is killed with SIGTERM / SIGHUP.
@@ -0,0 +1,124 @@
1
+ # Share Lanes through Throttle
2
+
3
+ **Candidate feature:** requires a platform and Studio Host built with Lanes
4
+ sharing support (Studio tunnel revision 1.3 and migration 0039). Workspace
5
+ exposure is off by default. This page does not enable anyone's workspaces.
6
+
7
+ The Throttle console's **Shared lanes** page shows workspaces shared with the
8
+ signed-in person. Select a workspace, then a registered lane to read its current
9
+ terminal output. The page refreshes every five seconds and works on desktop and
10
+ mobile Safari. Scrolling and refreshes do not summon the keyboard.
11
+
12
+ ## Connect your Lanes installation
13
+
14
+ Use your organization's existing Throttle SSO URL. Viewers need access to that
15
+ URL and an account in the same organization; they do not need your SSH key,
16
+ Zellij token, or a direct connection to your computer. The Host makes an outbound
17
+ connection to Throttle. If Throttle is on a private tailnet, viewers also need
18
+ access to that tailnet.
19
+
20
+ Install matching `throttle-rc` platform and Host builds using your normal
21
+ Throttle deployment procedure. Back up the platform database before applying
22
+ migration 0039. Run the Host as the account that can read your Lanes registry and
23
+ tmux sockets, then enroll it once:
24
+
25
+ ```sh
26
+ throttle-rc studio enroll https://throttle.example.com
27
+ ```
28
+
29
+ An existing in-container Studio Host reads `~/.lz/registry.json` by default.
30
+ That is the registered-lane projection written by `lanes sync`. No additional
31
+ configuration is needed for this source.
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`:
36
+
37
+ ```toml
38
+ server = "https://throttle.example.com"
39
+ machine_id = "lanes-server"
40
+
41
+ [inventory]
42
+ sharing_state_dir = "~/.local/state/lanes"
43
+ sharing_container_uid = 1000
44
+ legacy_coder_launch = false
45
+ ```
46
+
47
+ `sharing_state_dir` must name the directory used by `lanes sync` and
48
+ `lanes-statusd`. Change `sharing_container_uid` if the workspace's tmux owner
49
+ uses a different UID. The host-side adapter requires Linux, Docker inspection
50
+ access, and permission to read sockets through `/proc/<container-pid>/root`.
51
+ It verifies the live container PID before and after a capture.
52
+
53
+ ```sh
54
+ throttle-rc studio host --check
55
+ throttle-rc studio host
56
+ ```
57
+
58
+ Keep the Host running through your normal process supervisor. Enrollment is
59
+ persistent: the Host proves its own machine key and does not borrow a human
60
+ SSO session or need an eight-hour renewal. Keep its key and enrollment receipt
61
+ private and persistent across upgrades.
62
+
63
+ The enrollment command prints a short code. In the signed-in console, open
64
+ **Workspaces → Machines → Approve a machine**, enter that code, and approve.
65
+ The terminal stores the enrollment receipt; compare its label and fingerprint
66
+ with the console receipt. See the [Host enrollment runbook](../../docs/runbooks/studio-host-enrollment.md)
67
+ for revocation, checks and upgrades. Sharing is a separate configuration below.
68
+
69
+ The pre-merge sharing candidate used migration 0037 for different SQL and an
70
+ eight-hour Host session. If you deployed that candidate, use the
71
+ [candidate upgrade procedure](../../docs/runbooks/lanes-sharing-candidate-upgrade.md)
72
+ before deploying current builds. The normal migrator correctly refuses that
73
+ journal; do not edit its digest or reset the database.
74
+
75
+ ## Enable publishers, then expose workspaces
76
+
77
+ 1. Sign in to Throttle and open **Shared lanes → Manage sharing**.
78
+ 2. An organization administrator selects eligible people or teams under
79
+ **Who may share workspaces**, then saves publisher access.
80
+ 3. An eligible owner expands a workspace, checks **Expose this workspace**,
81
+ selects people or teams, and saves. The audience preview shows the selection.
82
+ 4. Share the workspace's ordinary `/shared-lanes/<id>` URL with those people.
83
+ Opening a URL still requires SSO and a current grant.
84
+
85
+ Each workspace is private until enabled. Direct user grants and current team
86
+ memberships are additive. Administrators can manage publisher eligibility but
87
+ do not automatically receive access to other people's output. Removing
88
+ exposure, eligibility, or membership denies subsequent reads; an open viewer
89
+ clears on its next refresh. Previously read output cannot be recalled.
90
+
91
+ ## What viewers can see
92
+
93
+ - The selected workspace's current and future **registered lanes**. Unregistered
94
+ sessions, legacy shells, extra/tool tabs and unrelated workspaces are excluded.
95
+ - Lane names and observed activity; “active” describes recent terminal output,
96
+ not a build verdict.
97
+ - For tmux lanes, a selectable pane and its latest 2,000 lines, capped at
98
+ 256 KiB. Unsupported holders show metadata without terminal output.
99
+
100
+ Output is escaped plain text. There is no input, upload, start/stop, resize,
101
+ PTY attach, or interactive terminal connection. Snapshots are not a log archive:
102
+ they stay briefly in platform memory, never in the database or browser storage.
103
+ Full raw terminal output in the selected pane can include sensitive information;
104
+ choose workspace audiences accordingly.
105
+
106
+ If output disappears, check the owner's Host connection, current SSO session,
107
+ `lanes sync`, `lanes-statusd`, and sharing settings. Old Hosts without revision
108
+ 1.3 cannot serve snapshots; update the Host instead of sharing a personal
109
+ interactive `/phone` endpoint.
110
+
111
+ ## Validation
112
+
113
+ Focused regression coverage lives in the platform's `pg_http_studio` sharing
114
+ 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:
116
+
117
+ ```sh
118
+ node --test crates/throttle-platform/src/http/console/views/shared-lanes/view.test.mjs
119
+ ```
120
+
121
+ The `Lanes sharing` GitHub Actions workflow runs the focused Rust, PostgreSQL,
122
+ real tmux and UI checks. Its manual `candidate_image` option also produces a
123
+ checksum-pinned Docker image and matching Host binary after those checks pass.
124
+ It does not deploy, publish a release, or change anyone's sharing settings.
@@ -0,0 +1,51 @@
1
+ # zmx ring 0 — gate report (2026-09-18)
2
+
3
+ Scope: zmx 0.8.1 (pinned, GitHub asset digest `dfd75720…ebe8`) as the per-lane pass-through holder, exercised ONLY on
4
+ disposable lanes `lz-test-*` created through the real contract (`lane new` inside `coder-dev-work`) and attached from
5
+ the host through `holders/zmx.sh`, plus a host-only sandbox for the daemon-count soak. Reproduce: `tests/zmx_ring0.py`.
6
+ The live-lane invariant (170 panes: pid + window size) held before and after every run.
7
+
8
+ | Gate | Result |
9
+ |---|---|
10
+ | Create through the contract; host reaches the socket via `~/.lz/<container>` (40-byte path, limit 108) | pass |
11
+ | Agent environment defined by `lane-run` (LANE_ID set; no XDG_CACHE_HOME / SSH_* / ZELLIJ / TMUX leak) | pass |
12
+ | Missing lane: refused (exit 66); nothing created on the host (`/bin/false` create-guard + socket precheck) | pass |
13
+ | Pass-through I/O host pty -> container lane | pass |
14
+ | `lane send` / `lane capture` (automation contract replacing tmux send-keys / capture-pane) | pass |
15
+ | Re-attach replays screen + scrollback into the viewer (1499 of 1500 lines, 18.6 KB) | pass |
16
+ | 50 attach/detach switches: always the right lane, zero leaked clients | pass |
17
+ | zmx #215 scenario: 200 column-halving resizes over dense OSC 8 hyperlinks, 3 clients | daemon survived (same pid) |
18
+ | Lanes never die: agent exit -> login shell; shell exit -> still there (`lane-run` loops) | pass |
19
+ | Daemon cost | ~4 MB RSS with a full scrollback; 100 daemons = 351 MB, create 22 ms each, `list` 19 ms |
20
+
21
+ Characterised, not failures:
22
+ - **Size policy**: the client that typed last leads (60x20 then 140x40 observed). One active device is fine; a wall of
23
+ live attaches is not — the wall tab must use snapshot tiles (`lane capture --vt`), as planned.
24
+ - **`zmx list` rows are indented / marked**: parse keys stripped. The socket file is the liveness signal.
25
+ - **A session ends when its root command ends** (contrary to the README): `lane-run` must never exit — it does not.
26
+ - **Logs never rotate**: ~260 KB per torture minute for three lanes -> `lanes gc` truncates them (daily timer).
27
+ - Not yet exercised here: kitty-keyboard/mouse-mode restore inside a zellij pane with a real codex/claude TUI, Android
28
+ touch (#263), output before the first client (#246/#264 — `lane new` always has its creation client attached).
29
+
30
+ ## Terminal niceties through the chain (measured with `tests/niceties.py`, `tests/osc8_zmx.py`)
31
+
32
+ What the client terminal actually receives, lane -> holder -> host zellij -> client:
33
+
34
+ | Nicety | tmux-held lane (all lanes today) | zmx-held lane |
35
+ |---|---|---|
36
+ | 24-bit colour fg/bg, italic, strike, bold/dim, emoji, box drawing | yes | yes |
37
+ | Curly / double / coloured underlines | yes | yes |
38
+ | OSC 8 hyperlinks (cmd/ctrl-click targets) | yes, and they survive lane switches (tmux re-emits them) | live: yes. **After a re-attach: text only, the link target is lost** (zmx snapshot replay and `history --vt` drop OSC 8) |
39
+ | Plain URLs (terminal-side detection) | yes | yes |
40
+ | Copy from tmux copy-mode -> system clipboard (OSC 52) | yes | n/a (zellij selection/copy instead) |
41
+ | App-emitted clipboard write (OSC 52) | no (tmux `set-clipboard external`; unchanged from before) | yes |
42
+ | Agent desktop notifications (OSC 9) | no (tmux drops them; unchanged from before) | yes |
43
+
44
+ Every lane switch is a re-attach, so with zmx a link already on screen stops being clickable after switching away
45
+ and back (plain URLs keep working). This is a real fidelity gap for ring 1; candidates: upstream fix in zmx's replay,
46
+ or the tmux-per-lane fallback, which keeps links and only needs `set-clipboard on` + passthrough for the other two rows.
47
+
48
+ Decision: **ring 0 passed.** Ring 1 (5–10 low-stakes real lanes for two weeks) needs the user's go-ahead because it
49
+ restarts agents, and should wait until the viewer switch-over has freed memory in the container (it sits at its
50
+ 208 GiB memcg limit today). Fallback holder if ring 1 disappoints: tmux-per-lane (`tmux -L lane_<id>`) behind the same
51
+ `lane` contract.
@@ -0,0 +1,21 @@
1
+ #!/bin/sh
2
+ # View-owned displays; interactive shells are persistent tmux sessions created by New +.
3
+ # usage: exec.sh <container> monitor|legacy [cwd-or-id]
4
+ set -u
5
+ container=${1:-} kind=${2:-} cwd=${3:-/home/coder}
6
+ case "$container" in ''|-*|*[!A-Za-z0-9_.-]*) echo 'invalid container' >&2; exit 64 ;; esac
7
+ if [ "$kind" = legacy ]; then
8
+ repo=$(cd "$(dirname "$0")/.." && pwd)
9
+ exec python3 -S -E "$repo/libexec/legacy_sessions.py" view "$container" "$cwd"
10
+ fi
11
+ case "$cwd" in /*) ;; *) cwd=/home/coder ;; esac
12
+ case "$cwd" in *[!A-Za-z0-9_./-]*) cwd=/home/coder ;; esac
13
+ common="-it -u coder -e TERM=xterm-256color -e COLORTERM=truecolor -e LANG=C.UTF-8"
14
+ case "$kind" in
15
+ monitor)
16
+ # Same measured four-worker pilot the previous viewer used.
17
+ # shellcheck disable=SC2086
18
+ exec docker exec $common -e TOKIO_WORKER_THREADS=4 -w /home/coder "$container" \
19
+ /home/coder/.local/bin/throttle monitor ;;
20
+ *) echo "unknown helper: $kind" >&2; exit 64 ;;
21
+ esac