@vibelord/lanes 1.0.1 → 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 (46) 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 +651 -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 +80 -13
  17. package/payload/lanes/docs/DESIGN.md +182 -4
  18. package/payload/lanes/docs/RUNBOOK.md +429 -39
  19. package/payload/lanes/docs/SHARING.md +1 -1
  20. package/payload/lanes/holders/exec.sh +2 -5
  21. package/payload/lanes/holders.lock +3 -0
  22. package/payload/lanes/install/host-install.sh +10 -1
  23. package/payload/lanes/install/services.py +30 -3
  24. package/payload/lanes/install.sh +108 -3
  25. package/payload/lanes/libexec/agent_state.py +32 -0
  26. package/payload/lanes/libexec/cursor_sync.py +10 -2
  27. package/payload/lanes/libexec/economy_poll.py +685 -0
  28. package/payload/lanes/libexec/guard.py +27 -1
  29. package/payload/lanes/libexec/lanes_lib.py +285 -38
  30. package/payload/lanes/libexec/notify.py +317 -0
  31. package/payload/lanes/libexec/registrations.py +1 -1
  32. package/payload/lanes/libexec/session_actions.py +7 -8
  33. package/payload/lanes/libexec/sidebar.py +911 -218
  34. package/payload/lanes/libexec/statusd.py +238 -69
  35. package/payload/lanes/libexec/tokens.py +38 -7
  36. package/payload/lanes/libexec/view.sh +13 -3
  37. package/payload/lanes/systemd/lanes-economy.service +20 -0
  38. package/payload/lanes/systemd/lanes-notify.service +19 -0
  39. package/payload/lanes/systemd/lanes-sync.service +3 -0
  40. package/payload/lanes/web/README.md +83 -4
  41. package/payload/lanes/web/index.html +2 -1
  42. package/payload/lanes/web/mobile.css +53 -198
  43. package/payload/lanes/web/mobile.js +87 -55
  44. package/payload/lanes/zellij/config.kdl +67 -16
  45. package/payload/manifest.json +4 -3
  46. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env python3
2
+ """lane-signal <status> [--event NAME] [--harness claude|codex] [PAYLOAD] — the hook target of Claude Code and Codex
3
+ inside a workspace container. Writes ~/.lz/state/hooks/<lane id>.json (atomic replace), which the host's
4
+ lanes-statusd reads through /proc/<container init pid>/root and publishes as the lane's `agent{}` (source `hook`).
5
+
6
+ The sidecar has its own directory because `lane-run`, the root process of every zmx-held lane, writes its
7
+ `{phase, exit, at, pid}` record to ~/.lz/state/<zmx name>.json and exports LANE_ID=<zmx name> to the agent: a
8
+ sidecar at state/<lane id>.json would overwrite that record for the agent's whole life (DEF-1436). Nothing but
9
+ this script writes state/hooks/, and lane-run never writes there.
10
+
11
+ {"v":1, "lane":"<id>", "harness":"claude|codex", "status":"<agent{} vocabulary>", "since":<epoch the status
12
+ began>, "at":<epoch of this event>, "event":"<hook event>", "pid":<agent pid>, "start":"<its starttime>",
13
+ "session":"<session/thread id or ''>"}
14
+
15
+ It never fails the agent and never speaks: every path exits 0 and nothing reaches stdout or stderr (a Claude Code
16
+ UserPromptSubmit/SessionStart hook's stdout becomes model context). An unresolvable lane, a status outside the
17
+ vocabulary or an unwritable state directory are silent no-ops.
18
+
19
+ Lane id: $LANE_ID — exported in a tmux lane only by a canonical/`--identity` recipe's `-e` flags (Task 7, DEF-1478;
20
+ `lane open` never exports it), and in a zmx-held lane by `lane-run` as the zmx session name (which is NOT the
21
+ `<ws>--z-<name>` id lanes-statusd publishes for it, so statusd does not join that sidecar to the zmx lane yet:
22
+ DEF-1437) — else the pane's tmux session (`tmux -S ${TMUX%%,*} display -p -t $TMUX_PANE '#S'`) looked up by (socket
23
+ name, session) in ~/.lz/registry.json, the projection `lanes sync` writes.
24
+
25
+ Agent pid: the parent process, or that parent's parent when the parent is a shell (Claude Code runs a hook command
26
+ through a shell, which may fork it rather than exec it; a pid that dies with the shell would read as a dead agent).
27
+ Session id: `session_id` of the hook's JSON on stdin, `thread-id` of a JSON PAYLOAD argument (Codex `notify` passes
28
+ its JSON as the last argument), else $CLAUDE_SESSION_ID / $CODEX_THREAD_ID. Stdin is read only when it is not a
29
+ terminal and for at most STDIN_WAIT seconds: a stdin that never closes cannot hang the hook.
30
+
31
+ Same status as the file already holds (same session) keeps `since` and advances `at`; a new status starts `since`.
32
+ """
33
+ import json
34
+ import os
35
+ import re
36
+ import select
37
+ import subprocess
38
+ import sys
39
+ import time
40
+
41
+ STATUSES = ('working', 'needs_input', 'needs_approval', 'done', 'errored', 'shell', 'idle') # agent_state.STATUSES
42
+ LANE_ID = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,127}')
43
+ SHELLS = {'sh', 'dash', 'bash', 'zsh', 'ksh', 'fish'}
44
+ STDIN_WAIT = 0.5
45
+ STDIN_MAX = 1 << 20
46
+
47
+
48
+ def options(argv):
49
+ status, event, harness, payload = (argv[0] if argv else ''), '', '', ''
50
+ rest = argv[1:]
51
+ while rest:
52
+ word = rest.pop(0)
53
+ if word == '--event' and rest:
54
+ event = rest.pop(0)
55
+ elif word == '--harness' and rest:
56
+ harness = rest.pop(0)
57
+ elif word.startswith('{'):
58
+ payload = word
59
+ return status, event[:64], harness[:16], payload
60
+
61
+
62
+ def lane_id(root):
63
+ found = os.environ.get('LANE_ID', '')
64
+ if not found and os.environ.get('TMUX') and os.environ.get('TMUX_PANE'):
65
+ sock = os.environ['TMUX'].split(',', 1)[0]
66
+ try:
67
+ done = subprocess.run(['tmux', '-S', sock, 'display', '-p', '-t', os.environ['TMUX_PANE'], '#S'],
68
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=1)
69
+ session = done.stdout.strip() if done.returncode == 0 else ''
70
+ lanes = json.load(open(os.path.join(root, 'registry.json'))).get('lanes') or {}
71
+ except (OSError, ValueError, subprocess.SubprocessError, AttributeError):
72
+ return ''
73
+ name = os.path.basename(sock)
74
+ for key, lane in lanes.items():
75
+ tmux = (lane or {}).get('tmux') or {}
76
+ if session and tmux.get('socket') == name and tmux.get('session') == session:
77
+ found = key
78
+ break
79
+ return found if LANE_ID.fullmatch(found or '') else ''
80
+
81
+
82
+ def stat_fields(pid):
83
+ """Fields after `comm` of /proc/<pid>/stat (state is [0], ppid [1], starttime [19]) and the comm itself."""
84
+ with open(f'/proc/{pid}/stat') as handle:
85
+ text = handle.read()
86
+ comm = text[text.index('(') + 1:text.rindex(')')]
87
+ return comm, text.rsplit(')', 1)[1].split()
88
+
89
+
90
+ def agent():
91
+ pid = os.getppid()
92
+ try:
93
+ comm, fields = stat_fields(pid)
94
+ if comm in SHELLS and int(fields[1]) > 1:
95
+ pid = int(fields[1])
96
+ comm, fields = stat_fields(pid)
97
+ return pid, fields[19]
98
+ except (OSError, ValueError, IndexError):
99
+ return pid, None
100
+
101
+
102
+ def stdin_session():
103
+ try:
104
+ if os.isatty(0):
105
+ return ''
106
+ except OSError:
107
+ return ''
108
+ data, deadline = b'', time.monotonic() + STDIN_WAIT
109
+ try:
110
+ while len(data) < STDIN_MAX:
111
+ left = deadline - time.monotonic()
112
+ if left <= 0 or not select.select([0], [], [], left)[0]:
113
+ break
114
+ chunk = os.read(0, 65536)
115
+ if not chunk:
116
+ break
117
+ data += chunk
118
+ doc = json.loads(data.decode('utf-8', 'replace')) if data.strip() else {}
119
+ except (OSError, ValueError):
120
+ return ''
121
+ return str(doc.get('session_id') or '') if isinstance(doc, dict) else ''
122
+
123
+
124
+ def main(argv):
125
+ status, event, harness, payload = options(argv)
126
+ if status not in STATUSES:
127
+ return
128
+ root = os.environ.get('LANE_ROOT') or os.path.join(os.path.expanduser('~'), '.lz')
129
+ lane = lane_id(root)
130
+ if not lane:
131
+ return
132
+ session = stdin_session()
133
+ if not session and payload:
134
+ try:
135
+ doc = json.loads(payload)
136
+ session = str(doc.get('thread-id') or doc.get('session_id') or '') if isinstance(doc, dict) else ''
137
+ except ValueError:
138
+ session = ''
139
+ session = session or os.environ.get('CLAUDE_SESSION_ID') or os.environ.get('CODEX_THREAD_ID') or ''
140
+ harness = harness or os.environ.get('LANE_HARNESS') or ('claude' if os.environ.get('CLAUDE_CODE_ENTRYPOINT')
141
+ else 'codex')
142
+ pid, start = agent()
143
+ now = int(time.time())
144
+ state = os.path.join(root, 'state', 'hooks') # never state/<id>.json: lane-run's phase record (DEF-1436)
145
+ path = os.path.join(state, f'{lane}.json')
146
+ try:
147
+ old = json.load(open(path))
148
+ except (OSError, ValueError):
149
+ old = None
150
+ session, since = session[:128], now
151
+ if (isinstance(old, dict) and old.get('v') == 1 and old.get('status') == status
152
+ and (not session or old.get('session') == session) and isinstance(old.get('since'), int)):
153
+ since, session = old['since'], session or str(old.get('session') or '')
154
+ doc = {'v': 1, 'lane': lane, 'harness': harness, 'status': status, 'since': since, 'at': now, 'event': event,
155
+ 'pid': pid, 'start': start, 'session': session}
156
+ os.makedirs(state, exist_ok=True)
157
+ tmp = os.path.join(state, f'.{lane}.json.{os.getpid()}.tmp')
158
+ try:
159
+ with open(tmp, 'w') as handle:
160
+ handle.write(json.dumps(doc, separators=(',', ':')) + '\n')
161
+ os.replace(tmp, path)
162
+ finally:
163
+ if os.path.exists(tmp):
164
+ os.unlink(tmp)
165
+
166
+
167
+ if __name__ == '__main__':
168
+ try:
169
+ sys.stdout = sys.stderr = open(os.devnull, 'w')
170
+ main(sys.argv[1:])
171
+ except BaseException: # noqa: BLE001 - a hook must never fail the agent: every outcome is exit 0
172
+ pass
173
+ os._exit(0)
@@ -10,7 +10,10 @@ terminal. Installing or closing a view does not end a lane.
10
10
  2. Each manifest identifies container names and repository `.vscode/terminals.json` paths.
11
11
  3. `lanes sync` reads those files through the container's mounted home, includes the site's optional
12
12
  `registered-sessions.json`, and emits the host registry,
13
- exact socket/session resolve files, and the container's `.lz/registry.json` projection.
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`.
14
17
  4. The status daemon observes tmux sessions and publishes status/health snapshots. Failed or timed-out
15
18
  probes mean unknown; they do not prove a missing session. A bad terminal registry retains the last good board.
16
19
 
@@ -19,7 +22,7 @@ terminal. Installing or closing a view does not end a lane.
19
22
  `working | needs_input | needs_approval | done | errored | shell | idle`. An absent key means no signal names
20
23
  an agent there. `statusd` builds it with `agent_state.merge` from signals the harnesses already emit, read once
21
24
  per container per cycle through `/proc/<container init pid>/root` without spawning anything. Precedence: a
22
- hook sidecar (planned, not read yet) > a Claude `~/.claude/sessions/<pid>.json` naming the pane
25
+ hook sidecar > a Claude `~/.claude/sessions/<pid>.json` naming the pane
23
26
  `<session>:@<win>.%<pane>` (live pids only, checked in the container's pid namespace, recycled pids rejected
24
27
  by `procStart`) > the Codex pane title (`[ ! ] Action Required` → `needs_approval`, braille spinner →
25
28
  `working`, a task title → `idle`) > the window bell (an idle title plus a bell is `done`; an approval title
@@ -28,11 +31,29 @@ terminal. Installing or closing a view does not end a lane.
28
31
  title. Unknown liveness leaves that key unset and never demotes. The tmux columns this needs
29
32
  (`window_bell_flag`, `window_id`, `pane_id`, `pane_title`) are appended to the listing format, the free-text
30
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").
31
52
  5. A lane's `source` is the container's spelling of its `terminals.json`. Every host-side reader of that file
32
53
  maps it to the container's home volume first (`statusd.registry_source_paths`, deduplicated by that host path:
33
54
  one repository cloned in two containers is two files): the status daemon's hot-sync
34
55
  starts one `lanes sync` per edit (at most one per 10 s), `lanes doctor` reports "registry up to date" from the
35
- same host-side mtimes, and promoting an unregistered session writes the binding through the volume.
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.
36
57
 
37
58
  `lanes cursor-sync` is a separate, explicit writer. Its default is a diff; `--write` takes backups and changes
38
59
  editor commands to `lane open <id>`. The original create recipe and exact tmux binding remain in the registry's
@@ -50,12 +71,14 @@ to the server's GLOBAL environment (`show-environment -g`), and every later sess
50
71
  unless its own `-e` overrides each key — a session created by a client carrying `LANE_ID=b` but no `-e` still got
51
72
  the server starter's `LANE_ID=a`, and one created with `-e LANE_ID=c` alone kept `a`'s `THROTTLE_TRACE`. Board
52
73
  sockets are shared (9 of the 10 bw-tools fixture lanes are on one socket), so `lane open` execs nothing — attach or
53
- recipe — that carries a lane identity, this lane's or one inherited from another lane's editor terminal (DEF-1422);
74
+ recipe — that carries a lane identity, this lane's or one inherited from another lane's editor terminal (DEF-1478);
54
75
  a recipe run by it may start the server with an identity-free global environment only. An attach cannot change a
55
76
  running session's env, so a running lane gains its identity only when it is next CREATED by a recipe carrying `-e`.
56
77
  A legacy recipe without `-e` gives its lane no identity. `lanes cursor-sync --canonicalize` (dry-run unless
57
78
  `--write`) gives every tmux lane whose recorded `create` cannot create it — null, attach-only (`attach-session`
58
- without `new`), or naming an absolute `.sh`/`.py`/… script missing in the container — the canonical recipe with
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
59
82
  `bash -l`; `--identity` gives a WORKING recipe of the plain shape `[exec] tmux -L <socket> new -ADs <session> [-c
60
83
  <dir>] <command…>` the same `-e` flags, re-quoting the command's words one for one so tmux receives the same argv
61
84
  (any expansion, glob, operator or other shape is left alone and stays identity-less). Both keep the old value as
@@ -69,7 +92,7 @@ stamped `lanes/<ws>/<id>` therefore puts its main session, every subagent and an
69
92
  into ONE fairness bucket where today each Claude session/agent is its own. `max_in_flight_per_trace` is configured
70
93
  and reported but no enforcement site exists in `src/` (MEASURED by search; `pressure.rs` documents fairness as "not a
71
94
  hard cap"). Whether a lane should be one bucket is an open decision for the plan owner before any live enablement
72
- (DEF-1423); this tree does not change the header shape. Unit-proven only (`test_lib_probe` `LaneOpenIdentity`,
95
+ (DEF-1479); this tree does not change the header shape. Unit-proven only (`test_lib_probe` `LaneOpenIdentity`,
73
96
  `CursorSyncCanonicalize`, `CursorSyncIdentity`, stopping at the exec boundary); the disposable-lane
74
97
  `tests/cursor_lane.py` exercises the installed `/home/coder/.lz/bin/lane`, so it proves this only after `lanes
75
98
  install-container`.
@@ -80,6 +103,22 @@ install-container`.
80
103
  a lane sidebar, and one to three viewports. The mobile layout uses separate picker and full-width view tabs.
81
104
  Its hidden viewports do not attach at placeholder dimensions; they wait until a real client displays them.
82
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
+
83
122
  The tmux holder reaches the container's socket through `/proc/<init-pid>/root`, scrubs the viewer environment,
84
123
  and uses `tmux -N` so an attach cannot create a server accidentally. Guarded geometry and client identification
85
124
  keep the visible view and editor attachments coordinated. Zmx is a separately pinned alternative holder;
@@ -114,15 +153,41 @@ Explicit **New +** starts a detached tmux shell inside the workspace container o
114
153
  rechecking its session ID and creation time and refusing registered lanes; affected viewers become empty.
115
154
  The tmux close condition is checked inside the server, protecting a replacement created between observation
116
155
  and the action. `user-sessions/` stores display metadata; `status.json` and `resolve.dynamic/` are projections.
117
- Legacy Docker shells imported into `legacy-sessions/` are observed by PID/start identity. Closing rechecks the
118
- owner, preserves that root identity through the process-family scan, and requires a verified root pidfd before
119
- signaling the original process family. Failed verification keeps the recovery record. These older connections cannot be recreated; their
120
- 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).
121
160
 
122
161
  The `lane` command lives in `/home/coder/.lz/bin`. `open` attaches or creates using its saved recipe;
123
162
  `attach` never creates; `send`, `capture`, `ls` and `status` are explicit operations. Only `kill` ends a lane.
124
163
  These commands operate inside the container, independently of the Zellij viewer's lifetime.
125
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
+
126
191
  ## Desktop and uploads
127
192
 
128
193
  The client installer creates an SSH alias and dedicated terminal configuration, using the selected profile's
@@ -158,9 +223,11 @@ workspace and lane pickers. The bar reads the `MobileState` that Zellij sends to
158
223
  phone picker's `FocusPane`. It overlays the terminal, so the terminal's columns and rows do not change, and a
159
224
  click on the chip leaves the keyboard focus in the terminal. The tablet and desktop code and styles live in
160
225
  `web/browser.js` and `web/browser.css`, which `mobile.js` appends only in those two modes, so a phone never
161
- downloads them and the phone's `mobile.js`/`mobile.css` budget (spec §8) stays with the phone. `lanes web <device> [--desktop|--mobile]`
162
- prepares a browser view, enables the services through `install/enable-web.sh` and prints the private URL; without
163
- a flag a known device keeps its layout.
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.
164
231
 
165
232
  ## Installed files and services
166
233
 
@@ -1,9 +1,56 @@
1
1
  # Lanes design system
2
2
 
3
- The operator-facing legend for the Lanes cockpit. The tables below are generated from `libexec/tokens.py`
4
- (`python3 libexec/tokens.py --write`; `--check` fails on drift); the design of record is
5
- `docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md` at the repository root. Prose, modes, row anatomy and
6
- mockups land with the docs task of the cockpit v2 plan.
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.
7
54
 
8
55
  <!-- BEGIN lanes tokens (generated by libexec/tokens.py --md; do not edit by hand) -->
9
56
  | Role | Dark | Light | ANSI-16 | CSS variable |
@@ -70,3 +117,134 @@ mockups land with the docs task of the cockpit v2 plan.
70
117
  | `↓` | U+2193 | `v` | tone-attention | economy `downgraded` |
71
118
  | `$` | U+0024 | `$` | ink-dim | economy `paid` |
72
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).