@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
@@ -8,21 +8,117 @@ terminal. Installing or closing a view does not end a lane.
8
8
 
9
9
  1. The deployment's `catalog.json` orders workspaces and selects their manifests.
10
10
  2. Each manifest identifies container names and repository `.vscode/terminals.json` paths.
11
- 3. `lanes sync` reads those files through the container's mounted home and emits the host registry,
12
- exact socket/session resolve files, and the container's `.lz/registry.json` projection.
11
+ 3. `lanes sync` reads those files through the container's mounted home, includes the site's optional
12
+ `registered-sessions.json`, and emits the host registry,
13
+ exact socket/session resolve files, and the container's `.lz/registry.json` projection. It asks docker about
14
+ every board container in ONE `docker inspect` and rewrites a projection only when its content changed
15
+ (`atomic_write_if_changed`; the two registry documents compare without `generated_at`), so a timer sync with
16
+ nothing new wakes no reader that watches an mtime. The sync time itself is kept in `registry.synced`.
13
17
  4. The status daemon observes tmux sessions and publishes status/health snapshots. Failed or timed-out
14
18
  probes mean unknown; they do not prove a missing session. A bad terminal registry retains the last good board.
15
19
 
20
+ Beside the six-word `state` (never changed by it), each live tmux lane, declared or unregistered, may carry
21
+ an additive `agent{}` key: `{harness, status, name, session_id?, pid?, since, source}` with `status` one of
22
+ `working | needs_input | needs_approval | done | errored | shell | idle`. An absent key means no signal names
23
+ an agent there. `statusd` builds it with `agent_state.merge` from signals the harnesses already emit, read once
24
+ per container per cycle through `/proc/<container init pid>/root` without spawning anything. Precedence: a
25
+ hook sidecar > a Claude `~/.claude/sessions/<pid>.json` naming the pane
26
+ `<session>:@<win>.%<pane>` (live pids only, checked in the container's pid namespace, recycled pids rejected
27
+ by `procStart`) > the Codex pane title (`[ ! ] Action Required` → `needs_approval`, braille spinner →
28
+ `working`, a task title → `idle`) > the window bell (an idle title plus a bell is `done`; an approval title
29
+ wins over the bell). A Codex title over a bare-shell foreground whose shell has no child process
30
+ (`pane_has_child`, read from `/proc/<pid>/task/*/children`) is `shell`: the harness ended and tmux kept its
31
+ title. Unknown liveness leaves that key unset and never demotes. The tmux columns this needs
32
+ (`window_bell_flag`, `window_id`, `pane_id`, `pane_title`) are appended to the listing format, the free-text
33
+ title last so a tab inside it cannot shift another column.
34
+
35
+ The hook sidecar is `~/.lz/state/hooks/<lane id>.json` in the container, written by `~/.lz/bin/lane-signal`
36
+ (Python 3 stdlib, installed by `lanes install-container`) when an opt-in Claude Code or Codex hook fires:
37
+ `{v: 1, lane, harness, status, since, at, event, pid, start, session}` — `since` when the status began (kept
38
+ while the same status is re-signalled), `at` the last event, `pid`/`start` the agent process and its
39
+ starttime. The lane id is `$LANE_ID` (in a tmux lane only through a canonical recipe's `-e` flags; in a zmx lane
40
+ `lane-run` exports it as the zmx session name) or the pane's tmux session looked up in `~/.lz/registry.json`; an unresolvable lane, a word outside the vocabulary or an
41
+ unwritable directory is a silent exit 0, and nothing is ever printed (a Claude prompt/session hook's stdout
42
+ becomes model context). `statusd` lists each container's `state/hooks/` once per cycle (`agent_sidecars`) and
43
+ `agent_state.read_sidecar_dict` drops a sidecar whose last event is older than `SIDECAR_MAX_AGE` (3600 s) or
44
+ whose agent pid is dead or recycled in the container's pid namespace. The sidecar has its own directory because
45
+ `lane-run` writes each zmx lane's `{phase, exit, at, pid}` record to `~/.lz/state/<zmx name>.json` and exports
46
+ that same name as `LANE_ID`: one shared file name would let a hook overwrite the record the zmx observer and the
47
+ container `lane ls` read (DEF-1436). A zmx lane's sidecar is therefore named by its zmx session, not by the
48
+ `<ws>--z-<name>` id statusd publishes, and does not reach that lane's `agent{}` yet — the zmx lane's `agent` key
49
+ stays lane-run's record (DEF-1437). A published sidecar adds `event` to `agent{}` and has `source: hook`. Hooks are
50
+ never installed implicitly: `lanes hooks install --agent claude|codex --container <name>` prints the merge and
51
+ writes only with `--write` (RUNBOOK "Installing agent hooks").
52
+ 5. A lane's `source` is the container's spelling of its `terminals.json`. Every host-side reader of that file
53
+ maps it to the container's home volume first (`statusd.registry_source_paths`, deduplicated by that host path:
54
+ one repository cloned in two containers is two files): the status daemon's hot-sync
55
+ starts one `lanes sync` per edit (at most one per 10 s), `lanes doctor` reports "registry up to date" from the
56
+ same host-side mtimes (against the later of `generated_at` and `registry.synced`), and promoting an unregistered session writes the binding through the volume.
57
+
16
58
  `lanes cursor-sync` is a separate, explicit writer. Its default is a diff; `--write` takes backups and changes
17
59
  editor commands to `lane open <id>`. The original create recipe and exact tmux binding remain in the registry's
18
60
  `lanes` block. Automatic sync never commits or pushes those edits. `cursor-commit --push` is an opt-in workflow.
19
61
 
62
+ Lane identity is an environment contract: `LANE_ID` (`<ws>--<slug>`), `LANE_WS` (`<ws>`), and the Throttle trace
63
+ `lanes/<ws>/<id>` as `THROTTLE_TRACE` and as `ANTHROPIC_CUSTOM_HEADERS='x-throttle-trace: lanes/<ws>/<id>'` (Claude
64
+ Code sends it on every request; the proxy's trace charset is `[A-Za-z0-9._-/]`, at most 256 characters, and a
65
+ trace outside it is never emitted). A tmux lane gets it in exactly one way: the `-e` flags of
66
+ `lanes_lib.canonical_recipe()` — `tmux -L <socket> new -ADs <session> -c <folder> -e … <cmd>` — which set the
67
+ SESSION environment of that one lane on a real create (`-A` ignores them on an attach). Never through `tmux
68
+ set-environment` and never through a client's environment, because of a tmux rule MEASURED on tmux 3.4 (the
69
+ container's version) with a throwaway socket: the client that STARTS a socket's server hands its whole environment
70
+ to the server's GLOBAL environment (`show-environment -g`), and every later session on that socket inherits it
71
+ unless its own `-e` overrides each key — a session created by a client carrying `LANE_ID=b` but no `-e` still got
72
+ the server starter's `LANE_ID=a`, and one created with `-e LANE_ID=c` alone kept `a`'s `THROTTLE_TRACE`. Board
73
+ sockets are shared (9 of the 10 bw-tools fixture lanes are on one socket), so `lane open` execs nothing — attach or
74
+ recipe — that carries a lane identity, this lane's or one inherited from another lane's editor terminal (DEF-1478);
75
+ a recipe run by it may start the server with an identity-free global environment only. An attach cannot change a
76
+ running session's env, so a running lane gains its identity only when it is next CREATED by a recipe carrying `-e`.
77
+ A legacy recipe without `-e` gives its lane no identity. `lanes cursor-sync --canonicalize` (dry-run unless
78
+ `--write`) gives every tmux lane whose recorded `create` cannot create it — null, attach-only (`attach-session`
79
+ without `new`), naming the retired in-container `~/.config/coder-launch` path (these three are
80
+ `lanes_lib.classify_recipe`'s classes, the ones `lanes doctor` warns about), or naming an absolute `.sh`/`.py`/…
81
+ script missing in the container — the canonical recipe with
82
+ `bash -l`; `--identity` gives a WORKING recipe of the plain shape `[exec] tmux -L <socket> new -ADs <session> [-c
83
+ <dir>] <command…>` the same `-e` flags, re-quoting the command's words one for one so tmux receives the same argv
84
+ (any expansion, glob, operator or other shape is left alone and stays identity-less). Both keep the old value as
85
+ `createPrevious`; a recipe whose paths cannot be checked (outside the home volume) is never counted as broken.
86
+ zmx lanes get `LANE_ID` (only) from `lane-run`, unchanged.
87
+
88
+ The trace is also a FAIRNESS key, not only an attribution key: the proxy uses `x-throttle-trace` when present and
89
+ synthesizes `cc-session/<session>[/<agent>]` only when it is absent (`src/proxy.rs`, `synthesize_flow_key`), and the
90
+ pool's parking queue is round-robin across trace keys at slot handoff (`src/pressure.rs`, `acquire_fair`). A lane
91
+ stamped `lanes/<ws>/<id>` therefore puts its main session, every subagent and any workflow fan-out started in it
92
+ into ONE fairness bucket where today each Claude session/agent is its own. `max_in_flight_per_trace` is configured
93
+ and reported but no enforcement site exists in `src/` (MEASURED by search; `pressure.rs` documents fairness as "not a
94
+ hard cap"). Whether a lane should be one bucket is an open decision for the plan owner before any live enablement
95
+ (DEF-1479); this tree does not change the header shape. Unit-proven only (`test_lib_probe` `LaneOpenIdentity`,
96
+ `CursorSyncCanonicalize`, `CursorSyncIdentity`, stopping at the exec boundary); the disposable-lane
97
+ `tests/cursor_lane.py` exercises the installed `/home/coder/.lz/bin/lane`, so it proves this only after `lanes
98
+ install-container`.
99
+
20
100
  ## Views and panes
21
101
 
22
102
  `lanes attach DEVICE` creates or reattaches the device's Zellij view. Layouts have one tab per workspace,
23
103
  a lane sidebar, and one to three viewports. The mobile layout uses separate picker and full-width view tabs.
24
104
  Its hidden viewports do not attach at placeholder dimensions; they wait until a real client displays them.
25
105
 
106
+ **Visual system:** one token table (`libexec/tokens.py`) is rendered into the sidebar's escapes, the Zellij themes,
107
+ the phone CSS (and its xterm.js palette in `mobile.js`), the viewer's shell (`libexec/tokens.sh`), the optional Ghostty
108
+ palette files and the docs table; `tokens.py --check` fails on drift in any of them. `docs/DESIGN.md` is the legend:
109
+ glyphs with their ASCII twins, modes (`LANES_COLOR`, `LANES_THEME`, `LANES_GLYPHS`, `LANES_DIM`, `LANES_NOTIFY`), row
110
+ anatomy, mockups and budgets; `tests/unit/test_docs.py` keeps the README legend complete against the glyph tables.
111
+
112
+ Frames and bars take their colours from the `lanes-dark`/`lanes-light` themes generated by `libexec/tokens.py`; the
113
+ compact bar is the only plugin; the mobile picker's sidebar pane has no layout name so its own title (attention
114
+ counts) reaches the phone. `zellij/config.kdl` adds, after the generated token block, one `ui` block whose
115
+ `pane_frames` set `rounded_corners false` and `hide_session_name true` (straight frames to match the sidebar's `─`
116
+ rules, no session name in the bar) and `simplified_ui true` (no Private-Use-Area chevrons, so no Nerd Font is needed);
117
+ `load_plugins` stays empty. The desktop sidebar keeps `pane size=36 borderless=true name="Lanes"` (Throttle Studio's view rule reads
118
+ that name); the view pane is borderless at one viewport and framed at two or three. `tests/unit/test_layout.py` pins
119
+ the generated layout and `test_keybinds.Chrome` the config; `zellij --config zellij/config.kdl setup --check` is the
120
+ parse check (0.45.1).
121
+
26
122
  The tmux holder reaches the container's socket through `/proc/<init-pid>/root`, scrubs the viewer environment,
27
123
  and uses `tmux -N` so an attach cannot create a server accidentally. Guarded geometry and client identification
28
124
  keep the visible view and editor attachments coordinated. Zmx is a separately pinned alternative holder;
@@ -39,20 +135,59 @@ manifest `discoveryRoots` extends those roots. A discovered assignment remains s
39
135
  a session no single board can claim is listed by EVERY owner of its socket rather than dropped, and ambiguity is
40
136
  logged rather than resolved by guess.
41
137
 
138
+ **+** beside an unregistered tmux session opens **Add to workspace**. It offers workspaces using the session's
139
+ container, with the current workspace first. Registration verifies the observed session ID/creation time and
140
+ saves its existing lane ID, name and exact binding in the version-1 `registered-sessions.json` site file.
141
+ It publishes the registered lane immediately without signaling viewers or processes. Sync, registration and
142
+ explicit close share a bounded registry lock; close also consults saved registrations if projection publication
143
+ failed. Stale observations cannot put a registered session back in Unregistered.
144
+
145
+ These registrations have no create recipe and do not change repository terminal lists. Repository declarations
146
+ win if they later declare the same binding. Unknown versions or malformed registration files refuse mutation;
147
+ registrations for removed workspaces are retained on disk and ignored until the workspace returns. Removing a
148
+ record and syncing unregisters it, including when a repository registry is temporarily invalid. Keep this site
149
+ file in operator backups; deleting derived state does not delete registrations.
150
+
42
151
  Explicit **New +** starts a detached tmux shell inside the workspace container on its dedicated
43
152
  `lanes-shells-<workspace>` socket. Reopening only attaches. Explicit **×** closes an unregistered session after
44
153
  rechecking its session ID and creation time and refusing registered lanes; affected viewers become empty.
45
154
  The tmux close condition is checked inside the server, protecting a replacement created between observation
46
155
  and the action. `user-sessions/` stores display metadata; `status.json` and `resolve.dynamic/` are projections.
47
- Legacy Docker shells imported into `legacy-sessions/` are observed by PID/start identity. Closing rechecks the
48
- owner, preserves that root identity through the process-family scan, and requires a verified root pidfd before
49
- signaling the original process family. Failed verification keeps the recovery record. These older connections cannot be recreated; their
50
- metadata is local recovery state, never a recipe to start replacement jobs. See [state contracts](../../docs/master-datamodel.md).
156
+ The pre-persistence Docker-shell tracker (`legacy-sessions/`, `legacy_sessions.py`, `lanes verify-legacy`) was
157
+ retired by plan Task 19: statusd no longer reads that directory (a record still there is inert — kept on disk,
158
+ never observed, never closed), and a `legacy` row from an older status.json is refused by close. See
159
+ [state contracts](../../docs/master-datamodel.md).
51
160
 
52
161
  The `lane` command lives in `/home/coder/.lz/bin`. `open` attaches or creates using its saved recipe;
53
162
  `attach` never creates; `send`, `capture`, `ls` and `status` are explicit operations. Only `kill` ends a lane.
54
163
  These commands operate inside the container, independently of the Zellij viewer's lifetime.
55
164
 
165
+ Every verb dispatches on the lane's holder. An id that `~/.lz/registry.json` holds as `holder: tmux` is driven
166
+ through its exact socket and session, by a tmux client that carries no lane identity (`identity_free`, as `open`):
167
+ `send` types the text literally and then Enter in one client (`send-keys -t =<session>: -l -- TEXT ; send-keys
168
+ Enter`; `--no-enter` omits it), `capture` is `capture-pane -p -J -S -N|- -t =<session>:` (`--vt` adds `-e`) trimmed
169
+ to the last N non-blank lines, `status` prints `{id, holder, ws, title, tmux, alive, agent}` from `has-session` and
170
+ the lane's `lane-signal` hook sidecar (exit 1 when not alive), `attach` refuses to nest and never creates, and
171
+ `kill` is `kill-session`. Two tmux 3.4 facts shape these argv (MEASURED on a disposable `lanes_test` server): a bare
172
+ `-t =<session>` is refused by `send-keys` and `capture-pane` ("can't find pane") while `=<session>:` resolves, and
173
+ tmux's argv parser ends a command at a trailing `;` of ANY argument, so `echo a;` would type `echo a` — `send`
174
+ writes that last `;` as `\;`. Any other id is a zmx lane (a registry `holder: zmx` entry maps to its `zmx.name`) and
175
+ keeps the zmx path; zmx `send` still writes the bytes as given, never an Enter of its own. `kill` now needs `--yes`
176
+ on both holders and refuses while the lane's hook sidecar says `working` unless `--force`; the container sees only
177
+ that sidecar (Claude session files and pane titles are judged on the host by `lanes-statusd`), so on a lane without
178
+ installed hooks the refusal cannot fire (DEF-1439). `ls --json` stays the zmx-only listing Throttle Studio reads —
179
+ its zmx holder takes EVERY row as a zmx lane, so tmux rows there would publish each tmux lane twice; `ls --all` adds
180
+ the registry's tmux lanes with `alive` from one `list-sessions` per socket (and still lists them in a root without the zmx binary, where the default listing keeps failing as before). zmx rows gain `alive`, and `status` of a
181
+ zmx lane gains `hook` (its sidecar); `agent` stays lane-run's phase record (DEF-1436).
182
+
183
+ The bw-tools agent launchers (`.bw-tools/bin/claude`, `codex`) exec the agent binary (shell `exec` → `agent.py`
184
+ `os.execvpe`), so a lane started through them shows the agent as the pane's foreground command. The operator's
185
+ `~/.local/bin/eval-claude` runs Claude as a child instead (it appends `session_end` to its trace file after the
186
+ exit), so a pane started through it keeps `bash` as its foreground command (DEF-1440); on the launcher path the end
187
+ of a session is the `SessionEnd` hook's `lane-signal shell`, when hooks are installed. `.bw-tools/bin/claude` runs
188
+ `$EVAL_CLAUDE_REAL_BIN` when it is set (the binary eval-claude would run) and skips it when it names a `.bw-tools`
189
+ shim, so `EVAL_CLAUDE_REAL_BIN=<checkout>/.bw-tools/bin/claude eval-claude` composes the two without a loop.
190
+
56
191
  ## Desktop and uploads
57
192
 
58
193
  The client installer creates an SSH alias and dedicated terminal configuration, using the selected profile's
@@ -77,6 +212,23 @@ The phone shell uses the pinned client's window hooks for workspaces, panes, inp
77
212
  swipe, refresh or server message cannot summon the keyboard. Native taps on input fields still work.
78
213
  Safe-area insets and the visual viewport handle the Home indicator, rotation and keyboard.
79
214
 
215
+ `web/mobile.js` chooses one of three modes once at load, by capability (`laneShellMode`), and records it on
216
+ `<html data-lanes-shell>`. A **phone** (mobile user agent, or a touch screen at most 600 px wide or 500 px tall)
217
+ gets the shell above unchanged. A **tablet** (any other touch screen) gets the same shell with its dock in the top
218
+ bar and the workspace sheet as a left panel. It also asks the pinned client for its mobile UI, because iPad Safari
219
+ reports a desktop user agent. That UI opens in single pane on every load; `web/browser.js` then sends the client's
220
+ own full-view preference once, so a desktop layout's sidebar shows beside the lanes. A **desktop** browser (fine
221
+ pointer, any width) keeps the plain client. Only a **Lanes** chip is added; it toggles an overlay bar with
222
+ workspace and lane pickers. The bar reads the `MobileState` that Zellij sends to every web client and sends the
223
+ phone picker's `FocusPane`. It overlays the terminal, so the terminal's columns and rows do not change, and a
224
+ click on the chip leaves the keyboard focus in the terminal. The tablet and desktop code and styles live in
225
+ `web/browser.js` and `web/browser.css`, which `mobile.js` appends only in those two modes, so a phone never
226
+ downloads them: a phone fetches only the two files spec §8 budgets, MEASURED 2026-10-04 after the fan-in with
227
+ plan Tasks 13/28 at 21 964 B of 22 174 B (`mobile.js`, whose explanations live in `web/README.md`, Code notes)
228
+ and 18 945 B of 18 982 B (`mobile.css`). `lanes web <device> [--desktop|--mobile]` prepares a browser view,
229
+ enables the services through `install/enable-web.sh` and prints the private URL; without a flag a known device
230
+ keeps its layout.
231
+
80
232
  ## Installed files and services
81
233
 
82
234
  `install/services.py` renders the service templates against the actual checkout and installed binary paths.
@@ -0,0 +1,250 @@
1
+ # Lanes design system
2
+
3
+ The operator-facing legend for the Lanes cockpit: what every glyph, colour and switch means, how a sidebar row is
4
+ laid out, and the budgets each surface is held to. The token and glyph tables below are generated from
5
+ `libexec/tokens.py` (`python3 libexec/tokens.py --write`; `--check` fails on drift), the one source the sidebar's
6
+ escapes, the Zellij themes, the phone CSS, the viewer's shell (`libexec/tokens.sh`), the optional Ghostty palette and
7
+ this page are projected from. The design of record is
8
+ [`docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md`](../../docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md)
9
+ at the repository root; this page condenses it for daily use and never overrides it.
10
+
11
+ ## Principles
12
+
13
+ 1. **Attention before identity before telemetry:** column 2 says who needs you, columns 4–27 which lane, columns
14
+ 29–34 how long and doing what.
15
+ 2. **Never colour alone:** every state has a glyph, a tone and an ASCII twin, and a fixed column; `LANES_COLOR=none`
16
+ leaves a fully readable sidebar.
17
+ 3. **One token set, many renderers:** `libexec/tokens.py` is the only place a colour or glyph is defined; every
18
+ other surface is a generated projection pinned by a drift test.
19
+ 4. **Theme-agnostic by construction:** the sidebar paints foreground only, learns dark/light once (OSC 11), falls
20
+ back to dark, and keeps lane identity colours in your own 16-colour palette.
21
+ 5. **Dim is a colour, not an attribute:** no SGR 2 in any frame; ink-dim in truecolor, bright black (`90`) in
22
+ 16-colour mode.
23
+ 6. **Restraint:** one accent, for selection, viewing marks and the brand only; no emoji in rows, one upload line, a
24
+ one-line legend plus the `?` key sheet.
25
+ 7. **Every row says how long:** a 3-cell age on every row — the agent's `since`, else the last output.
26
+ 8. **Marks never lose to names:** viewer dots and viewport marks are reserved first; the name is clipped and ends
27
+ in `…`.
28
+ 9. **The cursor follows the lane, not the index:** re-sorts never move your focus in a list that scrolls.
29
+ 10. **Performance is a design property:** cached row bytes, one width walk per name, frames written only when dirty,
30
+ OSC 2/9/11 in the same write; budgets below.
31
+ 11. **Pinned bytes change deliberately:** a moved byte assertion is rewritten with a role name, never loosened.
32
+ 12. **The phone is a list, not a terminal:** the system-font shell carries identity, counts and actions.
33
+ 13. **Docs are the fourth surface:** this page, the README legend and the RUNBOOK column table are kept with the
34
+ code; the screenshots are user-supplied and never regenerated.
35
+
36
+ ## Modes
37
+
38
+ Four switches pick the look; `devices/<device>.json` (written by `lanes attach DEVICE --ascii|--theme|--glyphs|--color`
39
+ and by `lanes-window --ascii|--light|--dark`) wins over the environment and reaches a running sidebar within one tick.
40
+
41
+ | Switch | Values | Default | Where read |
42
+ |---|---|---|---|
43
+ | `LANES_COLOR` | `truecolor` · `16` · `none` | `truecolor` when `COLORTERM` ∈ {`truecolor`, `24bit`} (config.kdl's `env` block forces it for every pane), else `16`; `NO_COLOR` set ⇒ `none` | `sidebar.py` at start **and on every tick**: `devices/<device>.json` is part of `reload()`'s mtime stamp (one `stat()`), its `color` key wins over env, and a changed file re-runs `resolve_modes()` → `sgr_table()`/`glyph_set()` and clears the row cache (`apply_look()`). Zellij panes inherit the *server's* environment at session creation, so an environment variable can never reach a running sidebar; the device file is the one channel that does |
44
+ | `LANES_THEME` | `dark` · `light` · `auto` | `auto`: one OSC 11 query `\033]11;?\033\\` written at start; the reply (`\033]11;rgb:RRRR/GGGG/BBBB\033\\` or BEL-terminated, 8- or 16-bit components) is parsed in `handle()` and **swallowed** — it never cancels a filter or prompt; relative luminance < 0.5 ⇒ dark; no reply within 300 ms ⇒ dark; no re-ask | same; `devices/<device>.json` key `theme` (per tick, as above); `STATE/clients/<device>.theme` is written (`dark`/`light`, only on change) for `glow` |
45
+ | `LANES_GLYPHS` | `unicode` · `ascii` · `emoji` | `unicode`; `ascii` automatically when none of `LC_ALL`/`LC_CTYPE`/`LANG` contains `UTF-8` | same; `devices/<device>.json` key `glyphs` (per tick, as above); `lanes attach DEVICE --ascii` / `--theme light` write them and every running sidebar of that device picks them up within one tick; `lanes-window --ascii`/`--light`/`--dark` pass them through. The sidebar publishes the resolved mode as `STATE/clients/<device>.glyphs` (`unicode`/`ascii`/`emoji` + newline, written on change or when absent) — the file `view.sh` reads with a builtin `read` for its status screens (§7) |
46
+ | `LANES_DIM` | `90` · `2` | `90` | 16-colour mode only: `2` is the documented escape hatch for palettes whose colour 8 is near the background (classic Solarized dark) |
47
+ | `LANES_NOTIFY` | `1` · `0` | `1` on desktop devices, `0` on mobile | OSC 9 emission (§4.9) |
48
+
49
+ ## Tokens and glyphs
50
+
51
+ Every glyph table is `key -> (unicode, ascii, tone)`: agent glyphs are drawn only in column 2, stream glyphs only in
52
+ column 33, marks only where the row anatomy puts them. `LANES_GLYPHS=ascii` swaps in the ASCII column, so an ASCII
53
+ frame is seven-bit. The `ANSI-16` column is what `LANES_COLOR=16` emits; truecolor uses the Dark or Light value.
54
+
55
+ <!-- BEGIN lanes tokens (generated by libexec/tokens.py --md; do not edit by hand) -->
56
+ | Role | Dark | Light | ANSI-16 | CSS variable |
57
+ |---|---|---|---|---|
58
+ | surface | `#101318` | `#f3f5f2` | `—` | `--lanes-surface` |
59
+ | surface-raised | `#191e25` | `#ffffff` | `—` | `--lanes-surface-raised` |
60
+ | line | `#2c333d` | `#d5ded6` | `—` | `--lanes-line` |
61
+ | accent-wash | `#1a2721` | `#e5f0e6` | `—` | `--lanes-accent-wash` |
62
+ | accent-line | `#4b6955` | `#acc9b4` | `—` | `--lanes-accent-line` |
63
+ | press | `#26332c` | `#d8e9db` | `—` | `--lanes-press` |
64
+ | chip | `#ffffff0f` | `#edf3ec` | `—` | `--lanes-chip` |
65
+ | ink | `#edf1f5` | `#17241b` | `39` | `--lanes-ink` |
66
+ | ink-dim | `#9ca7b5` | `#56616d` | `90` | `--lanes-ink-dim` |
67
+ | ink-faint | `#6b7683` | `#7f8a96` | `90` | `--lanes-ink-faint` |
68
+ | accent | `#b4e3bf` | `#2f6b45` | `1;39` | `--lanes-accent` |
69
+ | on-accent | `#101318` | `#ffffff` | `—` | `--lanes-on-accent` |
70
+ | on-accent-urgent | `#7a1a12` | `#ffd3cc` | `—` | `--lanes-on-accent-urgent` |
71
+ | tone-urgent | `#f28b7d` | `#b7322a` | `31` | `--lanes-tone-urgent` |
72
+ | tone-attention | `#e5b84a` | `#8a5a00` | `33` | `--lanes-tone-attention` |
73
+ | tone-working | `#7fb3f2` | `#2456a8` | `34` | `--lanes-tone-working` |
74
+ | tone-done | `#6fd39a` | `#1b7142` | `32` | `--lanes-tone-done` |
75
+ | scrim | `#000000a6` | `#1a222b80` | `—` | `--lanes-scrim` |
76
+ | shadow | `0 -16px 80px #0007` | `0 -16px 80px #0003` | `—` | `--lanes-shadow` |
77
+
78
+ | Glyph | Codepoint | ASCII | Tone | Meaning |
79
+ |---|---|---|---|---|
80
+ | `◆` | U+25C6 | `!` | tone-urgent | agent `needs_approval` |
81
+ | `◇` | U+25C7 | `?` | tone-attention | agent `needs_input` |
82
+ | `▸` | U+25B8 | `>` | tone-working | agent `working` |
83
+ | `✓` | U+2713 | `v` | tone-done | agent `done` |
84
+ | `✗` | U+2717 | `X` | tone-urgent | agent `errored` |
85
+ | `_` | U+005F | `_` | ink-dim | agent `shell` |
86
+ | `·` | U+00B7 | `.` | ink-dim | agent `idle` |
87
+ | `●` | U+25CF | `*` | tone-done | stream `active` |
88
+ | `○` | U+25CB | `o` | ink-dim | stream `quiet` |
89
+ | `✗` | U+2717 | `x` | tone-urgent | stream `missing` |
90
+ | `–` | U+2013 | `-` | ink-dim | stream `absent` |
91
+ | `!` | U+0021 | `!` | tone-urgent | stream `dead` |
92
+ | `·` | U+00B7 | `.` | ink-dim | stream `helper` |
93
+ | `?` | U+003F | `?` | ink-dim | stream `unknown` |
94
+ | `▌` | U+258C | `\|` | accent | mark `cursor` |
95
+ | `›` | U+203A | `=` | accent | mark `viewing` |
96
+ | `²` | U+00B2 | `2` | accent | mark `viewport-b` |
97
+ | `³` | U+00B3 | `3` | accent | mark `viewport-c` |
98
+ | `·` | U+00B7 | `.` | ink-dim | mark `viewer` |
99
+ | `…` | U+2026 | `~` | ink | mark `truncation` |
100
+ | `×` | U+00D7 | `x` | tone-urgent | mark `close` |
101
+ | `+` | U+002B | `+` | tone-done | mark `new` |
102
+ | `─` | U+2500 | `-` | ink-faint | mark `rule` |
103
+ | `⋮` | U+22EE | `:` | ink-dim | mark `more` |
104
+ | `⚠` | U+26A0 | `!` | tone-attention | mark `warning` |
105
+ | `↑` | U+2191 | `^` | ink-dim | mark `upload` |
106
+ | `↓` | U+2193 | `v` | tone-done | mark `armed` |
107
+ | `⏎` | U+23CE | `Enter` | ink-dim | mark `enter` |
108
+ | `█` | U+2588 | `_` | tone-working | mark `caret` |
109
+ | `?` | U+003F | `?` | ink-dim | mark `stale` |
110
+ | `›` | U+203A | `>` | ink | mark `lead` |
111
+ | `·` | U+00B7 | `-` | ink-dim | mark `sep` |
112
+ | `‘` | U+2018 | `'` | ink | mark `quote-open` |
113
+ | `’` | U+2019 | `'` | ink | mark `quote-close` |
114
+ | `✓` | U+2713 | `OK` | tone-done | mark `ok` |
115
+ | `✗` | U+2717 | `X` | tone-urgent | mark `fail` |
116
+ | `⚠` | U+26A0 | `!` | tone-attention | economy `starving` |
117
+ | `↓` | U+2193 | `v` | tone-attention | economy `downgraded` |
118
+ | `$` | U+0024 | `$` | ink-dim | economy `paid` |
119
+ <!-- END lanes tokens -->
120
+
121
+ ## Row anatomy
122
+
123
+ 1-based columns; every lane row is exactly 36 cells by `cell_width`, and column 36 is never written.
124
+
125
+ | col | content | tone |
126
+ |---|---|---|
127
+ | 1 | ▌ cursor or space | accent |
128
+ | 2 | `AGENT_GLYPHS[agent.status]`; blank without `agent{}`; ink-dim when stale | its tone |
129
+ | 3 | › viewing (not on the cursor row) · ² / ³ another viewport shows it · else space | accent |
130
+ | 4–27 | name (24 cells) in `38;5;N` or ink, **bold when viewing**; viewer dots reserved first, name clipped to `24 − len(dots)` and ending in `…` when clipped; truncation detected by `len(clipped) < len(name)` (no second width walk) | identity colour |
131
+ | 28 | `+` register control on an unregistered row that can be added to a workspace (closable and `R.eligible`; CHA-positioned at `cols − 8`; hit box `cols − 8 ≤ x < cols − 4`), else space — amended by Task 23 (§10, DEF-1428) | tone-done |
132
+ | 29–31 | age, right-aligned: `NNs` < 60 s, `NNm` < 60 min, `NNh` < 24 h, `NNd` ≤ 99 d; blank when nothing is known; source `agent.since` else `activity` | ink-dim; attention inside Needs you |
133
+ | 32 | space | |
134
+ | 33 | `GLYPHS[state]` — full tone without an agent, ink-dim with one (▸ + ○ is the long-tool-call case and stays readable) | its tone |
135
+ | 34 | tail: stale `?` > starving ⚠ > downgraded ↓ > paid `$` > space | ink-dim / attention |
136
+ | 35 | × on closable rows, else space | urgent |
137
+ | 36 | never written | |
138
+
139
+ Budget: 3 + 24 + 1 + 3 + 1 + 1 + 1 + 1 + 1 = 36. The close hit box stays `x ≥ cols − 4` (a click at `cols − 2` closes,
140
+ at `x = 5` opens — the pinned test keeps both). The `×` moved from `cols − 2` to `cols − 1`.
141
+
142
+ ## Mockups
143
+
144
+ Desktop, a 35-lane workspace at 36 × 40 in `unicode` and truecolor (spec §4.8). Every line is exactly 36 cells; the
145
+ real list scrolls where `⋮` is:
146
+
147
+ ```
148
+ Sakio ◆1 ◇2 ▸6 ✓1
149
+ ── ◆ Needs you (3) ─────────────────
150
+ ▌◆ Compiler - Orchestrator 25m ○
151
+ ◇›Runtime: supervisor 12m ○
152
+ ◇ granite_games 4m ○ ×
153
+ ── ▸ Working (6) ───────────────────
154
+ ▸²Runtime - Orchestrator·· 3m ●$
155
+ ▸ Runtime: rendering 41s ●
156
+ ▸ Runtime: contracts 2m ●
157
+ ▸ Runtime: rules 18m ○$
158
+ ▸ Runtime: events 9m ●
159
+ ▸ Runtime: value-hunter-r… 1h ○↓
160
+ ── ✓ Done (1) ──────────────────────
161
+ ✓ Sakio CLI 7m ○
162
+ ── Sakio (25) ──────────────────────
163
+ Granite 2h ○
164
+ Sakio rename 3d –
165
+ _ Runtime: waits-effects 12h ○
166
+ · Runtime: trace-replay 1d ○
167
+ Runtime: source-assemb…· 5h !
168
+ Runtime: conformance 2d ○
169
+ Runtime: integration 6h ✗
170
+ Runtime: wave-b-critic-… 9h ○
171
+ Design review 3h ○?
172
+ Docs 11h ○
173
+ Release 4d –
174
+ Bench 2d ○
175
+ Fixtures 8h ○
176
+ Harness 1d ○
177
+ Site 5d –
178
+ Infra 9h ○
179
+ ⋮ 9 more
180
+ ── Unregistered (5) ────────────────
181
+ + New shell
182
+ granite_brand_migration 2h ○ ×
183
+ refinery-workblocks 6h ? ×
184
+ ↑ uploads · click, then drop (U)
185
+ $8.5k/16k 53% · spill 76% · free 3
186
+ ────────────────────────────────────
187
+ ⏎ open · / find · x close · ? keys
188
+ ```
189
+
190
+ Row 3 is the cursor row (▌, no ›, bold name); row 4 is the viewing lane in this slot (›, bold); row 7 is shown in
191
+ viewport b (²) with two viewers (··). The same frame in ASCII mode, first rows:
192
+
193
+ ```
194
+ Sakio !1 ?2 >6 v1
195
+ -- ! Needs you (3) -----------------
196
+ |! Compiler - Orchestrator 25m o
197
+ ?=Runtime: supervisor 12m o
198
+ >2Runtime - Orchestrator.. 3m *$
199
+ ```
200
+
201
+ Phone, the `render_mobile` picker at 48+ columns with three rows per lane (spec §4.10): row A is the viewing mark,
202
+ the agent glyph and the name; row B the status word, age and harness; row C the agent's task.
203
+
204
+ ```
205
+ ◆1 need approval · ◇2 need you · ▸6 working
206
+
207
+ ── ◆ Needs you (3) ─────────────────────────────
208
+ ›◆ Compiler - Orchestrator
209
+ Needs approval · 25m · claude
210
+ Fix buildctl bugs
211
+ ◇ Runtime: supervisor··
212
+ Needs you · 12m · codex
213
+ Verify fleet plan mapping
214
+ ◇ granite_games ×
215
+ Needs you · 4m · claude
216
+ Port the brand migration
217
+ ── ▸ Working (6) ───────────────────────────────
218
+ ▸ Runtime: rendering
219
+ Working · 41s · claude
220
+ Renderer contracts wave B
221
+ ── Sakio (25) ──────────────────────────────────
222
+ Granite
223
+ Quiet · 2h
224
+
225
+ Swipe to scroll · Tap to open
226
+ ```
227
+
228
+ ## Budgets
229
+
230
+ Each budget is pinned by a test; a budget that is not met is an open defect row, never a raised number.
231
+
232
+ | Budget | Pin |
233
+ |---|---|
234
+ | `render(35 lanes + 5 unregistered, 36×60)` median of 50 after 5 warm-ups ≤ 2.0 ms hard (CI, loose); design target ≤ 0.9× the same-run baseline cold (today 0.459 ms here, binary-safe read) and ≤ 0.10 ms warm (row cache) — MEASURED 2026-10-04 after Task 23: cold 0.64× met; warm 0.27–0.31 ms at load ~23–30, NOT met, DEF-1429 open; the target is not raised | `test_sidebar_budget.test_desktop_render_time_and_frame` (time) + bench numbers in the commit message of every task touching `render()` |
235
+ | Frame ≤ 9216 B truecolor, ≤ 5632 B in `LANES_COLOR=16` at 36×60 (today 4330 B in both on the DEF-1417 fixture, 4297 B before it — `LANES_COLOR` is not read before Task 22; truecolor prefixes are the whole delta) | same test, `len(frame)` |
236
+ | Exactly `viewports` `open()` calls of target files per render (12 over 6 renders at 2 viewports today); `subprocess.Popen`/`run` never called | `test_render_opens_only_target_files_and_never_forks` (open counter on the view dir, Popen/run patched to raise) |
237
+ | Every emitted row ≤ 36 cells and column 36 blank on every row that is not made of the `rule` glyph alone. At Task 21 (before the one-row strip) the four upload-box rows are exempt as well — rows starting with `─ ┌ │ └`; Task 24 narrows the exemption to the rule when it removes the box | `test_no_row_exceeds_36_cells_and_column_36_is_blank` (ANSI- and OSC-stripped, binary-safe sink read, `cell_width`; and, because the strip hides positioned writes, every CHA/CUP column in the raw frame < 36 — today's maximum is 34; amendment 2026-10-04; and, because text positioned left of 36 can still run into it, every raw row walked cell by cell — `highest_cell` — writes no cell past 35, 36 on the exempt rows, over a fixture whose registrable ghosts carry both the `+` and the `×`; DEF-1417) with its detectors proven by `test_sidebar_budget_sabotage` |
238
+ | Per tick ≤ 7 `stat()` at one viewport — registry, status, target, health, `flags/sockets-lost` (today's five), economy (Task 12), `devices/<device>.json` (Task 22) — plus one per extra viewport; zero timers added; the OSC 11 query is one write at start | `test_tick_stats_at_most_seven_files` (one viewport); `test_theme_query_is_written_once` |
239
+ | Upload: select timeout stays 1.0 while uploading; `dirty` only on `.progress` mtime change | `test_upload_does_not_shorten_the_tick` (Task 24) |
240
+ | `render_mobile(35, 48×60)` ≤ 2.0 ms hard, frame ≤ 6144 B; target ≤ 0.45 ms cold / ≤ 0.10 ms warm (today 0.352 ms / 1663 B) | `test_mobile_render_time_and_frame` |
241
+ | `reload(force)` unchanged (0.14 ms at 35 / 0.39 ms at 183); fleet attention computed inside it from the loaded dict — NOT shown met: Task 24 measured 0.26–0.44 ms at 35 / 0.69–0.80 ms at 183 and Task 12 0.296 → 0.386 ms at 35 (load 30–62; the 0.14/0.39 figures are quiet-host figures), DEF-1483 open; the budget is not raised | bench in Task 24's and Task 12's commit messages; DEF-1483 |
242
+ | Phone: exactly one `setInterval(`, zero `setTimeout(`, no `backdrop-filter`, a painted `box-shadow` only in `.lanes-sheet-panel` and `.zj-mobile-menu` (a `box-shadow: none` override, as on the security modal, is not paint work and is not counted); `fetch(` absent; mobile.css grows ≤ 2048 B over today's 16 934 B, mobile.js ≤ 3072 B over today's 19 102 B (measured 2026-10-03; the caps are baseline + allowance, quoted in the test); `index.html` keeps its 9 SRI-pinned upstream assets | `test_web.test_js_timers_unchanged`, `test_web.test_no_scroll_time_effects`, `test_web.test_bundle_growth`, `test_web.test_integrity_pins_untouched` |
243
+ | Zellij: config.kdl grows by the theme + ui block only (~4 KB), `load_plugins {}` empty, no `rename-tab` from the sidebar | `test_keybinds.test_theme_block_and_plugin_free`, `test_tokens.test_generated_blocks_match_committed_files` |
244
+ | Unit suite stays under 75 s wall (MEASURED 2026-10-04 after Task 21: 833 tests, 124–176 s at load ~30–60 — exceeded, DEF-1415 open; the budget is not raised); new pins are exact-string asserts, no sleeps; `tokens.py --check` ≤ 50 ms, contrast test ≤ 10 ms | observed in each task's run step |
245
+
246
+ ## Spec
247
+
248
+ The full design system — token values and contrast, the glyph rules, header, sections, footer ladder, title and
249
+ notification bytes, Zellij chrome, phone shell and client alignment, and the decisions log — is
250
+ [`docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md`](../../docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md).
@@ -155,6 +155,45 @@ Connect Tailscale on the phone, open the printed URL in Safari, paste only the t
155
155
  `sh install/enable-web.sh` starts only the loopback services and prints no token. The frontend and services
156
156
  run on the workspace host; a Mac does not need to stay awake for the phone to work.
157
157
 
158
+ ### Laptop and tablet browser
159
+
160
+ The same private frontend serves every browser and chooses its controls once, when the page loads:
161
+
162
+ - **Laptop or desktop** (mouse or trackpad): the plain Zellij web client, unchanged, plus a small **Lanes**
163
+ chip in the top-right corner. The chip opens a thin overlay bar with a workspace picker and a lane picker;
164
+ the terminal keeps its size and all of its keys.
165
+ - **Tablet** (touch screen wider than 600 px): the phone controls arranged for the larger screen — Workspaces,
166
+ Lanes, Panes and **Type here** in the top bar, and the workspace list as a left panel. The terminal shows the
167
+ whole tab, so a desktop view's lane sidebar sits beside its lanes (**More → Single pane** shows one pane).
168
+ - **Phone**: the phone shell described above.
169
+
170
+ Give each browser its own view. On the server, one command prepares it, enables the loopback web services and
171
+ prints its private URL:
172
+
173
+ ```sh
174
+ lanes web ipad --desktop # desktop sidebar layout (a new device's default); --mobile: phone lane pickers
175
+ lanes web ipad --desktop --token # the same, then a fresh login token in this terminal
176
+ lanes web ipad --print-only # shows the exact prepare call and URL; changes nothing
177
+ ```
178
+
179
+ From an installed client, `sh install/enable-phone.sh --host lanes --desktop ipad` does the same over the
180
+ selected SSH key; without a name, `--desktop` uses the device `browser`. The server needs this version of Lanes
181
+ for `--desktop`. A device keeps the layout it was created with: `lanes web <device>` without a flag reuses it
182
+ (so `lanes web phone` reuses the phone's view), and an explicit request for the other layout is refused; choose
183
+ another device name. `lanes web` reads the node's Tailscale name. Tailscale Serve itself is set up once
184
+ by `enable-phone.sh`, as described above.
185
+
186
+ On a network with a captive portal (hotel, train or aircraft Wi-Fi), connect in this order:
187
+
188
+ 1. Complete the portal's sign-in page in the browser. Tailscale cannot connect until the portal lets traffic out.
189
+ 2. Connect Tailscale on the laptop or tablet and wait until it reports connected.
190
+ 3. Open the printed `https://<server>.<tailnet>.ts.net:8443/<device>` URL.
191
+ 4. Paste only the token value into the sign-in screen. Existing tokens remain valid; revoke one as shown above.
192
+
193
+ There is no public fallback by design: views are reachable only through the tailnet. On a host without a
194
+ Tailscale name, `lanes web` prints the loopback URL `http://127.0.0.1:8083/<device>` and says that it opens only
195
+ on the host itself.
196
+
158
197
  ## 5. Updates, optional operations and removal
159
198
 
160
199
  A server installed with `npx @vibelord/lanes --server` runs from the staged tree `~/.local/share/lanes/pkg/<version>/`; updating is `npx @vibelord/lanes@latest --server`, and the previous versions a service may still run from are kept.