@vibelord/lanes 1.0.1 → 1.0.3

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 (52) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +37 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +664 -50
  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 +18 -3
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +1 -1
  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 +310 -26
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +87 -13
  17. package/payload/lanes/docs/DESIGN.md +182 -4
  18. package/payload/lanes/docs/INSTALL.md +37 -2
  19. package/payload/lanes/docs/RUNBOOK.md +458 -39
  20. package/payload/lanes/docs/SHARING.md +240 -5
  21. package/payload/lanes/holders/exec.sh +2 -5
  22. package/payload/lanes/holders.lock +13 -0
  23. package/payload/lanes/install/enable-studio-host.sh +14 -0
  24. package/payload/lanes/install/host-install.sh +15 -3
  25. package/payload/lanes/install/services.py +128 -4
  26. package/payload/lanes/install.sh +108 -3
  27. package/payload/lanes/libexec/agent_state.py +32 -0
  28. package/payload/lanes/libexec/cursor_sync.py +10 -2
  29. package/payload/lanes/libexec/economy_poll.py +685 -0
  30. package/payload/lanes/libexec/guard.py +27 -1
  31. package/payload/lanes/libexec/lanes_lib.py +285 -38
  32. package/payload/lanes/libexec/notify.py +317 -0
  33. package/payload/lanes/libexec/registrations.py +1 -1
  34. package/payload/lanes/libexec/session_actions.py +7 -8
  35. package/payload/lanes/libexec/sidebar.py +911 -218
  36. package/payload/lanes/libexec/statusd.py +258 -74
  37. package/payload/lanes/libexec/studio_host.py +1608 -0
  38. package/payload/lanes/libexec/tokens.py +38 -7
  39. package/payload/lanes/libexec/view.sh +13 -3
  40. package/payload/lanes/systemd/lanes-economy.service +20 -0
  41. package/payload/lanes/systemd/lanes-notify.service +19 -0
  42. package/payload/lanes/systemd/lanes-studio-host-enrol.service +11 -0
  43. package/payload/lanes/systemd/lanes-studio-host-enrol.timer +10 -0
  44. package/payload/lanes/systemd/lanes-studio-host.service +37 -0
  45. package/payload/lanes/systemd/lanes-sync.service +3 -0
  46. package/payload/lanes/web/README.md +83 -4
  47. package/payload/lanes/web/index.html +2 -1
  48. package/payload/lanes/web/mobile.css +53 -198
  49. package/payload/lanes/web/mobile.js +87 -55
  50. package/payload/lanes/zellij/config.kdl +67 -16
  51. package/payload/manifest.json +5 -3
  52. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -14,32 +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.
33
- Agent glyphs (column 2, beside the cursor bar; from `status.json`'s additive `agent{}`, never from `state`): `◆`
34
- approval prompt blocking · `✗` errored · `◇` turn finished, waiting for your prompt · `▸` working · `✓` done, until
35
- you open the lane from this device (`clients/<device>.seen.json`) · `_` harness ended into a shell · `·` harness up,
36
- no turn · blank = a plain shell. With an agent the stream glyph after the age is dimmed (`▸` + `○` is a long tool
37
- call, not a stuck lane); a stale lane's agent glyph is dimmed too. Sections: **Needs you** (◆, ✗, ◇ — oldest first),
38
- **Working** (oldest first), **Done** (newest first), then the workspace's other lanes under its own header — only
39
- when an agent section exists; a filter flattens them. Header: `◆1 ◇2 ▸6 ✓1` (zeros omitted; `N lanes` without
40
- agents; dimmed while the footer warns that status is old). Every row ends with an age: the agent's `since`, else
41
- the last output (`45s`, ` 3m`, ` 2h`, ` 4d`). `x` / `×` asks ` › close ‘name’? y/n` first; `y` closes, any other
42
- key cancels. Design of record: `docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md` §3–§4.
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-1531). 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.
43
74
  A `·` after a lane's name (up to three) = other sessions VIEW that lane: any session GROUPED onto it, a Throttle
44
75
  Studio browser attach (`view-<studio>-<ch>`) included — counted in the lane's `attached`/`viewers` in `status.json`,
45
76
  never listed as unregistered (`statusd.viewer_of`; a ghost carries its viewers too, so a browser on an undeclared
@@ -47,7 +78,7 @@ lane is counted there). The `view-` NAME decides nothing: an ungrouped `view-not
47
78
  killed under it, are alive and ARE listed — but when a killed lane left SEVERAL grouped survivors, the oldest is
48
79
  the lane and the later ones are its viewers, the same choice the Studio makes (`statusd.group_leaders`,
49
80
  `inventory::group_leader`). A declared lane is always its own row, whatever it is grouped onto. A Studio lane the
50
- 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
51
82
  socket some board declares or not: discovery probes every live socket in the container.
52
83
 
53
84
  **Unregistered terminals:** every workspace has an **Unregistered** section, including sessions on shared and
@@ -65,7 +96,7 @@ reattachment, and reconnects never create or close a session.
65
96
  To keep an unregistered tmux session in a workspace's regular lane list, click/tap **+** beside it (or select it
66
97
  and press `a`), then choose a workspace. Only workspaces using its container are offered. **Cancel** or Esc leaves
67
98
  it unregistered. This preserves its name, running programs, scrollback and active views. It appears immediately
68
- and survives sync and reconnects. Registered lanes have no close control. Legacy Docker shells cannot be registered.
99
+ and survives sync and reconnects. Registered lanes have no close control.
69
100
 
70
101
  From the server CLI, use `lanes ls --unregistered` to find the ID, then
71
102
  `lanes register <lane-id> [workspace-id]`; omitting the workspace keeps the current assignment.
@@ -75,10 +106,10 @@ If publication fails after saving, `lanes sync` finishes it. Invalid/newer file
75
106
  overwriting the file. These registrations have no restart recipe: an ended session stays absent until the operator
76
107
  starts it. Add a repository terminal declaration when editor integration and a saved create recipe are wanted.
77
108
 
78
- Old plain Docker shells recovered during the transition are labelled **Legacy**. They remain tracked,
79
- including their existing running jobs. A still-connected viewer continues working; after leaving it, the old
80
- Docker connection has no normal reattach operation. Selecting that entry explains its status, and × ends the
81
- shell and its jobs. New + shells do not have this limitation.
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.
82
113
 
83
114
  ## Server (as the workspace account)
84
115
 
@@ -87,12 +118,45 @@ lanes doctor full health check lanes status per-wor
87
118
  lanes sync re-read registries now lanes ls [--json] lanes with live status
88
119
  lanes rebuild <device> recreate a view session lanes ensure recreate missing view sessions
89
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
90
122
  systemctl --user status lanes-statusd lanes-sync.timer lanes-ensure.timer lanes-web
123
+ lanes studio-host status [--check] the optional co-located Studio Host
124
+ lanes studio-host re-key --yes after the owner revoked the Host: a new machine key for the next deploy
125
+ (refused while lanes-sharing-host or throttle-rc-studio-host still runs)
91
126
  ```
92
127
 
128
+ - **What `lanes doctor` says about the boards' recipes (plan Task 18):** one line counts the tmux lanes per create
129
+ recipe class — `idempotent` (a `tmux new -A…`: creates, or attaches when the session exists; the canonical recipe),
130
+ `attach-only` (`attach-session` and no `new`), `null` (no recipe), `external` (a script or anything else, run as
131
+ written) and `retired-path` (names the retired in-container `~/.config/coder-launch`). One line says how many of
132
+ them create with their identity (`-e LANE_ID=…`, DEF-1559). Warnings (`⚠`, never a `✗`, the exit code is
133
+ unchanged): lanes that `lane open` cannot recreate once they end (attach-only, null, retired-path — fix with
134
+ `lanes cursor-sync --canonicalize`), lanes bound to a numeric session name (tmux numbers unnamed sessions itself,
135
+ so a recreated one gets another number), more than 4 lanes on one container's tmux socket `default` (the server every
136
+ plain `tmux` typed in that container talks to), and tmux socket files in a container's `/tmp/tmux-1000` that no
137
+ running tmux server (read from the container's cgroup) owns. That last one is **report only**: the host never
138
+ removes a file in a container. Inside the container, `~/.lz/bin/lane gc --sockets` probes each socket file with
139
+ `tmux -N -S <path> list-sessions` and, where tmux fails, connects to the file once itself: only ECONNREFUSED (the
140
+ file is there, nobody listens) unlinks it, printing each path; a server that answers (even with no session), does
141
+ not answer within 5 s, or refuses for another reason (EACCES, …) keeps its file. The errno decides, never tmux's
142
+ wording: tmux 3.4 prints `no server running on <path>` for ECONNREFUSED and `error connecting to <path>
143
+ (<strerror>)` only for other errnos (DEF-1540).
144
+ - **A device you no longer use (an old laptop, a phone you replaced):** `lanes device forget <name>` removes
145
+ `devices/<name>.json`, so `lanes ensure` (`lanes-ensure.timer`) stops recreating its view session and `sync` /
146
+ `upgrade-session` stop updating it. A view session that is running is left alone and says so; once it ends, nothing
147
+ brings it back. `lanes attach <name>` / `lanes prepare <name>` creates the device again. `lanes device list` shows
148
+ each device, phone or desktop (viewports), and whether its view session runs.
149
+
93
150
  - **A view looks wrong / stuck:** `lanes rebuild mac`. Views are disposable; agents are untouched; the client reconnects.
94
151
  - **A lane was added/renamed/removed in a repo's `.vscode/terminals.json`:** nothing to do. `lanes-sync.timer`
95
- re-reads registries every 2 minutes and the sidebar follows. The zellij layout is never edited live.
152
+ re-reads registries every 2 minutes and the sidebar follows. The zellij layout is never edited live. A sync that
153
+ finds nothing new leaves `registry.json` (and its `generated_at`) untouched and only records the time in
154
+ `registry.synced`; the unit is bounded at `TimeoutStartSec=120`.
155
+ - **`lanes gc` (daily `lanes-gc.timer`):** truncates zmx logs over 5 MB and removes a `resolve.dynamic/<id>` file
156
+ only when that id is in none of `status.json`'s `lanes`, `unregistered` or `zmx` AND the file is over an hour old.
157
+ statusd lists every `unregistered` and `zmx` row id under `lanes` as well; reading the other two keys is a
158
+ 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
159
+ changes, so a live one is as old as the session. `removed stale <id>` for a session you can see is a defect.
96
160
  - **Give a registered lane a different display name:** sidebar `R`, or `lanes rename <lane-id> <name>`. It writes
97
161
  `repositories[i].displayNames` in the board's `workspaces.<board>.json` (a `.bak-<UTC>` copy is kept on the first
98
162
  write) and syncs; the lane id, its resolve file and Cursor's `lane open <id>` stay the same. Renaming back to the
@@ -101,12 +165,47 @@ systemctl --user status lanes-statusd lanes-sync.timer lanes-ensure.timer lanes-
101
165
  `user-sessions/<id>.json`.
102
166
  - **Promote (`P`) vs Add to workspace (`a`):** `P` declares an UNREGISTERED tmux session in the board's single
103
167
  repository `terminals.json` (it then gets a repository lane id, `<ws>--<slug>`); `a` keeps the session's id. `P` is
104
- refused on any lane that is already registered — a repository lane or an added session.
168
+ refused on any lane that is already registered — a repository lane or an added session. It asks first, in the
169
+ footer's one prompt shape: ` › Register ‘name’? y/n`.
170
+ - **The sidebar footer** (bottom-up: footer, rule, strip row, upload strip; spec §4.6) shows ONE line by priority: the
171
+ close confirm ` › close ‘name’? y/n` > ` › Rename: …█` > ` › Register ‘name’? y/n` > the filter ` /abc · 3 matches` >
172
+ the health banner > ` ⚠ registry: …` > the last message > the legend ` ⏎ open · / find · x close · ? keys`. `?` shows
173
+ every key in place of the list until the next key. A filter with no hits says ` no lane matches ‘abc’ · Esc`; a
174
+ workspace with no lanes says ` no lanes declared · + New shell`. Above the rule, when the pane has 18 rows or more,
175
+ the strip row is the fleet economy line when `economy.json` knows a fleet fact (` $8.5k/16k 53% · F2 S1 · spill 76%`,
176
+ see "Economy projection"); otherwise it names the OTHER workspaces whose agents need you (` ◆1 Worlds · ◇2 Code`,
177
+ from the status.json already read). Under `LANES_GLYPHS=ascii` every glyph, separator and quote has a seven-bit twin (` > close 'sh'? y/n`,
178
+ ` Enter open - / find - x close - ? keys`).
105
179
  - **A newer layout added a tab (e.g. `Throttle`):** `lanes upgrade-session` adds it to the live sessions without a
106
- rebuild and leaves you on the tab you were on.
180
+ rebuild and leaves you on the tab you were on. It never changes an existing tab's panes: a changed pane (e.g. the
181
+ phone picker after plan Task 26, which `lanes doctor` flags) needs `lanes rebuild <device>`.
107
182
  - **A new workspace:** add it to `~/.config/lanes/workspaces/catalog.json` + a manifest, `lanes sync`, then
108
183
  `lanes rebuild <device>` (the only time a layout changes).
109
184
  - **Registry invalid:** the workspace keeps its previous lanes; the sidebar footer and `lanes doctor` show why.
185
+ - **The Studio Host (optional, `lanes-studio-host.service`):** `lanes studio-host status` names the state —
186
+ `running`, `waiting-for-enrolment` (the deploy has not written a receipt yet; the unit is enabled and the enrol
187
+ timer active, so it installs the receipt within 5 minutes), `receipt-refused` (a receipt is on disk and the
188
+ installer refused it — the code, when, and the next retry are named; the timer's one line per tick is in
189
+ `journalctl --user -u lanes-studio-host-enrol.service`), `key-refused` (the registration key was tried and
190
+ `studio enroll` refused it — code, exit, when, next retry), `credential-for-gone-key` (a receipt on disk for a
191
+ machine key this machine no longer holds, for a row it has NOT measured revoked: revoke the row in the console,
192
+ then `lanes studio-host re-key --yes`; the installer never enrols a second machine beside it by itself),
193
+ `not-enabled` (not enrolled and the unit or the enrol
194
+ timer is not enabled, so nothing retries: re-run `sh install/enable-studio-host.sh`),
195
+ `stopped` (exit 3 is the Host's typed refusal: `journalctl --user -u lanes-studio-host.service -n 30`, then
196
+ `systemctl --user start lanes-studio-host.service` or re-run `sh install/enable-studio-host.sh` — a check red only on
197
+ that run's own recorded refusal no longer blocks the start; the retry timer does NOT restart it on the same
198
+ enrolment),
199
+ `receipt-unreadable` / `key-unreadable` (the file exists but this account cannot read it — e.g. a receipt a deploy
200
+ run as another account wrote 0600: give it to this account), `conflict` (another Host unit runs here: one Host per
201
+ machine), `not-installed`, or `unknown` (no answer from the user manager; a shell without `XDG_RUNTIME_DIR` falls
202
+ back to `/run/user/<uid>`). `status --check` exits 1 (`running-check-failed`) when the unit runs but its own
203
+ `studio host --check` does not pass. A receipt refused `receipt_server_mismatch`: fix the Host's `server` in
204
+ `~/.config/throttle-rc/studio-host.toml` — the timer retries the same receipt when the config changes, a manual
205
+ `sh install/enable-studio-host.sh` retries it at once. A new binary: `lanes studio-host install --from FILE --sha256 HEX`, then re-run
206
+ `sh install/enable-studio-host.sh` (checks, then restarts). Revoked by the owner (`journalctl` says revoked; the
207
+ deploy warns `host_revoked`): `lanes studio-host re-key --yes`, then deploy again — see
208
+ [Sharing](SHARING.md#after-the-owner-revokes-this-machine). See [Sharing](SHARING.md#co-located-studio-host-one-host-per-lanes-server).
110
209
 
111
210
  ## When the host is overloaded
112
211
 
@@ -125,7 +224,7 @@ fails first. A question that got no answer is never published as "the lane is go
125
224
  | 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. |
126
225
  | Viewport status screen WITHOUT that line | The lane's tmux socket is really gone. | "A dead lane, or a blind harness?" below. |
127
226
  | 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. |
128
- | 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. |
227
+ | 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. |
129
228
  | 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). |
130
229
 
131
230
  ### Look, without adding load
@@ -135,6 +234,8 @@ lanes pressure memory / io / cpu pressure, swap, load, per-container
135
234
  shmem) and /tmp fill. Plain files + statvfs: safe on a thrashing host. Exit 1 unless level ok.
136
235
  cat ~/.local/state/lanes/health what statusd wrote last; at= and status_at= are epochs — compare with `date +%s`
137
236
  lanes doctor every external call is bounded; "N shown with their last known state" = blind, not dead
237
+ `preview renderer:` pinned glow | system glow (<path>) | less -R — looked up on the view
238
+ server's PATH, the one the Ctrl-Alt-m pane sees, never your shell's
138
239
  lanes invariant check 1 s: every live pane's pid and size against the last `invariant save`
139
240
  ```
140
241
 
@@ -197,6 +298,20 @@ refused. Everything removed is logged in `~/.local/state/lanes/tmpscan/deleted.l
197
298
  touch. Socket there and the server's pid alive = the lane lives and only the harness is blind.
198
299
  5. `health` older than 30 s (`at=` against `date +%s`) = statusd itself is starved: the sidebar shows history.
199
300
 
301
+ How statusd looks, and why one wedged server cannot freeze the board: it finds the live tmux servers in each
302
+ workspace container's OWN cgroup (`cgroup.procs` + `comm` + `cmdline` under `/proc`, the same walk as `lanes guard`;
303
+ no `ps`, no fork), probes at most 8 servers at once, and stops waiting `PROBE_TIMEOUT + 2` = 7 s after the round
304
+ began. A probe still running then is published as `"observed": "timeout"` (last state, `stale`), the other servers'
305
+ answers go out on time, and the next round starts only once that probe's own bound has passed (another 7 s). A
306
+ server the deadline cut carries `"cut": true` in `status.json` `servers` and is probed FIRST next round, so with more
307
+ servers than 8 the same tail is not cut every cycle (and never drifts to `unknown` while it answers in time). A
308
+ container whose recorded init pid cannot be walked (not a docker cgroup, a recycled pid) makes the round treat THAT
309
+ container's socket files as possibly live — nothing of it is concluded gone; the other containers keep their real
310
+ live sets. In `journalctl --user -u lanes-statusd`: `observe: … still running at the 7 s cycle deadline (first next
311
+ round): <container>/<socket> …` names the abandoned probes, and `live servers <container>: pid <n>: …` names a
312
+ container that became unwalkable (logged once, when it starts). `lanes invariant` stays all-or-nothing: one
313
+ unwalkable container makes it refuse rather than save a partial pane list.
314
+
200
315
  A lane is dead only on a POSITIVE observation: its server answered without that session (`–`), its pane died (`!`),
201
316
  or its container is down (`✖`).
202
317
 
@@ -226,6 +341,11 @@ Coexistence mode can be turned back on at any time with `touch ~/.local/state/la
226
341
  - Terminal: Ghostty in a dedicated instance (`~/.config/lanes/ghostty.conf`, inherits `~/.config/ghostty/config`, so
227
342
  Omarchy theme switches follow). Foot stays the system default; without Ghostty the window falls back to Foot with
228
343
  the same class. Install Ghostty only: `omarchy-pkg-add ghostty` (`omarchy-default-terminal` is not touched).
344
+ - Font: the Omarchy default font; a Nerd Font is not required by lanes — every glyph is plain Unicode
345
+ (`LANES_GLYPHS=ascii` for any font; the blocks a font needs are listed under "TUIs and the terminal's colours"
346
+ below). The Zellij bar runs with `simplified_ui true`, so it draws no Private-Use-Area chevrons either.
347
+ - Look of the window: `lanes-window --ascii`, `--light`, `--dark` (combinable with a suffix: `lanes-window --ascii w2`)
348
+ — see "TUIs and the terminal's colours" below.
229
349
  - Hyprland: one line `require("hypr.lanes")` at the end of `~/.config/hypr/hyprland.lua` + `~/.config/hypr/lanes.lua`
230
350
  (validated with `hyprctl configerrors`; backup `hyprland.lua.pre-lanes.*`). Pin it to a workspace by uncommenting
231
351
  the `o.window` line in `lanes.lua`.
@@ -255,6 +375,67 @@ night sky of single-dot Braille "stars". Started with no client attached it pain
255
375
  - It stops recurring when the clients agree on brightness: the same light/dark choice in Cursor's terminal and in the
256
376
  lanes window (Ghostty `theme = light:…,dark:…` follows the OS and therefore flips twice a day).
257
377
 
378
+ The sidebar's own colours and glyphs come from one token table (`libexec/tokens.py`; spec
379
+ `docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md` §2.4) and three switches, resolved per device:
380
+
381
+ | Switch | Values | Default |
382
+ |---|---|---|
383
+ | `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) |
384
+ | `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 |
385
+ | `LANES_GLYPHS` / `--ascii`, `--glyphs` | `unicode` · `ascii` · `emoji` | `unicode`; `ascii` when none of `LC_ALL`/`LC_CTYPE`/`LANG` names UTF-8 |
386
+ | `LANES_DIM` | `90` · `2` | `90`; 16-colour mode only: `2` is the escape hatch for a palette whose colour 8 is near the background |
387
+
388
+ - Set it per device, live: `lanes attach DEVICE --ascii` (or `--glyphs M`, `--theme T`, `--color C`) records the
389
+ choice in `~/.local/state/lanes/devices/<device>.json` (only for a device that already has that file; a bad value
390
+ refuses before anything is created). Every running sidebar of that device re-reads the file within one tick — no
391
+ restart. The choice survives a recreated session (`lanes ensure` after a reboot, `lanes rebuild`). Environment
392
+ variables never reach a running sidebar: Zellij panes keep the server's environment from the moment the session
393
+ was created. The device file wins over the environment.
394
+ - The sidebar publishes what it resolved: `clients/<device>.theme` (`dark`/`light`, on change or when absent; for
395
+ `glow`) and `clients/<device>.glyphs` (`unicode`/`ascii`/`emoji`, on change or when absent; read by `view.sh`).
396
+ - The terminal's answer to the OSC 11 question is taken out of the input before any key is read, also when it is
397
+ split across two reads, so it never cancels a filter or a prompt.
398
+ - Dim text is SGR 90 in 16-colour mode (never SGR 2, which several terminals render as invisible). A palette whose
399
+ colour 8 is close to the background (classic Solarized dark) can set `LANES_DIM=2` as the escape hatch.
400
+ - From the Mac or Omarchy window: `lanes-window --ascii`, `--light` or `--dark` (before an optional suffix:
401
+ `lanes-window --dark w2`) hands `--ascii` / `--theme light|dark` to `lanes-launch`, which passes them to
402
+ `lanes attach` — the same per-device record as above. `lanes-launch DEVICE --ascii|--theme T|--glyphs M|--color C`
403
+ takes them directly; a bad value refuses before any connection, an unknown option is named and dropped.
404
+ - The viewport's own status screens (`view.sh`: "THIS VIEWER DISCONNECTED", "No lane selected", "retrying in …") use
405
+ the same tokens (`libexec/tokens.sh`, sourced once, no fork): a bold lead with `⚠` — `!` when
406
+ `clients/<device>.glyphs` says `ascii`, read on every screen, so `lanes attach DEVICE --ascii` reaches the next
407
+ status screen without a restart — and the hint line in SGR 90 instead of SGR 2.
408
+ - Fonts: no Nerd Font is needed. Any monospace font with Latin-1, Box Drawing (U+2500 `─`), Geometric Shapes
409
+ (U+25A0–25FF `● ○ ◆ ◇ ▸ ▌ █`), Dingbats (`✓ ✗`), Arrows (`↑ ↓`) and `⋮ ⏎ ⚠ …` renders the cockpit (JetBrains Mono,
410
+ SF Mono, Menlo, Cascadia, DejaVu Sans Mono all do); `--ascii` is the complete remedy for any other font.
411
+ - The lanes window inherits your everyday Ghostty look (fonts, theme, padding: `client/ghostty/common.conf` never sets
412
+ them). Opt in to the cockpit's own palette with `./install.sh --palette` (client install): it installs
413
+ `~/.config/lanes/ghostty-palette.conf` — generated from the token table (`client/ghostty/lanes-palette.conf` and
414
+ `lanes-palette-light.conf`, `libexec/tokens.py --ghostty-dark|--ghostty-light`; slots 1–4 are the four tones, so
415
+ 16-colour mode under it equals truecolor mode) — and includes it last in `~/.config/lanes/ghostty.conf`. On Ghostty
416
+ 1.1 or newer (`ghostty +version`) that file is `theme = light:…,dark:…` over two installed theme files, so the
417
+ window still flips with the OS; on an older or unknown Ghostty it is the dark palette alone (the light one is
418
+ installed next to it as `ghostty-palette-light.conf`). Ghostty lays a `theme` UNDER every colour key
419
+ (`background`, `foreground`, `palette = N=…`, cursor/selection colours) set explicitly anywhere in the loaded
420
+ configuration, whatever the file order — and the lanes window loads your everyday config first. So the installer
421
+ walks the everyday config and every file it reaches through `config-file`: if any sets a colour key (Omarchy does —
422
+ its config includes the current theme's `ghostty.conf`), a theme pair would silently keep your everyday colours, and
423
+ it installs the explicit dark palette instead and says which file forced that (`dark only: … sets colours
424
+ explicitly`). Ghostty also loads `config-file` includes breadth-first (one first-in-first-out list:
425
+ `Config.zig` `loadRecursiveFiles`), so a file two includes deep — Omarchy's theme `ghostty.conf` — loads after
426
+ every direct include of the lanes `ghostty.conf`, the palette's included. The installer therefore records the
427
+ everyday chain's deepest include and places the palette keys (or the theme pair) behind as many one-line hop files
428
+ (`ghostty-palette.1.conf`, …) as it takes to sit at that depth, where they load last; on Omarchy that is one hop,
429
+ and the `say` line names it. A plain `./install.sh` re-run without `--palette` removes the include and all palette
430
+ files: inherit-first is the default. NOT yet looked at on a real Ghostty (2026-10-04: no Mac or Omarchy client was
431
+ used; the installer was exercised against a fake `ghostty` that reports a version, fake everyday configs and a
432
+ model of Ghostty's load order read from its source). DEF-1547 stays open until that is seen: run `--palette` on a
433
+ Mac (Ghostty ≥ 1.1) and on Omarchy, and the window background must be `#101318` (the dark `surface` token).
434
+ - OSC 11 on a real Ghostty view is NOT yet verified (2026-10-04: Task 22 ran on the server with no Mac client
435
+ attached, and no live session was touched). To check: with the Mac in light mode, `lanes attach mac` from the Mac,
436
+ then on the server `cat ~/.local/state/lanes/clients/mac.theme` — `light` means the answer arrived and was parsed;
437
+ `dark` means no answer within 0.3 s (the sidebar painted dark). Record the result here.
438
+
258
439
  ### "tmux server … is not running" on every lane at once (deleted sockets)
259
440
 
260
441
  Seen 2026-09-20 04:24: something emptied `coder-dev-work:/tmp`, and with it `/tmp/tmux-1000` — the directory of every
@@ -281,15 +462,19 @@ only normalizes HOW a terminal reaches its lane:
281
462
  shows an ordinary git diff to review and commit. Boards that keep their registry outside a repository
282
463
  are additionally projected into the file Cursor reads (`"cursor"` in `workspaces/*.json`).
283
464
  - `lanes cursor-sync <ws> --canonicalize` (dry run; add `--write` to apply) replaces every tmux lane's recorded
284
- create recipe that cannot create the lane — none, attach-only, or a script missing in the container — with the
465
+ create recipe that cannot create the lane — none, attach-only, the retired `~/.config/coder-launch` path (the three
466
+ classes `lanes doctor` warns about, DEF-1541), or a script missing in the container — with the
285
467
  canonical `tmux new -ADs … -e LANE_ID=… -e LANE_WS=… bash -l` recipe (old value kept as `createPrevious`).
286
468
  `--identity` adds the same `-e` flags to a working plain `tmux -L S new -ADs N [-c D] CMD` recipe, keeping CMD's
287
469
  argv; any other working recipe is left alone and its lane carries no identity. These `-e` flags are the ONLY way a
288
- tmux lane gets `LANE_ID`/the trace: `lane open` deliberately exports none (DEF-1422 — the first client on a shared
470
+ tmux lane gets `LANE_ID`/the trace: `lane open` deliberately exports none (DEF-1559 — the first client on a shared
289
471
  board socket would otherwise hand its identity to every later lane there). Both change the file only: a RUNNING
290
472
  lane keeps its environment and gains the identity when it is next created. Before enabling it on a live board,
291
- read DEF-1423: a `lanes/<ws>/<id>` trace makes the whole lane — main session, subagents, fan-out — one proxy
292
- fairness bucket.
473
+ read DEF-1560: a `lanes/<ws>/<id>` trace makes the whole lane — main session, subagents, fan-out — one proxy
474
+ fairness bucket. Applying `--canonicalize --write` or `--identity --write` to a live workspace is an operator gate
475
+ (plan "Operator gates"): it rewrites operator-declared recipes in the repo's `.vscode/terminals.json` (old value kept
476
+ as `createPrevious`, file backup under `cursor-sync-backups/<ts>/` as above) and produces the first live `lanes/<ws>/<id>` trace — decide DEF-1560 lane-level
477
+ vs per-session fairness first.
293
478
  - Size policy "the lanes view wins": lanes-view tmux clients run with `TERM=xterm-lanesview` (a user-level terminfo
294
479
  alias of xterm-256color). While such a client shows a lane, every other client of that lane (Cursor terminals,
295
480
  ad-hoc attaches) is in tmux `ignore-size`; `lanes-statusd` reconciles every few seconds and gives editors the size
@@ -297,6 +482,209 @@ only normalizes HOW a terminal reaches its lane:
297
482
  included, and both halves apply there: an `ignore-size` an operator set by hand on such a socket is given
298
483
  back like any other once no view shows the lane (DEF-982).
299
484
 
485
+ ## Installing agent hooks (opt-in, per container, dry run first)
486
+
487
+ Without hooks the sidebar's agent glyph comes from Claude's session files and Codex's pane title. Hooks make it
488
+ event-driven: Claude Code or Codex runs `~/.lz/bin/lane-signal <status>` on a turn event, which writes
489
+ `~/.lz/state/hooks/<lane id>.json` in the container, and `lanes-statusd` prefers that over the other signals while
490
+ its agent runs and its last event is under an hour old. Nothing installs a hook for you. (`~/.lz/state/<name>.json`
491
+ itself is `lane-run`'s phase record of a zmx lane; the hook never writes there. A hooked agent in a zmx lane writes
492
+ its sidecar under the zmx session name, which the sidebar does not show yet — DEF-1573.)
493
+
494
+ 1. `lanes install-container <name>` — puts `lane-signal` in `~/.lz/bin/` and the definitions in `~/.lz/hooks/`.
495
+ 2. `lanes hooks install --agent claude --container <name>` — prints the unified diff it would make to that
496
+ container's `~/.claude/settings.json` (your own hooks and keys stay; an earlier lane-signal install is replaced,
497
+ not duplicated). Read it. Then add `--write` (the old file is kept as `settings.json.lanes-bak-<time>`).
498
+ 3. `lanes hooks install --agent codex --container <name>` — the same for `~/.codex/hooks.json`, plus a top-level
499
+ `notify = [".../lane-signal", "done", …]` in `~/.codex/config.toml` only when that file has no top-level
500
+ `notify` (an existing notifier is left alone). Codex asks you to review and trust new hooks before running
501
+ them (`[hooks.state]` in its config.toml); lanes never writes that trust.
502
+
503
+ Expect a hook to reach only agent sessions started after the install (both harnesses read their hook
504
+ configuration at start — INFERRED, not measured here). `--home <dir>` targets a home directory instead of a
505
+ container (for trying the merge on a copy). Undo: restore the `.lanes-bak-*` file, or delete the groups whose
506
+ command contains `lane-signal`.
507
+
508
+ What each event writes (`agent{}` vocabulary):
509
+
510
+ | Harness | Event | status |
511
+ |---|---|---|
512
+ | Claude Code | `UserPromptSubmit`, `PostToolUse` | `working` |
513
+ | Claude Code | `Stop`, `Notification` (`idle_prompt`) | `needs_input` |
514
+ | Claude Code | `PermissionRequest`, `Notification` (`permission_prompt`) | `needs_approval` |
515
+ | Claude Code | `StopFailure` | `errored` |
516
+ | Claude Code | `SessionEnd` | `shell` |
517
+ | Codex | `SessionStart` | `idle` |
518
+ | Codex | `UserPromptSubmit`, `PostToolUse` | `working` |
519
+ | Codex | `PermissionRequest` | `needs_approval` |
520
+ | Codex | `Stop`, `notify` (turn complete) | `done` |
521
+ | Codex | `SessionEnd` | `shell` |
522
+
523
+ Verified event names (2026-10-04, this host): Claude Code 2.1.288's hook-event list compiled into its binary
524
+ (`PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch, Notification, UserPromptSubmit, UserPromptExpansion,
525
+ SessionStart, SessionEnd, Stop, StopFailure, SubagentStart, SubagentStop, PreCompact, PostCompact, PreModelSwitch,
526
+ PostModelSwitch, PermissionRequest, PermissionDenied, …`); Codex 0.160.0's hook event variants in its binary
527
+ (`PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, SessionEnd, SubagentStart,
528
+ SubagentStop, Interrupt`, plus `UserPromptSubmit` and `Stop`), and the shape of a trusted project
529
+ `.codex/hooks.json` (`{"hooks": {Event: [{"matcher": null, "hooks": [{"type": "command", "command", "timeout"}]}]}}`).
530
+ Codex 0.160 has no `codex --help hooks`; the names come from the binary. Not yet proven on a live lane: that Codex
531
+ loads a USER-level `~/.codex/hooks.json` the way it loads a project's (the only hooks.json files measured here are
532
+ project files), and what a hook's parent process is under each harness (lane-signal skips one shell either way).
533
+
534
+ Known limit (DEF-1515): after you approve a permission prompt, the lane shows `needs_approval` until the approved
535
+ tool call returns (`PostToolUse`), because neither harness signals "approved". A long approved command therefore
536
+ reads as waiting for you until it finishes. A lane whose agent crashed loses its sidecar at once (dead pid); one
537
+ whose agent is alive but silent keeps the last word for up to an hour.
538
+
539
+ ## Economy projection (opt-in)
540
+
541
+ `~/.local/state/lanes/economy.json` is what each lane costs and how hard the fleet is pushing the Throttle pool:
542
+ a fleet strip (overflow spend against the enforced daily cap, alert level, pressure phase and spill fraction,
543
+ effectively free accounts, the soonest 5 h reset, new upstream 429s since the last poll, paid and subscription
544
+ requests in flight, the Fable governor's sustainable rate) and, per lane, live figures from the daemon's
545
+ recent-request ring (model, paid share, $/h, tokens/min, cache-read ratio, downgrades, 429s, age of the last
546
+ request) plus today's totals folded from `proxy.log` (requests, paid requests, the four token classes — dollars only
547
+ as `list_usd_estimate`, which stays `null`; lanes keeps no price table).
548
+
549
+ **What the sidebar shows from it.** Every sidebar re-reads `economy.json` when its mtime changes (one `stat()` per
550
+ tick). Column 34 of a lane row is one tail cell (`⚠` starving, `↓` downgraded, `$` mostly paid; `?` — not observed —
551
+ beats all three). The strip row above the footer rule (panes of 18 rows or more) becomes the fleet economy line, parts
552
+ in priority order and whole parts dropped from the end to fit 34 cells:
553
+
554
+ | Part | From | Shown |
555
+ |---|---|---|
556
+ | `$8.5k/16k 53%` | `overflow_spend_today_usd` / `overflow_budget_usd`, `spent_fraction` | amber at or over `alert_level_pct` |
557
+ | `429 +4` | `upstream_429_delta` | only when > 0, amber |
558
+ | `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 |
559
+ | `spill 76%` | `spill_fraction` | amber unless `pressure_phase` is `closed` |
560
+ | `free 3/19` | `effective_free` / `accounts` | |
561
+ | `reset 2h` | `soonest_reset_5h` | only while in the future |
562
+
563
+ A leading `?` and an all-dim line mean the figures are frozen: the poller kept its last fleet strip because `/status`
564
+ failed (`"stale": true`), or the poller stopped — the poller rewrites `economy.json` at least every 60 s, and a sidebar
565
+ treats a document more than 180 s old by its own clock as frozen (DEF-1528). A lane's tail cell follows the same rule:
566
+ a `⚠` or `↓` drawn dim (not amber) is the last known figure, not a live alarm — the poller kept that lane's record
567
+ because `/sessions` failed (`"stale": true` on the lane), or the whole document is frozen. With
568
+ `LANES_THROTTLE_CONTROL=none` every fleet figure is `null`: the strip row then shows fleet attention exactly as without
569
+ a poller, and no row carries an economy cell (the day totals carry no live figure).
570
+
571
+ **Operator gate.** `lanes-economy.service` sends a GET to the Throttle daemon's control port (`/status` and
572
+ `/sessions`, default `http://127.0.0.1:19181`) every 10 s. It is installed with the other units and enabled by
573
+ nothing. Turning it on is your call.
574
+
575
+ **Where it runs — read this before enabling.** The unit is a HOST user unit (the server installer renders it beside
576
+ `lanes-statusd`); the daemon, its control port and its log normally live inside a workspace container. Neither
577
+ default reaches them from the host:
578
+
579
+ - The control URL is resolved in the HOST's network namespace. A daemon bound to `127.0.0.1` inside its container
580
+ (`[server] host = "127.0.0.1"`, measured 2026-10-04 in the `work` workspace container) is not reachable from the host at all, and
581
+ `http://127.0.0.1:19181` on the host is a different loopback. Until the control port is reachable from the host —
582
+ an operator change to the daemon's bind or forwarding, outside lanes — set `LANES_THROTTLE_CONTROL=none`: the
583
+ fleet strip and live per-lane figures stay `null`, the log fold still works (DEF-1525, open).
584
+ - The log lives in the container's home. Set `LANES_THROTTLE_CONTAINER=<container name>`: the poller reads
585
+ `/home/coder/.local/share/throttle` through that container's home volume as `lanes sync` recorded it in
586
+ `~/.local/state/lanes/containers.json` (the container must be on a board). The host user's own
587
+ `~/.local/share/throttle` is the default only for a poller that runs where the daemon runs.
588
+
589
+ Check what the poller can see before you enable it — `--once` names every source it could not read on stderr
590
+ (`connection refused`, `timed out`, `HTTP 401`, `log dir missing: …`, `log dir unreadable: …`,
591
+ `container … is not in containers.json`). `log dir unreadable` on a host unit usually means the container's home
592
+ volume sits under the docker data root (`docker info -f '{{.DockerRootDir}}'`: `/var/lib/docker` by default,
593
+ `/data/docker` on ceobox, where the workspace homes are named volumes at `<root>/volumes/coder-<id>-home/_data`),
594
+ which only root normally traverses. The log half then needs the same operator decision as the control half
595
+ (DEF-1525): point `LANES_THROTTLE_LOG_DIR` at a directory this user can traverse, or run the poller where the daemon
596
+ runs:
597
+
598
+ ```sh
599
+ LANES_THROTTLE_CONTROL=none LANES_THROTTLE_CONTAINER=<container> lanes economy --once --dry-run # writes nothing
600
+ systemctl --user edit lanes-economy.service # put the same two settings in the drop-in ([Service] Environment=)
601
+ systemctl --user enable --now lanes-economy.service
602
+ journalctl --user -u lanes-economy.service # one line each time a source goes down or comes back
603
+ ```
604
+
605
+ Before you enable it, settle DEF-1560 (the lane-stable trace's fairness consequence) if any lane was created with
606
+ the identity recipe; lanes without it are joined to their requests by `agent.session_id` alone.
607
+
608
+ Settings (environment of the installer — `install/services.py` renders them into this unit only, from
609
+ `./install.sh --server` and from `install/enable-studio-host.sh` alike, which re-renders every lanes unit — or a
610
+ `systemctl --user edit lanes-economy.service` drop-in):
611
+
612
+ | Variable | Default | Meaning |
613
+ |---|---|---|
614
+ | `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) |
615
+ | `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 |
616
+ | `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`) |
617
+ | `LANES_THROTTLE_LOG_DIR` | `~/.local/share/throttle` | where `proxy.log` and `proxy.log.<day>` are read (read only); wins over `LANES_THROTTLE_CONTAINER` |
618
+
619
+ Behaviour you can rely on: GET only, 3 s timeout, no proxy from the environment, no redirects followed (a redirect
620
+ would carry the bearer elsewhere). A failed `/status` keeps the last fleet strip with `"stale": true`; a failed
621
+ `/sessions` keeps each lane's last live figures with `"stale": true`; `source` says which of status, sessions and
622
+ log answered, and the journal (stderr) says why one did not — a line when a source's failure class changes, `ok`
623
+ 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
624
+ (`economy.cursor.json`) reads only bytes appended since the last poll and keeps ten days. Deleting both files is safe:
625
+ the next poll re-folds today. For a look without the daemon: `LANES_THROTTLE_CONTROL=none lanes economy --once
626
+ --dry-run --status-file status.json --sessions-file sessions.json` with saved documents.
627
+
628
+ ## Push notifications (opt-in)
629
+
630
+ `lanes-notify.service` sends one push to an [ntfy](https://ntfy.sh) topic when a lane's agent ENTERS needs-you —
631
+ `agent.status` becomes `needs_input` (`Needs you`), `needs_approval` (`Needs approval`) or `errored` (`Error`), the
632
+ 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
633
+ OSC 9 ("Title and notifications" above): it reads only `~/.local/state/lanes/status.json` (checked every 3 s,
634
+ re-read when its mtime changes) and `registry.json` for lane names; it never touches a lane, a session or the
635
+ Throttle daemon.
636
+
637
+ What a push says: `<lane name>: <agent task> — <status>` and, on a second line, `lanes-launch lane <id>` (the
638
+ command that opens that lane from a client). No client registers a URL handler for that command, so a push is
639
+ tappable only when you set `action_url` (below).
640
+
641
+ When nothing is sent:
642
+
643
+ - the status did not change, or the lane left needs-you (a later return is news again); a change from
644
+ `Needs you` to `Needs approval` is news;
645
+ - the service just started: lanes already waiting when it (re)starts are not announced (no burst on restart —
646
+ unlike a starting sidebar's OSC 9);
647
+ - a desk device was present within `quiet_when_device_active_s` (default 120 s): a `lanes attach` from any device
648
+ (`clients/<device>.<client>.pid`), or a lane opened from the sidebar of a desk device
649
+ (`clients/<device>.seen.json`, counted only for a device that has a `.pid` — one `lanes attach` has run for —
650
+ 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
651
+ opening a lane the pushes resume (DEF-1532). A suppressed transition is dropped, not sent later. Phone presence
652
+ is decided by the device's mobile layout, not by how it attached: a Safari phone writes no `.pid`, and an
653
+ SSH-only phone that ran `lanes attach phone --mobile` (below) is still a mobile device, so opening a lane from
654
+ the phone's picker never mutes pushes — you keep getting them while you are away from every desk (DEF-1533).
655
+ Only a phone's own `lanes attach` in the last two minutes counts, like any attach.
656
+
657
+ A failed send (server down, `HTTP 429`, no network) is one journal line naming the lane and the failure class — the
658
+ topic and the token are never logged — and is not retried.
659
+
660
+ Config `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` in the server installer's environment — or
661
+ `install/enable-studio-host.sh`'s, which re-renders every lanes unit — renders another path into this unit only),
662
+ `key=value` lines, `#` comments, re-read on change:
663
+
664
+ | Key | Default | Meaning |
665
+ |---|---|---|
666
+ | `url` | required | the ntfy server, `https://…` (self-hosted or `https://ntfy.sh`) |
667
+ | `topic` | required | `[A-Za-z0-9_-]{1,64}`; on a public server the topic name is the only secret — pick a long random one |
668
+ | `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` |
669
+ | `quiet_when_device_active_s` | `120` | presence window above; `0` never suppresses |
670
+ | `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 |
671
+
672
+ Without a valid `url` and `topic` the service only tracks transitions and sends nothing. Turn it on:
673
+
674
+ ```sh
675
+ $EDITOR ~/.config/lanes/notify.conf # url=, topic=, token_file= …
676
+ python3 ~/.local/share/lanes/current/libexec/notify.py --test # one test push; exit 1 = not sent
677
+ systemctl --user enable --now lanes-notify.service
678
+ journalctl --user -u lanes-notify.service # send failures, one line each
679
+ ```
680
+
681
+ **Operator gate.** The unit is installed with the others and enabled by nothing: it sends lane names and agent
682
+ task titles to the server you configure. Not yet run against any ntfy server or phone (unit-proven only).
683
+
684
+ **Not part of this:** answering a `PermissionRequest`-held approval from the phone. A push only says the lane needs
685
+ you; approving from the notification needs a request/response channel back into the hook that holds the prompt — a
686
+ follow-up design.
687
+
300
688
  ## Throttle Studio as a client
301
689
 
302
690
  Throttle Studio (`throttle-rc studio host` in a workspace container, the `/workspaces` view of the Throttle console)
@@ -359,14 +747,14 @@ already ignore it. Afterwards the in-container path is on your clipboard and in
359
747
 
360
748
  | Way | How |
361
749
  |---|---|
362
- | 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. |
750
+ | 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`. |
363
751
  | 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. |
364
752
  | Command | `lanes-upload [-w <board>] [--paste] <files…>` · `lanes-upload --clipboard [--paste]` (clipboard image or copied files) |
365
753
  | macOS droplet | `~/Applications/Lanes Upload.app`: drop files on it (Dock); double-click uploads the clipboard screenshot |
366
754
  | anarchy | `Super+Shift+U` uploads the clipboard and types the path into the lane |
367
755
 
368
756
  Why click first: a terminal delivers a drop as a *paste of the local path* into the FOCUSED pane (not the pane under
369
- the pointer), so the box has to hold the focus when you let go. And the server only ever receives that path string:
757
+ the pointer), so the strip has to hold the focus when you let go. And the server only ever receives that path string:
370
758
  the file itself is sent by the **upload helper** that `lanes-launch` runs on your machine. The helper is the security
371
759
  boundary — it reads only from folders listed in `~/.config/lanes/upload-allow` (defaults: Desktop, Downloads,
372
760
  Pictures, Documents, Lanes Uploads, temp folders; macOS asks once per new folder; `lanes-upload --allow <dir>`), and
@@ -399,6 +787,34 @@ On the phone, connect Tailscale and open the printed URL. Paste the token into t
399
787
  when tapping **Type here** or another text input; swipes, live updates and reconnects leave it closed.
400
788
  **More → Text size** remembers your preferred terminal font. The phone has its own view selection of the same agents.
401
789
 
790
+ The lane picker shows three rows per lane: the agent glyph and the name (`›` before the lane this phone views), then
791
+ what the agent needs and since when with its harness (`Needs you · 25m · claude`; a shell says `Quiet · 2h`), then the
792
+ agent's task. Its first line counts the lanes that need you (`◆1 need approval · ◇2 need you · ▸6 working`). The same
793
+ counts come from the picker pane's title (see "Title and notifications" above): the header subtitle on the picker,
794
+ the page title, a number on the **Lanes** button for lanes needing approval or input, and `◆ 1 · ◇ 2` on each
795
+ workspace card. Nothing is fetched or polled for them. Not yet confirmed (DEF-1531): until plan Task 26 (2026-10-04)
796
+ the phone's picker pane had the fixed layout name `Lanes`, which Zellij reports instead of the sidebar's title, so the
797
+ phone showed no counts. Task 26 removed that name from the generated layout, which reaches a phone only when its
798
+ session is laid out again (`lanes rebuild <device>`; see the device check below); whether the title
799
+ now reaches the phone is still unchecked in a browser — until then only the picker's own first line is known to
800
+ show them (DEF-1531).
801
+
802
+ The phone's light/dark toggle repaints the whole terminal, not only the shell: the page applies its own sixteen-colour
803
+ palette on attach, on every toggle and after every Zellij config reload (DEF-1413; not yet looked at on a device).
804
+
805
+ 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
806
+ without opening the keyboard. `n` and `^C` write only after you hold them for 400 ms; a short tap says
807
+ "Hold to send". With the keyboard open the row stays above the keyboard's modifier bar. The filter bar on the picker
808
+ sends its text as UTF-8, so a filter like `ø` matches.
809
+
810
+ Device check (plan Task 13 step 3, not yet run): on a real iPhone in Safari and from the Home Screen, tap each quick
811
+ key on a lane tab and confirm that the keyboard does NOT open, that `y`/`⏎`/arrows arrive in the lane, that a short tap
812
+ on `n`/`^C` sends nothing and a hold sends it, and (plan Task 26 has landed; DEF-1531 waits on this) that the Workspaces
813
+ sheet shows each picker's counts. A phone device created before Task 26 keeps the old picker pane (`upgrade-session`
814
+ does not re-lay-out a tab): run `lanes rebuild <device>` first (`lanes doctor` must not list it under DEF-1531), or no
815
+ counts appear and the check says nothing about the title path. Record the
816
+ device, iOS version and result here. The unit suite covers only the picker's text, not the browser.
817
+
402
818
  Tailscale Serve forwards HTTPS 8443 to `lanes-mobile.service` on `127.0.0.1:8083`. The separate unprivileged
403
819
  Caddy serves `web/` and forwards native authentication/assets/WebSockets to Zellij on `127.0.0.1:8082`.
404
820
  The Mac is a client; serving happens on the workspace host. See [frontend maintenance](../web/README.md).
@@ -434,13 +850,16 @@ Laptop and tablet browsers: see the [installation guide](INSTALL.md#laptop-and-t
434
850
  - pty-name collision guard: the lanes' `client-attached` hook detaches a client whose tty *name* equals the
435
851
  lane's `pane_tty`; host and container pty numbers collide, so the holder re-wraps itself on a fresh pty.
436
852
  - Views attach only from a pane zellij has really sized (unviewed tabs are 14x49) and only after a first client.
437
- - The sidebar binds in `zellij/config.kdl` walk `MoveFocus "left"` once per possible viewport before `WriteChars`:
438
- with a single left, `Ctrl-Alt-↓` pressed in the second viewport typed a `J` into a lane.
853
+ - The two binds in `zellij/config.kdl` that still type into the sidebar (`Ctrl-Alt-/`, `Ctrl-Alt-u`) walk
854
+ `MoveFocus "left"` once per possible viewport before `WriteChars`: with a single left, a key pressed in the second
855
+ viewport typed into a lane. `Ctrl-Alt-↓/↑` type nothing since plan Task 19 (`lanes next|prev --focused`).
439
856
  - Tests: `python3 -m unittest discover -s tests/unit` needs no server (runs on the Mac) and carries the harness's
440
857
  pure-function pins — the viewer rule, the client-size bucketing, the status keying, the shared-socket
441
858
  attribution and the zmx word (`tests/unit/test_alignment.py`). Live suites, on a QUIET host only:
442
859
  `tests/guards.py`, `tests/view.py` (incl. the wide-view key walk), `tests/upload_box.py`,
443
- `tests/smoke_real.py` (read-only, real lanes), `tests/latency.py` (from a client). All use disposable lanes except
860
+ `tests/smoke_real.py` (read-only, real lanes), `tests/latency.py` (from a client; `tests/latency.py --switch
861
+ [samples]` ON the server measures `switch` = SIGUSR1 to view.sh → first byte of the new lane, with a throwaway
862
+ view.sh on a temp `LANES_STATE` and two sessions on `-L lanes_test`, run ad hoc). All use disposable lanes except
444
863
  the explicitly read-only smoke test; every one refuses to start on a busy host (exit 75) and removes its lanes and
445
864
  sessions when it is killed with SIGTERM / SIGHUP.
446
865