@vibelord/lanes 1.0.0 → 1.0.2

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 (56) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +92 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +928 -150
  5. package/payload/lanes/client/ghostty/common.conf +3 -0
  6. package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
  7. package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
  8. package/payload/lanes/client/ghostty/omarchy.conf +3 -2
  9. package/payload/lanes/client/lanes-launch +62 -5
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +8 -0
  12. package/payload/lanes/container/hooks/claude-settings.json +10 -0
  13. package/payload/lanes/container/hooks/codex-hooks.json +22 -0
  14. package/payload/lanes/container/lane +326 -25
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +158 -6
  17. package/payload/lanes/docs/DESIGN.md +250 -0
  18. package/payload/lanes/docs/INSTALL.md +39 -0
  19. package/payload/lanes/docs/RUNBOOK.md +507 -24
  20. package/payload/lanes/docs/SHARING.md +1 -1
  21. package/payload/lanes/holders/exec.sh +2 -5
  22. package/payload/lanes/holders/ssh.sh +18 -0
  23. package/payload/lanes/holders.lock +3 -0
  24. package/payload/lanes/install/enable-phone.sh +15 -6
  25. package/payload/lanes/install/enable-web.sh +19 -6
  26. package/payload/lanes/install/host-install.sh +10 -1
  27. package/payload/lanes/install/services.py +30 -3
  28. package/payload/lanes/install.sh +108 -3
  29. package/payload/lanes/libexec/agent_state.py +246 -0
  30. package/payload/lanes/libexec/connection.py +178 -0
  31. package/payload/lanes/libexec/cursor_sync.py +147 -6
  32. package/payload/lanes/libexec/device_sync.py +134 -0
  33. package/payload/lanes/libexec/economy_poll.py +685 -0
  34. package/payload/lanes/libexec/guard.py +27 -1
  35. package/payload/lanes/libexec/lanes_lib.py +436 -36
  36. package/payload/lanes/libexec/notify.py +317 -0
  37. package/payload/lanes/libexec/registrations.py +148 -0
  38. package/payload/lanes/libexec/session_actions.py +53 -22
  39. package/payload/lanes/libexec/sidebar.py +1190 -186
  40. package/payload/lanes/libexec/statusd.py +352 -90
  41. package/payload/lanes/libexec/tokens.py +310 -0
  42. package/payload/lanes/libexec/tokens.sh +11 -0
  43. package/payload/lanes/libexec/view.sh +22 -11
  44. package/payload/lanes/systemd/lanes-economy.service +20 -0
  45. package/payload/lanes/systemd/lanes-notify.service +19 -0
  46. package/payload/lanes/systemd/lanes-sync.service +3 -0
  47. package/payload/lanes/web/README.md +96 -2
  48. package/payload/lanes/web/browser.css +22 -0
  49. package/payload/lanes/web/browser.js +73 -0
  50. package/payload/lanes/web/index.html +2 -1
  51. package/payload/lanes/web/mobile.css +108 -221
  52. package/payload/lanes/web/mobile.js +105 -55
  53. package/payload/lanes/workspaces.example/README.md +9 -3
  54. package/payload/lanes/zellij/config.kdl +128 -19
  55. package/payload/manifest.json +7 -3
  56. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -14,22 +14,63 @@ original names and measurements; substitute your account, containers and paths i
14
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
15
  | One lane, no multiplexer, native Ghostty scrollback | `lanes-launch lane <lane-id>` (ids: `lanes-launch ls`) |
16
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) |
17
+ | 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 |
18
19
  | Find a lane by name | `Ctrl-Alt-/`, type a few letters, `Enter` |
19
20
  | Browse the sidebar | click it (or `Ctrl-Alt-p` then `←`), `↑/↓` or `j/k`, `Enter`; selecting a lane focuses it again |
21
+ | 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 |
20
22
  | Scroll a lane | mouse wheel / tmux copy-mode as before (history lives in the lane, not in the viewer) |
21
23
  | 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
24
  | Detach | `Ctrl-Alt-o` then `d` — or just close the window. Nothing on the server stops. |
23
25
 
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.
26
+ Sidebar glyphs: see the legend in [`docs/DESIGN.md`](DESIGN.md) (agent column `◆ ◇ ▸ ✓ ✗ _ ·`, terminal column
27
+ `● ○ – ! ✗ · ?`, every glyph with its ASCII twin, the token table and the modes); a dimmed glyph with `?` in column 34
28
+ means not observed right now, last known state shown (see "When the host is overloaded"). By row column (spec §4.2;
29
+ every row is 36 cells and column 36 is never written):
30
+
31
+ | Column | Shows |
32
+ | --- | --- |
33
+ | 1 | `▌` the cursor (no underline) |
34
+ | 2 | the agent, from `status.json`'s additive `agent{}` (never from `state`): `◆` approval prompt blocking · `✗` errored · `◇` turn finished, waiting for your prompt · `▸` working · `✓` done, until you open the lane from this device (`clients/<device>.seen.json`) · `_` harness ended into a shell · `·` harness up, no turn · blank = a plain shell; dimmed when the lane is stale |
35
+ | 3 | `›` the lane this pane shows — never on the cursor row, where the bold name says it · `²` / `³` shown in pane 2 / 3 of a wide view |
36
+ | 4–27 | the name in its identity colour, bold while this pane shows it; up to three `·` (other sessions view it) are reserved first, and a longer name ends in `…` |
37
+ | 28 | `+` on an unregistered tmux session that can be added to a workspace (click it, or `a`) |
38
+ | 29–31 | the age: the agent's `since`, else the last output — `45s`, ` 3m`, ` 2h`, ` 4d` (at most `99d`); blank when nothing is known; amber inside **Needs you** |
39
+ | 33 | the stream: `●` output in the last 20 s · `○` quiet · `–` absent (no such session right now, e.g. a finished orchestrator turn) · `!` pane dead · `✗` container down · `·` helper · `?` never observed; dimmed when an agent glyph sits in column 2 (`▸` + `○` is a long tool call, not a stuck lane) or when stale |
40
+ | 34 | one tail cell by priority: `?` not observed right now, last known state shown (see "When the host is overloaded") > `⚠` starving (the pane is `active` but the proxy saw no request from the lane for over 600 s, or a 429 in its recent-request ring) > `↓` downgraded in the last 5 min > `$` more than half its recent requests paid. The three economy cells come from `economy.json` ("Economy projection" below); a lane the poller has not joined — or no poller — has a blank cell |
41
+ | 35 | `×` close, on unregistered sessions only: it asks ` › close ‘name’? y/n` first; `y` closes, any other key cancels |
42
+
43
+ The cursor is a lane, not a row: when a lane changes section (it starts needing you, its turn finishes, you open a
44
+ `done` lane) the cursor and the scroll position go with it. A list that fits the pane is never scrolled: its
45
+ first rows stay on screen.
46
+
47
+ Title and notifications (spec §4.9): the sidebar pane's title is `WORKSPACE · ◆1 ◇2` (the header counts, rewritten
48
+ only when they change; a workspace without agents never sets one) — the phone is built to read its counts from it.
49
+ Since plan Task 26 the phone's picker pane has no fixed layout name, so Zellij can report that title — but only in a
50
+ session laid out after Task 26: an existing phone device keeps the old picker pane until `lanes rebuild <device>`
51
+ (`upgrade-session` and `ensure` add missing tabs and rename titles, they never re-lay-out a tab; `lanes doctor` names
52
+ such a device). Whether it
53
+ reaches the phone is still unchecked in a browser (DEF-1450). A lane that
54
+ STARTS needing you (`◆` `✗` `◇`) raises one desktop notification, `name: task`, through OSC 9 — once per transition,
55
+ never for the lane this pane shows, never on a phone, and not at all under `LANES_NOTIFY=0`. A sidebar that starts
56
+ (or restarts) while lanes already need you announces each of them once. Both are bytes in the frame the sidebar
57
+ already writes: no process is started. Ghostty shows OSC 9 only while the lanes window is NOT focused
58
+ (`desktop-notifications = true` in `client/ghostty/common.conf`); on a terminal or Zellij build that drops OSC 9 the
59
+ notification is a silent no-op and nothing else changes. Mac check: not yet run (the plan's Task 25 step 4 asks for one
60
+ on the Mac — record the Zellij and Ghostty versions and the outcome here).
61
+
62
+ Header: the workspace title, then `◆1 ◇2 ▸6 ✓1` in the column-2 glyphs (zeros omitted; `N lanes` without agents;
63
+ dimmed while the footer warns that status is old). Sections: **Needs you** (◆, ✗, ◇ — oldest first), **Working**
64
+ (oldest first), **Done** (newest first), then the workspace's other lanes under its own header — only when an agent
65
+ section exists; a filter flattens them and keeps column 2. A list longer than the pane ends in `⋮ N more`; moving onto
66
+ that row, or clicking it, scrolls instead of opening the lane behind it. **Unregistered** lists every observed tmux
67
+ session associated with this workspace but absent from its registry — nothing alive is ever hidden, and a session
68
+ whose NAME could not be declared (parentheses, `/` and `,` are outside the lane grammar) is listed, opened and closed
69
+ exactly like any other ghost: the grammar governs what may be DECLARED, never what may be SEEN. (`status.json`
70
+ carries a `note` on such a row saying why it cannot be declared; no surface draws it yet.) A session on a socket
71
+ SEVERAL boards declare, whose name claims no board and whose directory is under nobody's roots, is listed by EVERY
72
+ owner of that socket rather than dropped — one id, one row, several sidebars. Design of record:
73
+ `docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md` §3–§4.
33
74
  A `·` after a lane's name (up to three) = other sessions VIEW that lane: any session GROUPED onto it, a Throttle
34
75
  Studio browser attach (`view-<studio>-<ch>`) included — counted in the lane's `attached`/`viewers` in `status.json`,
35
76
  never listed as unregistered (`statusd.viewer_of`; a ghost carries its viewers too, so a browser on an undeclared
@@ -37,7 +78,7 @@ lane is counted there). The `view-` NAME decides nothing: an ungrouped `view-not
37
78
  killed under it, are alive and ARE listed — but when a killed lane left SEVERAL grouped survivors, the oldest is
38
79
  the lane and the later ones are its viewers, the same choice the Studio makes (`statusd.group_leaders`,
39
80
  `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
81
+ Studio itself started is `st_<board>_<slug>` on the board's socket and IS listed under **Unregistered** until declared, on a
41
82
  socket some board declares or not: discovery probes every live socket in the container.
42
83
 
43
84
  **Unregistered terminals:** every workspace has an **Unregistered** section, including sessions on shared and
@@ -52,10 +93,23 @@ process running under the survivor, which returns next cycle as a new `👻` (DE
52
93
  have no close control. `+` creates a shell from the keyboard. New and Close are explicit user actions; discovery,
53
94
  reattachment, and reconnects never create or close a session.
54
95
 
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.
96
+ To keep an unregistered tmux session in a workspace's regular lane list, click/tap **+** beside it (or select it
97
+ and press `a`), then choose a workspace. Only workspaces using its container are offered. **Cancel** or Esc leaves
98
+ it unregistered. This preserves its name, running programs, scrollback and active views. It appears immediately
99
+ and survives sync and reconnects. Registered lanes have no close control.
100
+
101
+ From the server CLI, use `lanes ls --unregistered` to find the ID, then
102
+ `lanes register <lane-id> [workspace-id]`; omitting the workspace keeps the current assignment.
103
+ The site's version-1 `registered-sessions.json` is durable configuration: back it up with the catalog.
104
+ To undo registration, remove its record from `sessions` and run `lanes sync`; the terminal keeps running.
105
+ If publication fails after saving, `lanes sync` finishes it. Invalid/newer file versions are refused without
106
+ overwriting the file. These registrations have no restart recipe: an ended session stays absent until the operator
107
+ starts it. Add a repository terminal declaration when editor integration and a saved create recipe are wanted.
108
+
109
+ The tracker for plain Docker shells from before persistent terminals (**Legacy** rows) is retired (plan Task 19):
110
+ statusd no longer reads `~/.local/state/lanes/legacy-sessions/` and the sidebar no longer lists or closes those
111
+ shells. `lanes doctor` names any record still there (`⚠ legacy-sessions/ holds N records …`); end that shell
112
+ inside its container by hand, then delete the record.
59
113
 
60
114
  ## Server (as the workspace account)
61
115
 
@@ -64,14 +118,64 @@ lanes doctor full health check lanes status per-wor
64
118
  lanes sync re-read registries now lanes ls [--json] lanes with live status
65
119
  lanes rebuild <device> recreate a view session lanes ensure recreate missing view sessions
66
120
  lanes invariant save|check prove no live pane changed (pid + window size)
121
+ lanes device list the devices ensure keeps lanes device forget <name> stop recreating one
67
122
  systemctl --user status lanes-statusd lanes-sync.timer lanes-ensure.timer lanes-web
68
123
  ```
69
124
 
125
+ - **What `lanes doctor` says about the boards' recipes (plan Task 18):** one line counts the tmux lanes per create
126
+ recipe class — `idempotent` (a `tmux new -A…`: creates, or attaches when the session exists; the canonical recipe),
127
+ `attach-only` (`attach-session` and no `new`), `null` (no recipe), `external` (a script or anything else, run as
128
+ 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
130
+ unchanged): lanes that `lane open` cannot recreate once they end (attach-only, null, retired-path — fix with
131
+ `lanes cursor-sync --canonicalize`), lanes bound to a numeric session name (tmux numbers unnamed sessions itself,
132
+ so a recreated one gets another number), more than 4 lanes on one container's tmux socket `default` (the server every
133
+ plain `tmux` typed in that container talks to), and tmux socket files in a container's `/tmp/tmux-1000` that no
134
+ running tmux server (read from the container's cgroup) owns. That last one is **report only**: the host never
135
+ removes a file in a container. Inside the container, `~/.lz/bin/lane gc --sockets` probes each socket file with
136
+ `tmux -N -S <path> list-sessions` and, where tmux fails, connects to the file once itself: only ECONNREFUSED (the
137
+ file is there, nobody listens) unlinks it, printing each path; a server that answers (even with no session), does
138
+ not answer within 5 s, or refuses for another reason (EACCES, …) keeps its file. The errno decides, never tmux's
139
+ 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).
141
+ - **A device you no longer use (an old laptop, a phone you replaced):** `lanes device forget <name>` removes
142
+ `devices/<name>.json`, so `lanes ensure` (`lanes-ensure.timer`) stops recreating its view session and `sync` /
143
+ `upgrade-session` stop updating it. A view session that is running is left alone and says so; once it ends, nothing
144
+ brings it back. `lanes attach <name>` / `lanes prepare <name>` creates the device again. `lanes device list` shows
145
+ each device, phone or desktop (viewports), and whether its view session runs.
146
+
70
147
  - **A view looks wrong / stuck:** `lanes rebuild mac`. Views are disposable; agents are untouched; the client reconnects.
71
148
  - **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.
149
+ re-reads registries every 2 minutes and the sidebar follows. The zellij layout is never edited live. A sync that
150
+ finds nothing new leaves `registry.json` (and its `generated_at`) untouched and only records the time in
151
+ `registry.synced`; the unit is bounded at `TimeoutStartSec=120`.
152
+ - **`lanes gc` (daily `lanes-gc.timer`):** truncates zmx logs over 5 MB and removes a `resolve.dynamic/<id>` file
153
+ only when that id is in none of `status.json`'s `lanes`, `unregistered` or `zmx` AND the file is over an hour old.
154
+ statusd lists every `unregistered` and `zmx` row id under `lanes` as well; reading the other two keys is a
155
+ defence, not a second source. The mtime alone is no liveness signal: an unregistered session's file is rewritten only when its target or name
156
+ changes, so a live one is as old as the session. `removed stale <id>` for a session you can see is a defect.
157
+ - **Give a registered lane a different display name:** sidebar `R`, or `lanes rename <lane-id> <name>`. It writes
158
+ `repositories[i].displayNames` in the board's `workspaces.<board>.json` (a `.bak-<UTC>` copy is kept on the first
159
+ write) and syncs; the lane id, its resolve file and Cursor's `lane open <id>` stay the same. Renaming back to the
160
+ terminal's own title removes the override. A session added through Add to workspace / `lanes register` is renamed
161
+ in its `registered-sessions.json` record instead (same id, same binding); an unregistered session in its
162
+ `user-sessions/<id>.json`.
163
+ - **Promote (`P`) vs Add to workspace (`a`):** `P` declares an UNREGISTERED tmux session in the board's single
164
+ repository `terminals.json` (it then gets a repository lane id, `<ws>--<slug>`); `a` keeps the session's id. `P` is
165
+ refused on any lane that is already registered — a repository lane or an added session. It asks first, in the
166
+ footer's one prompt shape: ` › Register ‘name’? y/n`.
167
+ - **The sidebar footer** (bottom-up: footer, rule, strip row, upload strip; spec §4.6) shows ONE line by priority: the
168
+ close confirm ` › close ‘name’? y/n` > ` › Rename: …█` > ` › Register ‘name’? y/n` > the filter ` /abc · 3 matches` >
169
+ the health banner > ` ⚠ registry: …` > the last message > the legend ` ⏎ open · / find · x close · ? keys`. `?` shows
170
+ every key in place of the list until the next key. A filter with no hits says ` no lane matches ‘abc’ · Esc`; a
171
+ workspace with no lanes says ` no lanes declared · + New shell`. Above the rule, when the pane has 18 rows or more,
172
+ the strip row is the fleet economy line when `economy.json` knows a fleet fact (` $8.5k/16k 53% · F2 S1 · spill 76%`,
173
+ see "Economy projection"); otherwise it names the OTHER workspaces whose agents need you (` ◆1 Worlds · ◇2 Code`,
174
+ from the status.json already read). Under `LANES_GLYPHS=ascii` every glyph, separator and quote has a seven-bit twin (` > close 'sh'? y/n`,
175
+ ` Enter open - / find - x close - ? keys`).
73
176
  - **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.
177
+ rebuild and leaves you on the tab you were on. It never changes an existing tab's panes: a changed pane (e.g. the
178
+ phone picker after plan Task 26, which `lanes doctor` flags) needs `lanes rebuild <device>`.
75
179
  - **A new workspace:** add it to `~/.config/lanes/workspaces/catalog.json` + a manifest, `lanes sync`, then
76
180
  `lanes rebuild <device>` (the only time a layout changes).
77
181
  - **Registry invalid:** the workspace keeps its previous lanes; the sidebar footer and `lanes doctor` show why.
@@ -93,7 +197,7 @@ fails first. A question that got no answer is never published as "the lane is go
93
197
  | 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
198
  | Viewport status screen WITHOUT that line | The lane's tmux socket is really gone. | "A dead lane, or a blind harness?" below. |
95
199
  | 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. |
200
+ | Upload strip ` ✗ no upload helper` (the old 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
201
  | 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
202
 
99
203
  ### Look, without adding load
@@ -103,6 +207,8 @@ lanes pressure memory / io / cpu pressure, swap, load, per-container
103
207
  shmem) and /tmp fill. Plain files + statvfs: safe on a thrashing host. Exit 1 unless level ok.
104
208
  cat ~/.local/state/lanes/health what statusd wrote last; at= and status_at= are epochs — compare with `date +%s`
105
209
  lanes doctor every external call is bounded; "N shown with their last known state" = blind, not dead
210
+ `preview renderer:` pinned glow | system glow (<path>) | less -R — looked up on the view
211
+ server's PATH, the one the Ctrl-Alt-m pane sees, never your shell's
106
212
  lanes invariant check 1 s: every live pane's pid and size against the last `invariant save`
107
213
  ```
108
214
 
@@ -165,6 +271,20 @@ refused. Everything removed is logged in `~/.local/state/lanes/tmpscan/deleted.l
165
271
  touch. Socket there and the server's pid alive = the lane lives and only the harness is blind.
166
272
  5. `health` older than 30 s (`at=` against `date +%s`) = statusd itself is starved: the sidebar shows history.
167
273
 
274
+ How statusd looks, and why one wedged server cannot freeze the board: it finds the live tmux servers in each
275
+ workspace container's OWN cgroup (`cgroup.procs` + `comm` + `cmdline` under `/proc`, the same walk as `lanes guard`;
276
+ no `ps`, no fork), probes at most 8 servers at once, and stops waiting `PROBE_TIMEOUT + 2` = 7 s after the round
277
+ began. A probe still running then is published as `"observed": "timeout"` (last state, `stale`), the other servers'
278
+ answers go out on time, and the next round starts only once that probe's own bound has passed (another 7 s). A
279
+ server the deadline cut carries `"cut": true` in `status.json` `servers` and is probed FIRST next round, so with more
280
+ servers than 8 the same tail is not cut every cycle (and never drifts to `unknown` while it answers in time). A
281
+ container whose recorded init pid cannot be walked (not a docker cgroup, a recycled pid) makes the round treat THAT
282
+ container's socket files as possibly live — nothing of it is concluded gone; the other containers keep their real
283
+ live sets. In `journalctl --user -u lanes-statusd`: `observe: … still running at the 7 s cycle deadline (first next
284
+ round): <container>/<socket> …` names the abandoned probes, and `live servers <container>: pid <n>: …` names a
285
+ container that became unwalkable (logged once, when it starts). `lanes invariant` stays all-or-nothing: one
286
+ unwalkable container makes it refuse rather than save a partial pane list.
287
+
168
288
  A lane is dead only on a POSITIVE observation: its server answered without that session (`–`), its pane died (`!`),
169
289
  or its container is down (`✖`).
170
290
 
@@ -194,6 +314,11 @@ Coexistence mode can be turned back on at any time with `touch ~/.local/state/la
194
314
  - Terminal: Ghostty in a dedicated instance (`~/.config/lanes/ghostty.conf`, inherits `~/.config/ghostty/config`, so
195
315
  Omarchy theme switches follow). Foot stays the system default; without Ghostty the window falls back to Foot with
196
316
  the same class. Install Ghostty only: `omarchy-pkg-add ghostty` (`omarchy-default-terminal` is not touched).
317
+ - Font: the Omarchy default font; a Nerd Font is not required by lanes — every glyph is plain Unicode
318
+ (`LANES_GLYPHS=ascii` for any font; the blocks a font needs are listed under "TUIs and the terminal's colours"
319
+ below). The Zellij bar runs with `simplified_ui true`, so it draws no Private-Use-Area chevrons either.
320
+ - Look of the window: `lanes-window --ascii`, `--light`, `--dark` (combinable with a suffix: `lanes-window --ascii w2`)
321
+ — see "TUIs and the terminal's colours" below.
197
322
  - Hyprland: one line `require("hypr.lanes")` at the end of `~/.config/hypr/hyprland.lua` + `~/.config/hypr/lanes.lua`
198
323
  (validated with `hyprctl configerrors`; backup `hyprland.lua.pre-lanes.*`). Pin it to a workspace by uncommenting
199
324
  the `o.window` line in `lanes.lua`.
@@ -223,6 +348,67 @@ night sky of single-dot Braille "stars". Started with no client attached it pain
223
348
  - It stops recurring when the clients agree on brightness: the same light/dark choice in Cursor's terminal and in the
224
349
  lanes window (Ghostty `theme = light:…,dark:…` follows the OS and therefore flips twice a day).
225
350
 
351
+ The sidebar's own colours and glyphs come from one token table (`libexec/tokens.py`; spec
352
+ `docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md` §2.4) and three switches, resolved per device:
353
+
354
+ | Switch | Values | Default |
355
+ |---|---|---|
356
+ | `LANES_COLOR` / `--color` | `truecolor` · `16` · `none` | `truecolor` when `COLORTERM` is `truecolor`/`24bit`, else `16` (your terminal's own palette); `NO_COLOR` set ⇒ `none` (glyphs only) |
357
+ | `LANES_THEME` / `--theme` | `dark` · `light` · `auto` | `auto`: the sidebar asks the terminal's background once at start (OSC 11) and paints dark until the answer; no answer within 0.3 s ⇒ dark |
358
+ | `LANES_GLYPHS` / `--ascii`, `--glyphs` | `unicode` · `ascii` · `emoji` | `unicode`; `ascii` when none of `LC_ALL`/`LC_CTYPE`/`LANG` names UTF-8 |
359
+ | `LANES_DIM` | `90` · `2` | `90`; 16-colour mode only: `2` is the escape hatch for a palette whose colour 8 is near the background |
360
+
361
+ - Set it per device, live: `lanes attach DEVICE --ascii` (or `--glyphs M`, `--theme T`, `--color C`) records the
362
+ choice in `~/.local/state/lanes/devices/<device>.json` (only for a device that already has that file; a bad value
363
+ refuses before anything is created). Every running sidebar of that device re-reads the file within one tick — no
364
+ restart. The choice survives a recreated session (`lanes ensure` after a reboot, `lanes rebuild`). Environment
365
+ variables never reach a running sidebar: Zellij panes keep the server's environment from the moment the session
366
+ was created. The device file wins over the environment.
367
+ - The sidebar publishes what it resolved: `clients/<device>.theme` (`dark`/`light`, on change or when absent; for
368
+ `glow`) and `clients/<device>.glyphs` (`unicode`/`ascii`/`emoji`, on change or when absent; read by `view.sh`).
369
+ - The terminal's answer to the OSC 11 question is taken out of the input before any key is read, also when it is
370
+ split across two reads, so it never cancels a filter or a prompt.
371
+ - Dim text is SGR 90 in 16-colour mode (never SGR 2, which several terminals render as invisible). A palette whose
372
+ colour 8 is close to the background (classic Solarized dark) can set `LANES_DIM=2` as the escape hatch.
373
+ - From the Mac or Omarchy window: `lanes-window --ascii`, `--light` or `--dark` (before an optional suffix:
374
+ `lanes-window --dark w2`) hands `--ascii` / `--theme light|dark` to `lanes-launch`, which passes them to
375
+ `lanes attach` — the same per-device record as above. `lanes-launch DEVICE --ascii|--theme T|--glyphs M|--color C`
376
+ takes them directly; a bad value refuses before any connection, an unknown option is named and dropped.
377
+ - The viewport's own status screens (`view.sh`: "THIS VIEWER DISCONNECTED", "No lane selected", "retrying in …") use
378
+ the same tokens (`libexec/tokens.sh`, sourced once, no fork): a bold lead with `⚠` — `!` when
379
+ `clients/<device>.glyphs` says `ascii`, read on every screen, so `lanes attach DEVICE --ascii` reaches the next
380
+ status screen without a restart — and the hint line in SGR 90 instead of SGR 2.
381
+ - Fonts: no Nerd Font is needed. Any monospace font with Latin-1, Box Drawing (U+2500 `─`), Geometric Shapes
382
+ (U+25A0–25FF `● ○ ◆ ◇ ▸ ▌ █`), Dingbats (`✓ ✗`), Arrows (`↑ ↓`) and `⋮ ⏎ ⚠ …` renders the cockpit (JetBrains Mono,
383
+ SF Mono, Menlo, Cascadia, DejaVu Sans Mono all do); `--ascii` is the complete remedy for any other font.
384
+ - The lanes window inherits your everyday Ghostty look (fonts, theme, padding: `client/ghostty/common.conf` never sets
385
+ them). Opt in to the cockpit's own palette with `./install.sh --palette` (client install): it installs
386
+ `~/.config/lanes/ghostty-palette.conf` — generated from the token table (`client/ghostty/lanes-palette.conf` and
387
+ `lanes-palette-light.conf`, `libexec/tokens.py --ghostty-dark|--ghostty-light`; slots 1–4 are the four tones, so
388
+ 16-colour mode under it equals truecolor mode) — and includes it last in `~/.config/lanes/ghostty.conf`. On Ghostty
389
+ 1.1 or newer (`ghostty +version`) that file is `theme = light:…,dark:…` over two installed theme files, so the
390
+ window still flips with the OS; on an older or unknown Ghostty it is the dark palette alone (the light one is
391
+ installed next to it as `ghostty-palette-light.conf`). Ghostty lays a `theme` UNDER every colour key
392
+ (`background`, `foreground`, `palette = N=…`, cursor/selection colours) set explicitly anywhere in the loaded
393
+ configuration, whatever the file order — and the lanes window loads your everyday config first. So the installer
394
+ walks the everyday config and every file it reaches through `config-file`: if any sets a colour key (Omarchy does —
395
+ its config includes the current theme's `ghostty.conf`), a theme pair would silently keep your everyday colours, and
396
+ it installs the explicit dark palette instead and says which file forced that (`dark only: … sets colours
397
+ explicitly`). Ghostty also loads `config-file` includes breadth-first (one first-in-first-out list:
398
+ `Config.zig` `loadRecursiveFiles`), so a file two includes deep — Omarchy's theme `ghostty.conf` — loads after
399
+ every direct include of the lanes `ghostty.conf`, the palette's included. The installer therefore records the
400
+ everyday chain's deepest include and places the palette keys (or the theme pair) behind as many one-line hop files
401
+ (`ghostty-palette.1.conf`, …) as it takes to sit at that depth, where they load last; on Omarchy that is one hop,
402
+ and the `say` line names it. A plain `./install.sh` re-run without `--palette` removes the include and all palette
403
+ files: inherit-first is the default. NOT yet looked at on a real Ghostty (2026-10-04: no Mac or Omarchy client was
404
+ 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
406
+ Mac (Ghostty ≥ 1.1) and on Omarchy, and the window background must be `#101318` (the dark `surface` token).
407
+ - 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
+ attached, and no live session was touched). To check: with the Mac in light mode, `lanes attach mac` from the Mac,
409
+ then on the server `cat ~/.local/state/lanes/clients/mac.theme` — `light` means the answer arrived and was parsed;
410
+ `dark` means no answer within 0.3 s (the sidebar painted dark). Record the result here.
411
+
226
412
  ### "tmux server … is not running" on every lane at once (deleted sockets)
227
413
 
228
414
  Seen 2026-09-20 04:24: something emptied `coder-dev-work:/tmp`, and with it `/tmp/tmux-1000` — the directory of every
@@ -236,7 +422,8 @@ Cursor terminals need a reopen. Never `rm -rf /tmp/*` in a workspace container;
236
422
  ## Cursor / VS Code terminals = the same lanes
237
423
 
238
424
  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`
425
+ stay the source of truth for editor-defined lanes; sidebar registrations supplement the Lanes view without
426
+ changing the editor's terminal list. `lanes cursor-sync`
240
427
  only normalizes HOW a terminal reaches its lane:
241
428
 
242
429
  - every `command` becomes `exec /home/coder/.lz/bin/lane open <lane-id>` — attach; if the lane is missing, run its
@@ -247,6 +434,20 @@ only normalizes HOW a terminal reaches its lane:
247
434
  `lanes cursor-sync <ws> --write` applies (backup under `~/.local/state/lanes/cursor-sync-backups/`). The repo then
248
435
  shows an ordinary git diff to review and commit. Boards that keep their registry outside a repository
249
436
  are additionally projected into the file Cursor reads (`"cursor"` in `workspaces/*.json`).
437
+ - `lanes cursor-sync <ws> --canonicalize` (dry run; add `--write` to apply) replaces every tmux lane's recorded
438
+ 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
440
+ canonical `tmux new -ADs … -e LANE_ID=… -e LANE_WS=… bash -l` recipe (old value kept as `createPrevious`).
441
+ `--identity` adds the same `-e` flags to a working plain `tmux -L S new -ADs N [-c D] CMD` recipe, keeping CMD's
442
+ 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
444
+ board socket would otherwise hand its identity to every later lane there). Both change the file only: a RUNNING
445
+ 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
447
+ fairness bucket. Applying `--canonicalize --write` or `--identity --write` to a live workspace is an operator gate
448
+ (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
450
+ vs per-session fairness first.
250
451
  - Size policy "the lanes view wins": lanes-view tmux clients run with `TERM=xterm-lanesview` (a user-level terminfo
251
452
  alias of xterm-256color). While such a client shows a lane, every other client of that lane (Cursor terminals,
252
453
  ad-hoc attaches) is in tmux `ignore-size`; `lanes-statusd` reconciles every few seconds and gives editors the size
@@ -254,6 +455,207 @@ only normalizes HOW a terminal reaches its lane:
254
455
  included, and both halves apply there: an `ignore-size` an operator set by hand on such a socket is given
255
456
  back like any other once no view shows the lane (DEF-982).
256
457
 
458
+ ## Installing agent hooks (opt-in, per container, dry run first)
459
+
460
+ Without hooks the sidebar's agent glyph comes from Claude's session files and Codex's pane title. Hooks make it
461
+ event-driven: Claude Code or Codex runs `~/.lz/bin/lane-signal <status>` on a turn event, which writes
462
+ `~/.lz/state/hooks/<lane id>.json` in the container, and `lanes-statusd` prefers that over the other signals while
463
+ its agent runs and its last event is under an hour old. Nothing installs a hook for you. (`~/.lz/state/<name>.json`
464
+ 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.)
466
+
467
+ 1. `lanes install-container <name>` — puts `lane-signal` in `~/.lz/bin/` and the definitions in `~/.lz/hooks/`.
468
+ 2. `lanes hooks install --agent claude --container <name>` — prints the unified diff it would make to that
469
+ container's `~/.claude/settings.json` (your own hooks and keys stay; an earlier lane-signal install is replaced,
470
+ not duplicated). Read it. Then add `--write` (the old file is kept as `settings.json.lanes-bak-<time>`).
471
+ 3. `lanes hooks install --agent codex --container <name>` — the same for `~/.codex/hooks.json`, plus a top-level
472
+ `notify = [".../lane-signal", "done", …]` in `~/.codex/config.toml` only when that file has no top-level
473
+ `notify` (an existing notifier is left alone). Codex asks you to review and trust new hooks before running
474
+ them (`[hooks.state]` in its config.toml); lanes never writes that trust.
475
+
476
+ Expect a hook to reach only agent sessions started after the install (both harnesses read their hook
477
+ configuration at start — INFERRED, not measured here). `--home <dir>` targets a home directory instead of a
478
+ container (for trying the merge on a copy). Undo: restore the `.lanes-bak-*` file, or delete the groups whose
479
+ command contains `lane-signal`.
480
+
481
+ What each event writes (`agent{}` vocabulary):
482
+
483
+ | Harness | Event | status |
484
+ |---|---|---|
485
+ | Claude Code | `UserPromptSubmit`, `PostToolUse` | `working` |
486
+ | Claude Code | `Stop`, `Notification` (`idle_prompt`) | `needs_input` |
487
+ | Claude Code | `PermissionRequest`, `Notification` (`permission_prompt`) | `needs_approval` |
488
+ | Claude Code | `StopFailure` | `errored` |
489
+ | Claude Code | `SessionEnd` | `shell` |
490
+ | Codex | `SessionStart` | `idle` |
491
+ | Codex | `UserPromptSubmit`, `PostToolUse` | `working` |
492
+ | Codex | `PermissionRequest` | `needs_approval` |
493
+ | Codex | `Stop`, `notify` (turn complete) | `done` |
494
+ | Codex | `SessionEnd` | `shell` |
495
+
496
+ Verified event names (2026-10-04, this host): Claude Code 2.1.288's hook-event list compiled into its binary
497
+ (`PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch, Notification, UserPromptSubmit, UserPromptExpansion,
498
+ SessionStart, SessionEnd, Stop, StopFailure, SubagentStart, SubagentStop, PreCompact, PostCompact, PreModelSwitch,
499
+ PostModelSwitch, PermissionRequest, PermissionDenied, …`); Codex 0.160.0's hook event variants in its binary
500
+ (`PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, SessionEnd, SubagentStart,
501
+ SubagentStop, Interrupt`, plus `UserPromptSubmit` and `Stop`), and the shape of a trusted project
502
+ `.codex/hooks.json` (`{"hooks": {Event: [{"matcher": null, "hooks": [{"type": "command", "command", "timeout"}]}]}}`).
503
+ Codex 0.160 has no `codex --help hooks`; the names come from the binary. Not yet proven on a live lane: that Codex
504
+ loads a USER-level `~/.codex/hooks.json` the way it loads a project's (the only hooks.json files measured here are
505
+ project files), and what a hook's parent process is under each harness (lane-signal skips one shell either way).
506
+
507
+ Known limit (DEF-1434): after you approve a permission prompt, the lane shows `needs_approval` until the approved
508
+ tool call returns (`PostToolUse`), because neither harness signals "approved". A long approved command therefore
509
+ reads as waiting for you until it finishes. A lane whose agent crashed loses its sidecar at once (dead pid); one
510
+ whose agent is alive but silent keeps the last word for up to an hour.
511
+
512
+ ## Economy projection (opt-in)
513
+
514
+ `~/.local/state/lanes/economy.json` is what each lane costs and how hard the fleet is pushing the Throttle pool:
515
+ a fleet strip (overflow spend against the enforced daily cap, alert level, pressure phase and spill fraction,
516
+ effectively free accounts, the soonest 5 h reset, new upstream 429s since the last poll, paid and subscription
517
+ requests in flight, the Fable governor's sustainable rate) and, per lane, live figures from the daemon's
518
+ recent-request ring (model, paid share, $/h, tokens/min, cache-read ratio, downgrades, 429s, age of the last
519
+ request) plus today's totals folded from `proxy.log` (requests, paid requests, the four token classes — dollars only
520
+ as `list_usd_estimate`, which stays `null`; lanes keeps no price table).
521
+
522
+ **What the sidebar shows from it.** Every sidebar re-reads `economy.json` when its mtime changes (one `stat()` per
523
+ tick). Column 34 of a lane row is one tail cell (`⚠` starving, `↓` downgraded, `$` mostly paid; `?` — not observed —
524
+ beats all three). The strip row above the footer rule (panes of 18 rows or more) becomes the fleet economy line, parts
525
+ in priority order and whole parts dropped from the end to fit 34 cells:
526
+
527
+ | Part | From | Shown |
528
+ |---|---|---|
529
+ | `$8.5k/16k 53%` | `overflow_spend_today_usd` / `overflow_budget_usd`, `spent_fraction` | amber at or over `alert_level_pct` |
530
+ | `429 +4` | `upstream_429_delta` | only when > 0, amber |
531
+ | `F2 S1` | this workspace's lanes by `model`: `F` fable, `O` opus, `S` sonnet, `H` haiku, `G` gpt | tier letters live here and in the preview, never in a row |
532
+ | `spill 76%` | `spill_fraction` | amber unless `pressure_phase` is `closed` |
533
+ | `free 3/19` | `effective_free` / `accounts` | |
534
+ | `reset 2h` | `soonest_reset_5h` | only while in the future |
535
+
536
+ A leading `?` and an all-dim line mean the figures are frozen: the poller kept its last fleet strip because `/status`
537
+ 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:
539
+ a `⚠` or `↓` drawn dim (not amber) is the last known figure, not a live alarm — the poller kept that lane's record
540
+ because `/sessions` failed (`"stale": true` on the lane), or the whole document is frozen. With
541
+ `LANES_THROTTLE_CONTROL=none` every fleet figure is `null`: the strip row then shows fleet attention exactly as without
542
+ a poller, and no row carries an economy cell (the day totals carry no live figure).
543
+
544
+ **Operator gate.** `lanes-economy.service` sends a GET to the Throttle daemon's control port (`/status` and
545
+ `/sessions`, default `http://127.0.0.1:19181`) every 10 s. It is installed with the other units and enabled by
546
+ nothing. Turning it on is your call.
547
+
548
+ **Where it runs — read this before enabling.** The unit is a HOST user unit (the server installer renders it beside
549
+ `lanes-statusd`); the daemon, its control port and its log normally live inside a workspace container. Neither
550
+ default reaches them from the host:
551
+
552
+ - The control URL is resolved in the HOST's network namespace. A daemon bound to `127.0.0.1` inside its container
553
+ (`[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
+ `http://127.0.0.1:19181` on the host is a different loopback. Until the control port is reachable from the host —
555
+ 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).
557
+ - The log lives in the container's home. Set `LANES_THROTTLE_CONTAINER=<container name>`: the poller reads
558
+ `/home/coder/.local/share/throttle` through that container's home volume as `lanes sync` recorded it in
559
+ `~/.local/state/lanes/containers.json` (the container must be on a board). The host user's own
560
+ `~/.local/share/throttle` is the default only for a poller that runs where the daemon runs.
561
+
562
+ Check what the poller can see before you enable it — `--once` names every source it could not read on stderr
563
+ (`connection refused`, `timed out`, `HTTP 401`, `log dir missing: …`, `log dir unreadable: …`,
564
+ `container … is not in containers.json`). `log dir unreadable` on a host unit usually means the container's home
565
+ volume sits under the docker data root (`docker info -f '{{.DockerRootDir}}'`: `/var/lib/docker` by default,
566
+ `/data/docker` on ceobox, where the workspace homes are named volumes at `<root>/volumes/coder-<id>-home/_data`),
567
+ 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
569
+ runs:
570
+
571
+ ```sh
572
+ LANES_THROTTLE_CONTROL=none LANES_THROTTLE_CONTAINER=<container> lanes economy --once --dry-run # writes nothing
573
+ systemctl --user edit lanes-economy.service # put the same two settings in the drop-in ([Service] Environment=)
574
+ systemctl --user enable --now lanes-economy.service
575
+ journalctl --user -u lanes-economy.service # one line each time a source goes down or comes back
576
+ ```
577
+
578
+ Before you enable it, settle DEF-1479 (the lane-stable trace's fairness consequence) if any lane was created with
579
+ the identity recipe; lanes without it are joined to their requests by `agent.session_id` alone.
580
+
581
+ Settings (environment of the installer — `install/services.py` renders them into this unit only — or a
582
+ `systemctl --user edit lanes-economy.service` drop-in):
583
+
584
+ | Variable | Default | Meaning |
585
+ |---|---|---|
586
+ | `LANES_THROTTLE_CONTROL` | `http://127.0.0.1:19181` | control base URL, resolved in the poller's network namespace (see above); `none` sends no request at all (the log fold still runs) |
587
+ | `LANES_THROTTLE_TOKEN_FILE` | unset | file holding a bearer; a loopback daemon without `remote_key` needs none. Never written to `economy.json` or the cursor |
588
+ | `LANES_THROTTLE_CONTAINER` | unset | the container whose home volume holds the daemon's log; the log dir becomes its `/home/coder/.local/share/throttle` (via `containers.json`) |
589
+ | `LANES_THROTTLE_LOG_DIR` | `~/.local/share/throttle` | where `proxy.log` and `proxy.log.<day>` are read (read only); wins over `LANES_THROTTLE_CONTAINER` |
590
+
591
+ Behaviour you can rely on: GET only, 3 s timeout, no proxy from the environment, no redirects followed (a redirect
592
+ would carry the bearer elsewhere). A failed `/status` keeps the last fleet strip with `"stale": true`; a failed
593
+ `/sessions` keeps each lane's last live figures with `"stale": true`; `source` says which of status, sessions and
594
+ log answered, and the journal (stderr) says why one did not — a line when a source's failure class changes, `ok`
595
+ when it recovers; the reason is never written to `economy.json`. `economy.json` is rewritten only when something other than `at` changed. The fold cursor
596
+ (`economy.cursor.json`) reads only bytes appended since the last poll and keeps ten days. Deleting both files is safe:
597
+ the next poll re-folds today. For a look without the daemon: `LANES_THROTTLE_CONTROL=none lanes economy --once
598
+ --dry-run --status-file status.json --sessions-file sessions.json` with saved documents.
599
+
600
+ ## Push notifications (opt-in)
601
+
602
+ `lanes-notify.service` sends one push to an [ntfy](https://ntfy.sh) topic when a lane's agent ENTERS needs-you —
603
+ `agent.status` becomes `needs_input` (`Needs you`), `needs_approval` (`Needs approval`) or `errored` (`Error`), the
604
+ same set the sidebar's Needs you section and its OSC 9 use. It is the away-from-every-terminal half of the sidebar's
605
+ OSC 9 ("Title and notifications" above): it reads only `~/.local/state/lanes/status.json` (checked every 3 s,
606
+ re-read when its mtime changes) and `registry.json` for lane names; it never touches a lane, a session or the
607
+ Throttle daemon.
608
+
609
+ What a push says: `<lane name>: <agent task> — <status>` and, on a second line, `lanes-launch lane <id>` (the
610
+ command that opens that lane from a client). No client registers a URL handler for that command, so a push is
611
+ tappable only when you set `action_url` (below).
612
+
613
+ When nothing is sent:
614
+
615
+ - the status did not change, or the lane left needs-you (a later return is news again); a change from
616
+ `Needs you` to `Needs approval` is news;
617
+ - the service just started: lanes already waiting when it (re)starts are not announced (no burst on restart —
618
+ unlike a starting sidebar's OSC 9);
619
+ - a desk device was present within `quiet_when_device_active_s` (default 120 s): a `lanes attach` from any device
620
+ (`clients/<device>.<client>.pid`), or a lane opened from the sidebar of a desk device
621
+ (`clients/<device>.seen.json`, counted only for a device that has a `.pid` — one `lanes attach` has run for —
622
+ 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
624
+ is decided by the device's mobile layout, not by how it attached: a Safari phone writes no `.pid`, and an
625
+ 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).
627
+ Only a phone's own `lanes attach` in the last two minutes counts, like any attach.
628
+
629
+ A failed send (server down, `HTTP 429`, no network) is one journal line naming the lane and the failure class — the
630
+ topic and the token are never logged — and is not retried.
631
+
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:
634
+
635
+ | Key | Default | Meaning |
636
+ |---|---|---|
637
+ | `url` | required | the ntfy server, `https://…` (self-hosted or `https://ntfy.sh`) |
638
+ | `topic` | required | `[A-Za-z0-9_-]{1,64}`; on a public server the topic name is the only secret — pick a long random one |
639
+ | `token_file` | unset | a file holding an ntfy access token (sent as a bearer). An unreadable file disables sending rather than sending unauthenticated. Keep the token in that file (`chmod 600`), never in `notify.conf` |
640
+ | `quiet_when_device_active_s` | `120` | presence window above; `0` never suppresses |
641
+ | `action_url` | unset | a URL template over `{lane}` (the lane id, URL-quoted), e.g. a page of yours that opens it; becomes the push's tap target and an **Open lane** button |
642
+
643
+ Without a valid `url` and `topic` the service only tracks transitions and sends nothing. Turn it on:
644
+
645
+ ```sh
646
+ $EDITOR ~/.config/lanes/notify.conf # url=, topic=, token_file= …
647
+ python3 ~/.local/share/lanes/current/libexec/notify.py --test # one test push; exit 1 = not sent
648
+ systemctl --user enable --now lanes-notify.service
649
+ journalctl --user -u lanes-notify.service # send failures, one line each
650
+ ```
651
+
652
+ **Operator gate.** The unit is installed with the others and enabled by nothing: it sends lane names and agent
653
+ task titles to the server you configure. Not yet run against any ntfy server or phone (unit-proven only).
654
+
655
+ **Not part of this:** answering a `PermissionRequest`-held approval from the phone. A push only says the lane needs
656
+ you; approving from the notification needs a request/response channel back into the hook that holds the prompt — a
657
+ follow-up design.
658
+
257
659
  ## Throttle Studio as a client
258
660
 
259
661
  Throttle Studio (`throttle-rc studio host` in a workspace container, the `/workspaces` view of the Throttle console)
@@ -316,14 +718,14 @@ already ignore it. Afterwards the in-container path is on your clipboard and in
316
718
 
317
719
  | Way | How |
318
720
  |---|---|
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. |
721
+ | Upload strip in the sidebar | The one row above the footer rule, ` ↑ uploads · click, then drop (U)`. Click it (or press `U` in the sidebar, `Ctrl-Alt-u` from anywhere in the tab) — it turns into ` ↓ DROP FILES NOW · anywhere · Esc` — 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. While it sends the strip says ` ↑ sending 2 files · 12.4 MiB` and repaints only when the helper reports progress (no spinner, no fast tick); the result stays 8 s as ` ✓ name, name` or ` ✗ reason`. |
320
722
  | 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
723
  | Command | `lanes-upload [-w <board>] [--paste] <files…>` · `lanes-upload --clipboard [--paste]` (clipboard image or copied files) |
322
724
  | macOS droplet | `~/Applications/Lanes Upload.app`: drop files on it (Dock); double-click uploads the clipboard screenshot |
323
725
  | anarchy | `Super+Shift+U` uploads the clipboard and types the path into the lane |
324
726
 
325
727
  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:
728
+ the pointer), so the strip has to hold the focus when you let go. And the server only ever receives that path string:
327
729
  the file itself is sent by the **upload helper** that `lanes-launch` runs on your machine. The helper is the security
328
730
  boundary — it reads only from folders listed in `~/.config/lanes/upload-allow` (defaults: Desktop, Downloads,
329
731
  Pictures, Documents, Lanes Uploads, temp folders; macOS asks once per new folder; `lanes-upload --allow <dir>`), and
@@ -356,6 +758,34 @@ On the phone, connect Tailscale and open the printed URL. Paste the token into t
356
758
  when tapping **Type here** or another text input; swipes, live updates and reconnects leave it closed.
357
759
  **More → Text size** remembers your preferred terminal font. The phone has its own view selection of the same agents.
358
760
 
761
+ The lane picker shows three rows per lane: the agent glyph and the name (`›` before the lane this phone views), then
762
+ what the agent needs and since when with its harness (`Needs you · 25m · claude`; a shell says `Quiet · 2h`), then the
763
+ agent's task. Its first line counts the lanes that need you (`◆1 need approval · ◇2 need you · ▸6 working`). The same
764
+ counts come from the picker pane's title (see "Title and notifications" above): the header subtitle on the picker,
765
+ 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)
767
+ the phone's picker pane had the fixed layout name `Lanes`, which Zellij reports instead of the sidebar's title, so the
768
+ phone showed no counts. Task 26 removed that name from the generated layout, which reaches a phone only when its
769
+ session is laid out again (`lanes rebuild <device>`; see the device check below); whether the title
770
+ 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).
772
+
773
+ The phone's light/dark toggle repaints the whole terminal, not only the shell: the page applies its own sixteen-colour
774
+ palette on attach, on every toggle and after every Zellij config reload (DEF-1413; not yet looked at on a device).
775
+
776
+ On a lane's own tab a row of quick keys sits above the dock: `⏎ y n Esc ^C ↑ ↓`. A tap writes the key to the lane
777
+ without opening the keyboard. `n` and `^C` write only after you hold them for 400 ms; a short tap says
778
+ "Hold to send". With the keyboard open the row stays above the keyboard's modifier bar. The filter bar on the picker
779
+ sends its text as UTF-8, so a filter like `ø` matches.
780
+
781
+ 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
+ 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
784
+ 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
786
+ counts appear and the check says nothing about the title path. Record the
787
+ device, iOS version and result here. The unit suite covers only the picker's text, not the browser.
788
+
359
789
  Tailscale Serve forwards HTTPS 8443 to `lanes-mobile.service` on `127.0.0.1:8083`. The separate unprivileged
360
790
  Caddy serves `web/` and forwards native authentication/assets/WebSockets to Zellij on `127.0.0.1:8082`.
361
791
  The Mac is a client; serving happens on the workspace host. See [frontend maintenance](../web/README.md).
@@ -365,6 +795,23 @@ a real client before attaching; visible views accept 20 columns / 4 rows for the
365
795
  Revoke a named token with `~/.local/share/lanes/bin/zellij-0.45.1 web --revoke-token NAME`.
366
796
  An SSH-only phone client can run `lanes attach phone --mobile` instead.
367
797
 
798
+ Laptop and tablet browsers: see the [installation guide](INSTALL.md#laptop-and-tablet-browser). Troubleshooting:
799
+
800
+ - **A tablet landed on the lane-picker layout** (the device was made for a phone, for example by
801
+ `enable-phone.sh`'s default): give the tablet its own desktop-layout view with `lanes web <device> --desktop`
802
+ under a new device name. The old view stays as it is; a running view is never rebuilt into the other layout,
803
+ and an explicit `--desktop` against it is refused instead. Without a flag, `lanes web <device>` reuses
804
+ whatever layout that device already has. A tablet shows the whole tab (sidebar beside the lanes): the pinned
805
+ client opens its mobile UI in single pane on every load, and `web/browser.js` asks once per load for the full
806
+ view with the client's own preference. **More → Single pane** still switches to one pane for that load.
807
+ - **A laptop shows no Lanes chip**: the chip appears after sign-in, when the first workspace state arrives. A
808
+ reload after an upgrade picks up the new `mobile.js` and `browser.js` (both served with `no-cache`; `mobile.js`
809
+ appends `browser.js`/`browser.css` only on a tablet or desktop, so a phone's network panel never lists them).
810
+ The chip keeps the keyboard focus in the terminal; if typing stops after using a picker, Escape in the bar or a
811
+ click in the terminal returns it.
812
+ - **`lanes web` printed a `127.0.0.1` URL**: the host has no Tailscale name. Connect Tailscale on the host;
813
+ there is no public fallback.
814
+
368
815
  ## Safety rules baked into the code (do not remove)
369
816
 
370
817
  - tmux is always called with `-N` (`attach-session`, `new-session` and `start-server` on a missing socket start
@@ -374,12 +821,48 @@ An SSH-only phone client can run `lanes attach phone --mobile` instead.
374
821
  - pty-name collision guard: the lanes' `client-attached` hook detaches a client whose tty *name* equals the
375
822
  lane's `pane_tty`; host and container pty numbers collide, so the holder re-wraps itself on a fresh pty.
376
823
  - 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.
824
+ - The two binds in `zellij/config.kdl` that still type into the sidebar (`Ctrl-Alt-/`, `Ctrl-Alt-u`) walk
825
+ `MoveFocus "left"` once per possible viewport before `WriteChars`: with a single left, a key pressed in the second
826
+ viewport typed into a lane. `Ctrl-Alt-↓/↑` type nothing since plan Task 19 (`lanes next|prev --focused`).
379
827
  - Tests: `python3 -m unittest discover -s tests/unit` needs no server (runs on the Mac) and carries the harness's
380
828
  pure-function pins — the viewer rule, the client-size bucketing, the status keying, the shared-socket
381
829
  attribution and the zmx word (`tests/unit/test_alignment.py`). Live suites, on a QUIET host only:
382
830
  `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
831
+ `tests/smoke_real.py` (read-only, real lanes), `tests/latency.py` (from a client; `tests/latency.py --switch
832
+ [samples]` ON the server measures `switch` = SIGUSR1 to view.sh → first byte of the new lane, with a throwaway
833
+ view.sh on a temp `LANES_STATE` and two sessions on `-L lanes_test`, run ad hoc). All use disposable lanes except
384
834
  the explicitly read-only smoke test; every one refuses to start on a busy host (exit 75) and removes its lanes and
385
835
  sessions when it is killed with SIGTERM / SIGHUP.
836
+
837
+ ## Workspace catalog propagation
838
+
839
+ Register and rename workspaces in the host site catalog. Device-local copies are
840
+ not a second authority. `lanes sync` publishes the registry and reconciles all
841
+ known live devices; `lanes upgrade-session mac anarchy phone` performs only the
842
+ view reconciliation. Device metadata chooses desktop or mobile layout. Missing
843
+ sessions are recreated by `lanes ensure` with that same catalog.
844
+ For an existing session with no attached client, additions wait until a client
845
+ connects and the next ensure tick. Zellij 0.45.1 otherwise acknowledges a new
846
+ background tab without spawning its panes. Names and ordering can still sync.
847
+
848
+ Reconciliation identifies managed tabs by the workspace IDs in their launched
849
+ sidebar/view commands, then adds or renames tabs and orders them using Zellij's
850
+ stable tab IDs. It never closes tabs, restarts agents or changes client focus.
851
+ An unknown session/pane response or an ambiguous identity refuses changes for that
852
+ device and reports an error. Another device can still be updated. An unmanaged
853
+ name collision is preserved for explicit inspection; it is never adopted by title.
854
+ Per-device file locks serialize a sync with the ensure timer.
855
+ SSH split tabs match the host, absolute helper and declared pane ID together.
856
+ Different pane IDs may share one helper; overlapping endpoint identities refuse
857
+ before tab mutations and require an unambiguous catalog assignment.
858
+
859
+ For a connection wait, the Syncing screen owns and drains terminal input until
860
+ the attach client produces output. Terminal modes and sizes are restored when a
861
+ viewer ends; TERM/HUP stop only that attach process group. The persistent lane
862
+ continues independently. Connection diagnostics remain in the view's `.err` file.
863
+ Existing views pick up the connection helper on their next attach; restart only
864
+ the relevant view panes when activating a changed supervisor, never the lane.
865
+
866
+ Catalog fields are additive to site schema 1. Back up the catalog and running
867
+ checkout before updating the server. Restore those files to roll back; do not
868
+ rebuild device sessions as a rollback shortcut. Previously added tabs remain open.