@vibelord/lanes 1.0.2 → 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 (33) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/VERSION +1 -1
  3. package/payload/lanes/bin/lanes +21 -8
  4. package/payload/lanes/container/env.sh +1 -1
  5. package/payload/lanes/container/lane +5 -5
  6. package/payload/lanes/container/lane-signal +4 -4
  7. package/payload/lanes/docs/ARCHITECTURE.md +14 -7
  8. package/payload/lanes/docs/DESIGN.md +3 -3
  9. package/payload/lanes/docs/INSTALL.md +37 -2
  10. package/payload/lanes/docs/RUNBOOK.md +52 -23
  11. package/payload/lanes/docs/SHARING.md +239 -4
  12. package/payload/lanes/holders.lock +10 -0
  13. package/payload/lanes/install/enable-studio-host.sh +14 -0
  14. package/payload/lanes/install/host-install.sh +5 -2
  15. package/payload/lanes/install/services.py +101 -4
  16. package/payload/lanes/install.sh +1 -1
  17. package/payload/lanes/libexec/agent_state.py +1 -1
  18. package/payload/lanes/libexec/cursor_sync.py +2 -2
  19. package/payload/lanes/libexec/economy_poll.py +3 -3
  20. package/payload/lanes/libexec/lanes_lib.py +1 -1
  21. package/payload/lanes/libexec/notify.py +3 -3
  22. package/payload/lanes/libexec/registrations.py +1 -1
  23. package/payload/lanes/libexec/session_actions.py +1 -1
  24. package/payload/lanes/libexec/sidebar.py +7 -7
  25. package/payload/lanes/libexec/statusd.py +25 -10
  26. package/payload/lanes/libexec/studio_host.py +1608 -0
  27. package/payload/lanes/systemd/lanes-economy.service +1 -1
  28. package/payload/lanes/systemd/lanes-studio-host-enrol.service +11 -0
  29. package/payload/lanes/systemd/lanes-studio-host-enrol.timer +10 -0
  30. package/payload/lanes/systemd/lanes-studio-host.service +37 -0
  31. package/payload/lanes/web/README.md +2 -2
  32. package/payload/lanes/zellij/config.kdl +1 -1
  33. package/payload/manifest.json +4 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibelord/lanes",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Installer for Lanes: shared agent terminals over SSH with Zellij views, registry sync, uploads and a phone interface.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1 +1 @@
1
- 1.0.2
1
+ 1.0.3
@@ -36,7 +36,7 @@
36
36
  attach-only, missing script) with the canonical one whose `tmux new -e` flags give the
37
37
  lane LANE_ID/LANE_WS and the lanes/<ws>/<id> trace; --identity adds those flags to a
38
38
  working plain `tmux -L S new -ADs N [-c D] CMD` recipe, keeping CMD. The old recipe is
39
- kept as createPrevious. Only these -e flags give a lane its identity (DEF-1478)
39
+ kept as createPrevious. Only these -e flags give a lane its identity (DEF-1559)
40
40
  lanes cursor-commit [ws…] [--push]
41
41
  commit the normalized terminals.json on top of each repo's REMOTE branch tip and push it,
42
42
  with git plumbing only (no working tree or index is touched). Dry-run without --push.
@@ -79,6 +79,15 @@
79
79
  its last 300 lines (guarded tmux capture, resolved against its cwd, existing ones only),
80
80
  newest first; one is shown at once, several give a numbered menu. glow (pinned in
81
81
  holders.lock) in the device's dark/light theme, else less -R
82
+ lanes studio-host status [--json] [--check]
83
+ the co-located Throttle Studio Host (opt-in: install/enable-studio-host.sh): binary
84
+ digest, config mode, enrolment, units. Exit 0 only when it runs enrolled
85
+ lanes studio-host install [--from PATH [--sha256 HEX]]
86
+ select the Host binary: the holders.lock opt-in pin, or a locally built file (both
87
+ sha256-verified); restarts nothing
88
+ lanes studio-host re-key [--yes] [--dry-run]
89
+ after the owner revoked this machine: stop the Host, remove the machine key and its
90
+ dead enrolment, publish a NEW public key for the next deploy. Nothing without --yes
82
91
  lanes doctor full health check (every external call is bounded; includes the pressure summary), with
83
92
  the lanes per create-recipe class (idempotent / attach-only / null / external /
84
93
  retired-path), lane identity, numeric session names, crowded `default` sockets and
@@ -257,7 +266,7 @@ def mobile_workspace_tab(device, workspace, picker):
257
266
  command = str(L.REPO / 'libexec' / ('sidebar' if picker else 'view.sh'))
258
267
  args = f'{q(device)} {q(workspace["id"])}' + ('' if picker else ' "main" "--mobile"')
259
268
  # The picker's sidebar pane has NO layout name: Zellij reports a non-empty pane name instead of the pane's own
260
- # OSC 2 title, and that title (the attention counts) is what the phone reads (DEF-1450).
269
+ # OSC 2 title, and that title (the attention counts) is what the phone reads (DEF-1531).
261
270
  name = '' if picker else f' name={q(workspace["title"])}'
262
271
  return [f' tab name={q(title)} {{',
263
272
  f' pane borderless=true{name} command={q(command)} {{',
@@ -268,7 +277,7 @@ def stale_picker_layouts():
268
277
  """Phone devices whose stored layout still names the picker's sidebar pane (the pre-Task 26 `name="Lanes"`).
269
278
 
270
279
  `upgrade-session` / `ensure` only add missing tabs and rename titles: a picker tab created from an older layout
271
- keeps its pane name, which Zellij reports instead of the OSC 2 counts, until `lanes rebuild <device>` (DEF-1450).
280
+ keeps its pane name, which Zellij reports instead of the OSC 2 counts, until `lanes rebuild <device>` (DEF-1531).
272
281
  `create_session` is the only writer of layouts/<device>.kdl, so the file is the layout the session was built from.
273
282
  """
274
283
  sidebar = str(L.REPO / 'libexec' / 'sidebar')
@@ -1214,7 +1223,7 @@ def cmd_gc(_args):
1214
1223
  # shells) and `zmx` into `lanes` (statusd.observe/build_payload; pinned by test_statusd
1215
1224
  # Cycle.test_every_published_row_id_is_also_a_lanes_key), so `lanes` alone covers them today. The union is
1216
1225
  # defensive: the sweep does not rest on that invariant if the payload is ever decoupled. The mtime is no
1217
- # liveness signal — make_entry rewrites an unregistered session's file only when it changes (DEF-1456).
1226
+ # liveness signal — make_entry rewrites an unregistered session's file only when it changes (DEF-1537).
1218
1227
  known = set(status.get('lanes') or {})
1219
1228
  for key in ('unregistered', 'zmx'):
1220
1229
  for entries in (status.get(key) or {}).values():
@@ -1234,6 +1243,10 @@ def cmd_guard(args):
1234
1243
  return libexec_module('guard').run(args)
1235
1244
 
1236
1245
 
1246
+ def cmd_studio_host(args):
1247
+ return libexec_module('studio_host').main(args) # verbs, refusals and exit codes are studio_host's
1248
+
1249
+
1237
1250
  def libexec_module(name):
1238
1251
  try:
1239
1252
  return __import__(name)
@@ -1295,7 +1308,7 @@ DEFAULT_SOCKET_LANES = 4
1295
1308
 
1296
1309
  def recipe_report(registry):
1297
1310
  """(info lines, warnings) of the registry's tmux lanes for `lanes doctor` (plan Task 18): lanes per create-recipe
1298
- class (lanes_lib.classify_recipe), how many create with their identity (-e LANE_ID, DEF-1478), lanes bound to a
1311
+ class (lanes_lib.classify_recipe), how many create with their identity (-e LANE_ID, DEF-1559), lanes bound to a
1299
1312
  numeric session name, and crowded `default` sockets. Reads registry.json only: no tmux, no container."""
1300
1313
  counts, identity, total = dict.fromkeys(L.RECIPE_CLASSES, 0), 0, 0
1301
1314
  broken, numeric, on_default = [], [], {}
@@ -1499,7 +1512,7 @@ def cmd_doctor(_args):
1499
1512
  except (OSError, L.LanesError) as error:
1500
1513
  check(False, 'zellij reachable', str(error))
1501
1514
  stale_pickers = stale_picker_layouts()
1502
- check(not stale_pickers, 'phone picker panes report their own title (DEF-1450)',
1515
+ check(not stale_pickers, 'phone picker panes report their own title (DEF-1531)',
1503
1516
  'built from a layout that names the picker pane, so the phone shows no counts; upgrade-session does not '
1504
1517
  're-lay-out a tab — run ' + '; '.join(f'lanes rebuild {d}' for d in stale_pickers))
1505
1518
  print('PRESSURE')
@@ -1545,7 +1558,7 @@ def cmd_rename(args):
1545
1558
  if not lane:
1546
1559
  raise L.LanesError(f'Unknown lane: {lane_id}')
1547
1560
  # An adopted session (Add to workspace / `lanes register`) takes its name from registered-sessions.json,
1548
- # which is the one file renamed here; its id and binding never change (DEF-1475).
1561
+ # which is the one file renamed here; its id and binding never change (DEF-1556).
1549
1562
  if lane.get('registered_session'):
1550
1563
  import registrations
1551
1564
  registrations.rename(lane_id, new_name)
@@ -1800,7 +1813,7 @@ COMMANDS = {'rebuilt': cmd_rebuilt, 'hello': cmd_hello, 'client-config': cmd_cli
1800
1813
  'prepare': cmd_prepare, 'web': cmd_web, 'attach': cmd_attach, 'rebuild': cmd_rebuild, 'ensure': cmd_ensure, 'invariant': cmd_invariant,
1801
1814
  'doctor': cmd_doctor, 'pressure': cmd_pressure, 'sockets': cmd_sockets, 'tmp': cmd_tmp,
1802
1815
  'guard': cmd_guard, 'rename': cmd_rename, 'register': cmd_register, 'hooks': cmd_hooks, 'economy': cmd_economy,
1803
- 'preview': cmd_preview, 'device': cmd_device, 'next': cmd_next, 'prev': cmd_prev}
1816
+ 'preview': cmd_preview, 'device': cmd_device, 'next': cmd_next, 'prev': cmd_prev, 'studio-host': cmd_studio_host}
1804
1817
 
1805
1818
  if __name__ == '__main__':
1806
1819
  if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
@@ -14,7 +14,7 @@
14
14
  # (<ws>--<slug>), LANE_WS (<ws>), THROTTLE_TRACE and ANTHROPIC_CUSTOM_HEADERS ('x-throttle-trace: lanes/<ws>/<id>';
15
15
  # trace charset [A-Za-z0-9._-/], at most 256). Never export them from a profile or a client: the client that starts
16
16
  # a shared board socket's tmux server copies its env into the server's GLOBAL env, which every later session there
17
- # without its own -e inherits (DEF-1478) — so `lane open` strips them. A running lane gains its identity only when it
17
+ # without its own -e inherits (DEF-1559) — so `lane open` strips them. A running lane gains its identity only when it
18
18
  # is next created by a recipe carrying -e; a legacy recipe without -e gives none (`lanes cursor-sync --identity`).
19
19
  if [ -z "${LANES_TMP_OPTOUT+set}" ] && [ -d "${LANES_TMP_DIR:-/home/coder/.tmp}" ] \
20
20
  && [ -w "${LANES_TMP_DIR:-/home/coder/.tmp}" ] && [ -x "${LANES_TMP_DIR:-/home/coder/.tmp}" ]; then
@@ -38,7 +38,7 @@ ROOT = Path(os.environ.get('LANE_ROOT', '/home/coder/.lz'))
38
38
  VERSION = os.environ.get('LANE_ZMX_VERSION', '0.8.1')
39
39
  ZMX = ROOT / 'bin' / f'zmx-{VERSION}'
40
40
  SOCKETS, META, STATE = ROOT / 's', ROOT / 'meta', ROOT / 'state'
41
- HOOKS = STATE / 'hooks' # lane-signal's sidecars (never lane-run's phase records one directory up, DEF-1436)
41
+ HOOKS = STATE / 'hooks' # lane-signal's sidecars (never lane-run's phase records one directory up, DEF-1572)
42
42
  PROC = Path('/proc') # this container's pid namespace: the hook's agent pid is checked here
43
43
  IDENT = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$')
44
44
  STATUSES = ('working', 'needs_input', 'needs_approval', 'done', 'errored', 'shell', 'idle') # agent_state.STATUSES
@@ -167,7 +167,7 @@ IDENTITY = ('LANE_ID', 'LANE_WS', 'THROTTLE_TRACE') # + a lanes/ ANTHROPIC_CUS
167
167
  def identity_free(environ, drop=('TMUX',)):
168
168
  """`environ` without any lane identity (lanes/docs/ARCHITECTURE.md "Lane identity"). Whatever tmux client STARTS
169
169
  a socket's server hands its whole environment to the server's GLOBAL environment, and every later session on
170
- that socket created without its own `-e` inherits it (MEASURED, tmux 3.4: DEF-1478). Board sockets are shared by
170
+ that socket created without its own `-e` inherits it (MEASURED, tmux 3.4: DEF-1559). Board sockets are shared by
171
171
  many lanes, so a lane identity in the env of anything that may start one would be stamped on unrelated lanes.
172
172
  Identity therefore reaches a lane's shell ONLY through `-e` in its create recipe (lanes_lib.canonical_recipe).
173
173
  An operator's own ANTHROPIC_CUSTOM_HEADERS that is not a lane trace is theirs and stays."""
@@ -292,7 +292,7 @@ def cmd_open(args):
292
292
  sys.exit(f"lane open: already inside tmux — refusing to nest. The lane is: {json.dumps(entry.get('tmux') or entry.get('zmx'))}")
293
293
  # Nothing `lane open` execs carries a lane identity — neither this lane's nor one inherited from ANOTHER lane (an
294
294
  # editor terminal started inside a lane): the recipe may be the first client on a shared board socket and would
295
- # hand it to every later session there (DEF-1478). The canonical recipe's own `-e` flags give the identity.
295
+ # hand it to every later session there (DEF-1559). The canonical recipe's own `-e` flags give the identity.
296
296
  env = identity_free(os.environ, drop=('TMUX', 'TMUX_PANE'))
297
297
  if entry.get('holder') == 'zmx':
298
298
  name = entry['zmx']['name']
@@ -453,7 +453,7 @@ def cmd_status(args):
453
453
  zname = zmx_name('status', name)
454
454
  row = next((r for r in describe() if r['id'] == zname), None)
455
455
  if row:
456
- row['hook'] = hook_agent(zname) # lane-run's phase record stays `agent` (DEF-1436)
456
+ row['hook'] = hook_agent(zname) # lane-run's phase record stays `agent` (DEF-1572)
457
457
  print(json.dumps(row or {'id': name, 'running': False}, ensure_ascii=False, indent=1))
458
458
  sys.exit(0 if row else 1)
459
459
 
@@ -519,7 +519,7 @@ def cmd_gc(args):
519
519
  Refusal is read from the errno, never from tmux's prose: tmux 3.4 words ECONNREFUSED as `no server running on
520
520
  <path>` and uses `error connecting to <path> (<strerror>)` only for OTHER errnos (measured 2026-10-04: EACCES →
521
521
  `(Permission denied)`, ENOENT → `(No such file or directory)`), so a 'connection refused' text match never fired
522
- (DEF-1459). When tmux fails, `socket_refused` connects to the same path once and only ConnectionRefusedError
522
+ (DEF-1540). When tmux fails, `socket_refused` connects to the same path once and only ConnectionRefusedError
523
523
  counts."""
524
524
  if args != ['--sockets']:
525
525
  sys.exit(GC_USAGE)
@@ -5,7 +5,7 @@ lanes-statusd reads through /proc/<container init pid>/root and publishes as the
5
5
 
6
6
  The sidecar has its own directory because `lane-run`, the root process of every zmx-held lane, writes its
7
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
8
+ sidecar at state/<lane id>.json would overwrite that record for the agent's whole life (DEF-1572). Nothing but
9
9
  this script writes state/hooks/, and lane-run never writes there.
10
10
 
11
11
  {"v":1, "lane":"<id>", "harness":"claude|codex", "status":"<agent{} vocabulary>", "since":<epoch the status
@@ -16,10 +16,10 @@ It never fails the agent and never speaks: every path exits 0 and nothing reache
16
16
  UserPromptSubmit/SessionStart hook's stdout becomes model context). An unresolvable lane, a status outside the
17
17
  vocabulary or an unwritable state directory are silent no-ops.
18
18
 
19
- Lane id: $LANE_ID — exported in a tmux lane only by a canonical/`--identity` recipe's `-e` flags (Task 7, DEF-1478;
19
+ Lane id: $LANE_ID — exported in a tmux lane only by a canonical/`--identity` recipe's `-e` flags (Task 7, DEF-1559;
20
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
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
22
+ DEF-1573) — else the pane's tmux session (`tmux -S ${TMUX%%,*} display -p -t $TMUX_PANE '#S'`) looked up by (socket
23
23
  name, session) in ~/.lz/registry.json, the projection `lanes sync` writes.
24
24
 
25
25
  Agent pid: the parent process, or that parent's parent when the parent is a shell (Claude Code runs a hook command
@@ -141,7 +141,7 @@ def main(argv):
141
141
  else 'codex')
142
142
  pid, start = agent()
143
143
  now = int(time.time())
144
- state = os.path.join(root, 'state', 'hooks') # never state/<id>.json: lane-run's phase record (DEF-1436)
144
+ state = os.path.join(root, 'state', 'hooks') # never state/<id>.json: lane-run's phase record (DEF-1572)
145
145
  path = os.path.join(state, f'{lane}.json')
146
146
  try:
147
147
  old = json.load(open(path))
@@ -44,9 +44,9 @@ terminal. Installing or closing a view does not end a lane.
44
44
  whose agent pid is dead or recycled in the container's pid namespace. The sidecar has its own directory because
45
45
  `lane-run` writes each zmx lane's `{phase, exit, at, pid}` record to `~/.lz/state/<zmx name>.json` and exports
46
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
47
+ container `lane ls` read (DEF-1572). A zmx lane's sidecar is therefore named by its zmx session, not by the
48
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
49
+ stays lane-run's record (DEF-1573). A published sidecar adds `event` to `agent{}` and has `source: hook`. Hooks are
50
50
  never installed implicitly: `lanes hooks install --agent claude|codex --container <name>` prints the merge and
51
51
  writes only with `--write` (RUNBOOK "Installing agent hooks").
52
52
  5. A lane's `source` is the container's spelling of its `terminals.json`. Every host-side reader of that file
@@ -71,7 +71,7 @@ to the server's GLOBAL environment (`show-environment -g`), and every later sess
71
71
  unless its own `-e` overrides each key — a session created by a client carrying `LANE_ID=b` but no `-e` still got
72
72
  the server starter's `LANE_ID=a`, and one created with `-e LANE_ID=c` alone kept `a`'s `THROTTLE_TRACE`. Board
73
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);
74
+ recipe — that carries a lane identity, this lane's or one inherited from another lane's editor terminal (DEF-1559);
75
75
  a recipe run by it may start the server with an identity-free global environment only. An attach cannot change a
76
76
  running session's env, so a running lane gains its identity only when it is next CREATED by a recipe carrying `-e`.
77
77
  A legacy recipe without `-e` gives its lane no identity. `lanes cursor-sync --canonicalize` (dry-run unless
@@ -92,7 +92,7 @@ stamped `lanes/<ws>/<id>` therefore puts its main session, every subagent and an
92
92
  into ONE fairness bucket where today each Claude session/agent is its own. `max_in_flight_per_trace` is configured
93
93
  and reported but no enforcement site exists in `src/` (MEASURED by search; `pressure.rs` documents fairness as "not a
94
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`,
95
+ (DEF-1560); this tree does not change the header shape. Unit-proven only (`test_lib_probe` `LaneOpenIdentity`,
96
96
  `CursorSyncCanonicalize`, `CursorSyncIdentity`, stopping at the exec boundary); the disposable-lane
97
97
  `tests/cursor_lane.py` exercises the installed `/home/coder/.lz/bin/lane`, so it proves this only after `lanes
98
98
  install-container`.
@@ -153,6 +153,10 @@ Explicit **New +** starts a detached tmux shell inside the workspace container o
153
153
  rechecking its session ID and creation time and refusing registered lanes; affected viewers become empty.
154
154
  The tmux close condition is checked inside the server, protecting a replacement created between observation
155
155
  and the action. `user-sessions/` stores display metadata; `status.json` and `resolve.dynamic/` are projections.
156
+ `status.json` also carries `zmx_listed_at` — per container, the unix time statusd ISSUED the `zmx list` that last
157
+ answered (a container whose listing timed out or failed is absent; its `zmx` groups are then the previous ones
158
+ republished). It sits outside the change comparison, so the file is still rewritten only on change or the heartbeat;
159
+ the co-located Studio Host prunes a lane it started only on such a listing (docs/SHARING.md, design §8.10 C1).
156
160
  The pre-persistence Docker-shell tracker (`legacy-sessions/`, `legacy_sessions.py`, `lanes verify-legacy`) was
157
161
  retired by plan Task 19: statusd no longer reads that directory (a record still there is inert — kept on disk,
158
162
  never observed, never closed), and a `legacy` row from an older status.json is refused by close. See
@@ -175,15 +179,15 @@ writes that last `;` as `\;`. Any other id is a zmx lane (a registry `holder: zm
175
179
  keeps the zmx path; zmx `send` still writes the bytes as given, never an Enter of its own. `kill` now needs `--yes`
176
180
  on both holders and refuses while the lane's hook sidecar says `working` unless `--force`; the container sees only
177
181
  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 —
182
+ installed hooks the refusal cannot fire (DEF-1575). `ls --json` stays the zmx-only listing Throttle Studio reads —
179
183
  its zmx holder takes EVERY row as a zmx lane, so tmux rows there would publish each tmux lane twice; `ls --all` adds
180
184
  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).
185
+ zmx lane gains `hook` (its sidecar); `agent` stays lane-run's phase record (DEF-1572).
182
186
 
183
187
  The bw-tools agent launchers (`.bw-tools/bin/claude`, `codex`) exec the agent binary (shell `exec` → `agent.py`
184
188
  `os.execvpe`), so a lane started through them shows the agent as the pane's foreground command. The operator's
185
189
  `~/.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
190
+ exit), so a pane started through it keeps `bash` as its foreground command (DEF-1576); on the launcher path the end
187
191
  of a session is the `SessionEnd` hook's `lane-signal shell`, when hooks are installed. `.bw-tools/bin/claude` runs
188
192
  `$EVAL_CLAUDE_REAL_BIN` when it is set (the binary eval-claude would run) and skips it when it names a `.bw-tools`
189
193
  shim, so `EVAL_CLAUDE_REAL_BIN=<checkout>/.bw-tools/bin/claude eval-claude` composes the two without a loop.
@@ -236,6 +240,9 @@ It replaces old unit symlinks atomically and creates `~/.local/share/lanes/curre
236
240
  No service requires the checkout to be named `~/lanes`. Re-run the server installer after moving it.
237
241
 
238
242
  Core units are the status observer and sync, ensure and garbage-collection timers. The web units are opt-in.
243
+ The co-located Throttle Studio Host (`lanes-studio-host.service` and its enrolment retry timer) is opt-in too:
244
+ `install/enable-studio-host.sh` enables it, and its binary is selected by `lanes studio-host install` from an
245
+ `opt-in` line of `holders.lock` or a local build, sha256-verified either way (`libexec/studio_host.py`).
239
246
  Temporary-file cleanup and process protection have explicit plan/apply or enable steps; installation does not
240
247
  arm them. The root host guard is also separate and records rollback values. See [the runbook](RUNBOOK.md)
241
248
  and [host protection](HOST-GUARD.md) for those operations.
@@ -128,7 +128,7 @@ frame is seven-bit. The `ANSI-16` column is what `LANES_COLOR=16` emits; truecol
128
128
  | 2 | `AGENT_GLYPHS[agent.status]`; blank without `agent{}`; ink-dim when stale | its tone |
129
129
  | 3 | › viewing (not on the cursor row) · ² / ³ another viewport shows it · else space | accent |
130
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 |
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-1509) | tone-done |
132
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
133
  | 32 | space | |
134
134
  | 33 | `GLYPHS[state]` — full tone without an agent, ink-dim with one (▸ + ○ is the long-tool-call case and stays readable) | its tone |
@@ -231,14 +231,14 @@ Each budget is pinned by a test; a budget that is not met is an open defect row,
231
231
 
232
232
  | Budget | Pin |
233
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()` |
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-1510 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
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
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
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
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
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
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 |
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-1564 open; the budget is not raised | bench in Task 24's and Task 12's commit messages; DEF-1564 |
242
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
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
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 |
@@ -194,7 +194,37 @@ There is no public fallback by design: views are reachable only through the tail
194
194
  Tailscale name, `lanes web` prints the loopback URL `http://127.0.0.1:8083/<device>` and says that it opens only
195
195
  on the host itself.
196
196
 
197
- ## 5. Updates, optional operations and removal
197
+ ## 5. Optional Throttle Studio Host on the server
198
+
199
+ One Throttle Studio Host can serve every board of this server from the workspace account, supervised as the
200
+ opt-in `lanes-studio-host.service` (Slice `lanes.slice`, after `lanes-statusd`). `./install.sh --server` renders its
201
+ unit but never enables it or downloads its binary. Install a sha256-verified `throttle-rc`, then enable it:
202
+
203
+ ```sh
204
+ lanes studio-host install --from ./throttle-rc --sha256 <digest> # or, once holders.lock pins a release: lanes studio-host install
205
+ sh install/enable-studio-host.sh --server https://throttle.example.com --dry-run
206
+ sh install/enable-studio-host.sh --server https://throttle.example.com
207
+ lanes studio-host status
208
+ ```
209
+
210
+ Enrolment needs no browser on the server: the platform deploy adopts the machine's published public key and writes a
211
+ receipt that `lanes-studio-host-enrol.timer` installs, or the owner places a registration key file. Until then the
212
+ unit waits (exit 3, never a restart loop). The deploy-provisioned path needs the deploy to run on this machine as
213
+ this account (one filesystem, its own env file via `THROTTLE_RC_ENV_FILE`); otherwise use the registration key.
214
+ [Sharing](SHARING.md#co-located-studio-host-one-host-per-lanes-server) has the details, the deploy variables and the
215
+ migration from a hand-built `lanes-sharing-host.service`.
216
+
217
+ The installers write the lanes units where the user manager reads them: `~/.config/systemd/user`, or
218
+ `$XDG_CONFIG_HOME/systemd/user` when the MANAGER's environment sets `XDG_CONFIG_HOME` (`systemctl --user
219
+ show-environment`). Setting `XDG_CONFIG_HOME` in the manager's environment after installing (pam_env,
220
+ `/etc/environment`, `systemctl --user set-environment`) moves where it looks: re-run `./install.sh --server`. The
221
+ two studio units pin `XDG_CONFIG_HOME`/`XDG_DATA_HOME` themselves (the roots `install/enable-studio-host.sh` ran
222
+ with, recorded in `$LANES_STATE/studio-host/xdg-roots.json`, else `%h/.config` and `%h/.local/share`), so neither the
223
+ manager's own values nor a later `./install.sh --server` from a shell without those roots moves the Host's config or
224
+ credential; the server installer warns when its shell's roots differ from the record. To move them, re-run
225
+ `install/enable-studio-host.sh` from the shell whose roots should hold.
226
+
227
+ ## 6. Updates, optional operations and removal
198
228
 
199
229
  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.
200
230
 
@@ -205,6 +235,8 @@ use the same overrides when reinstalling. `LANES_WORKSPACES` is also carried int
205
235
  when supplied; the standard configuration directory is the simplest default.
206
236
 
207
237
  After updating server code, restart the status observer with `systemctl --user restart lanes-statusd.service`.
238
+ If you enabled the Studio Host and selected a new `throttle-rc` or edited its config, restart
239
+ `lanes-studio-host` (re-running `install/enable-studio-host.sh` does that, after `studio host --check`).
208
240
  If web service definitions or the pinned Zellij binary changed, restart `lanes-web` and `lanes-mobile` too;
209
241
  browser clients reconnect. Existing Zellij view sessions use their original layout until explicitly rebuilt.
210
242
  `lanes rebuild DEVICE` replaces only that device's view, never the underlying agent sessions.
@@ -216,6 +248,8 @@ Normal paths:
216
248
  | `~/.config/lanes/` | Site profile, client settings and server workspace configuration |
217
249
  | `~/.local/share/lanes/` | Client copy, pinned binaries, download cache, installed checkout link and npm-staged trees (`pkg/<version>/`) |
218
250
  | `~/.local/state/lanes/` | Host registry, device selections, status, upload handoff and housekeeping projections |
251
+ | `~/.local/state/lanes/studio-host/` | Optional Studio Host: published public key, deploy receipt, enrolment record, migrated unit text (`lanes studio-host re-key --yes` clears the key half after a revoke) |
252
+ | `~/.config/throttle-rc/`, `~/.local/share/throttle-rc/` | Optional Studio Host config, machine key and enrolment receipt (throttle-rc's own files) |
219
253
  | `~/.local/state/lanes-client/` | Client helper heartbeat and logs |
220
254
  | `/home/coder/.lz/` in each container | Lane helper, bindings and optional zmx metadata |
221
255
 
@@ -226,6 +260,7 @@ its root guard. These operations are included in the package but are not an inst
226
260
  Clients uninstall with `./install.sh --uninstall`; SSH keys, `~/Lanes Uploads`, and any server workspace
227
261
  configuration and state on the same account are kept.
228
262
  To stop the host services, disable `lanes-statusd.service`, `lanes-sync.timer`, `lanes-ensure.timer`,
229
- `lanes-gc.timer`, and any optional units you enabled. For the web endpoint, also run
263
+ `lanes-gc.timer`, and any optional units you enabled (`lanes-studio-host.service` and
264
+ `lanes-studio-host-enrol.timer` included; revoke the machine in the console if it should not come back). For the web endpoint, also run
230
265
  `tailscale serve --https=8443 off`. Keep the state and container lane data until you have decided which
231
266
  sessions to retain; no broad deletion command is part of uninstall.
@@ -50,7 +50,7 @@ Since plan Task 26 the phone's picker pane has no fixed layout name, so Zellij c
50
50
  session laid out after Task 26: an existing phone device keeps the old picker pane until `lanes rebuild <device>`
51
51
  (`upgrade-session` and `ensure` add missing tabs and rename titles, they never re-lay-out a tab; `lanes doctor` names
52
52
  such a device). Whether it
53
- reaches the phone is still unchecked in a browser (DEF-1450). A lane that
53
+ reaches the phone is still unchecked in a browser (DEF-1531). A lane that
54
54
  STARTS needing you (`◆` `✗` `◇`) raises one desktop notification, `name: task`, through OSC 9 — once per transition,
55
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
56
  (or restarts) while lanes already need you announces each of them once. Both are bytes in the frame the sidebar
@@ -120,13 +120,16 @@ lanes rebuild <device> recreate a view session lanes ensure recrea
120
120
  lanes invariant save|check prove no live pane changed (pid + window size)
121
121
  lanes device list the devices ensure keeps lanes device forget <name> stop recreating one
122
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)
123
126
  ```
124
127
 
125
128
  - **What `lanes doctor` says about the boards' recipes (plan Task 18):** one line counts the tmux lanes per create
126
129
  recipe class — `idempotent` (a `tmux new -A…`: creates, or attaches when the session exists; the canonical recipe),
127
130
  `attach-only` (`attach-session` and no `new`), `null` (no recipe), `external` (a script or anything else, run as
128
131
  written) and `retired-path` (names the retired in-container `~/.config/coder-launch`). One line says how many of
129
- them create with their identity (`-e LANE_ID=…`, DEF-1478). Warnings (`⚠`, never a `✗`, the exit code is
132
+ them create with their identity (`-e LANE_ID=…`, DEF-1559). Warnings (`⚠`, never a `✗`, the exit code is
130
133
  unchanged): lanes that `lane open` cannot recreate once they end (attach-only, null, retired-path — fix with
131
134
  `lanes cursor-sync --canonicalize`), lanes bound to a numeric session name (tmux numbers unnamed sessions itself,
132
135
  so a recreated one gets another number), more than 4 lanes on one container's tmux socket `default` (the server every
@@ -137,7 +140,7 @@ systemctl --user status lanes-statusd lanes-sync.timer lanes-ensure.timer lanes-
137
140
  file is there, nobody listens) unlinks it, printing each path; a server that answers (even with no session), does
138
141
  not answer within 5 s, or refuses for another reason (EACCES, …) keeps its file. The errno decides, never tmux's
139
142
  wording: tmux 3.4 prints `no server running on <path>` for ECONNREFUSED and `error connecting to <path>
140
- (<strerror>)` only for other errnos (DEF-1459).
143
+ (<strerror>)` only for other errnos (DEF-1540).
141
144
  - **A device you no longer use (an old laptop, a phone you replaced):** `lanes device forget <name>` removes
142
145
  `devices/<name>.json`, so `lanes ensure` (`lanes-ensure.timer`) stops recreating its view session and `sync` /
143
146
  `upgrade-session` stop updating it. A view session that is running is left alone and says so; once it ends, nothing
@@ -179,6 +182,30 @@ systemctl --user status lanes-statusd lanes-sync.timer lanes-ensure.timer lanes-
179
182
  - **A new workspace:** add it to `~/.config/lanes/workspaces/catalog.json` + a manifest, `lanes sync`, then
180
183
  `lanes rebuild <device>` (the only time a layout changes).
181
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).
182
209
 
183
210
  ## When the host is overloaded
184
211
 
@@ -402,7 +429,7 @@ The sidebar's own colours and glyphs come from one token table (`libexec/tokens.
402
429
  and the `say` line names it. A plain `./install.sh` re-run without `--palette` removes the include and all palette
403
430
  files: inherit-first is the default. NOT yet looked at on a real Ghostty (2026-10-04: no Mac or Omarchy client was
404
431
  used; the installer was exercised against a fake `ghostty` that reports a version, fake everyday configs and a
405
- model of Ghostty's load order read from its source). DEF-1466 stays open until that is seen: run `--palette` on a
432
+ model of Ghostty's load order read from its source). DEF-1547 stays open until that is seen: run `--palette` on a
406
433
  Mac (Ghostty ≥ 1.1) and on Omarchy, and the window background must be `#101318` (the dark `surface` token).
407
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
408
435
  attached, and no live session was touched). To check: with the Mac in light mode, `lanes attach mac` from the Mac,
@@ -436,17 +463,17 @@ only normalizes HOW a terminal reaches its lane:
436
463
  are additionally projected into the file Cursor reads (`"cursor"` in `workspaces/*.json`).
437
464
  - `lanes cursor-sync <ws> --canonicalize` (dry run; add `--write` to apply) replaces every tmux lane's recorded
438
465
  create recipe that cannot create the lane — none, attach-only, the retired `~/.config/coder-launch` path (the three
439
- classes `lanes doctor` warns about, DEF-1460), or a script missing in the container — with the
466
+ classes `lanes doctor` warns about, DEF-1541), or a script missing in the container — with the
440
467
  canonical `tmux new -ADs … -e LANE_ID=… -e LANE_WS=… bash -l` recipe (old value kept as `createPrevious`).
441
468
  `--identity` adds the same `-e` flags to a working plain `tmux -L S new -ADs N [-c D] CMD` recipe, keeping CMD's
442
469
  argv; any other working recipe is left alone and its lane carries no identity. These `-e` flags are the ONLY way a
443
- tmux lane gets `LANE_ID`/the trace: `lane open` deliberately exports none (DEF-1478 — the first client on a shared
470
+ tmux lane gets `LANE_ID`/the trace: `lane open` deliberately exports none (DEF-1559 — the first client on a shared
444
471
  board socket would otherwise hand its identity to every later lane there). Both change the file only: a RUNNING
445
472
  lane keeps its environment and gains the identity when it is next created. Before enabling it on a live board,
446
- read DEF-1479: a `lanes/<ws>/<id>` trace makes the whole lane — main session, subagents, fan-out — one proxy
473
+ read DEF-1560: a `lanes/<ws>/<id>` trace makes the whole lane — main session, subagents, fan-out — one proxy
447
474
  fairness bucket. Applying `--canonicalize --write` or `--identity --write` to a live workspace is an operator gate
448
475
  (plan "Operator gates"): it rewrites operator-declared recipes in the repo's `.vscode/terminals.json` (old value kept
449
- as `createPrevious`, file backup under `cursor-sync-backups/<ts>/` as above) and produces the first live `lanes/<ws>/<id>` trace — decide DEF-1479 lane-level
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
450
477
  vs per-session fairness first.
451
478
  - Size policy "the lanes view wins": lanes-view tmux clients run with `TERM=xterm-lanesview` (a user-level terminfo
452
479
  alias of xterm-256color). While such a client shows a lane, every other client of that lane (Cursor terminals,
@@ -462,7 +489,7 @@ event-driven: Claude Code or Codex runs `~/.lz/bin/lane-signal <status>` on a tu
462
489
  `~/.lz/state/hooks/<lane id>.json` in the container, and `lanes-statusd` prefers that over the other signals while
463
490
  its agent runs and its last event is under an hour old. Nothing installs a hook for you. (`~/.lz/state/<name>.json`
464
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
465
- its sidecar under the zmx session name, which the sidebar does not show yet — DEF-1437.)
492
+ its sidecar under the zmx session name, which the sidebar does not show yet — DEF-1573.)
466
493
 
467
494
  1. `lanes install-container <name>` — puts `lane-signal` in `~/.lz/bin/` and the definitions in `~/.lz/hooks/`.
468
495
  2. `lanes hooks install --agent claude --container <name>` — prints the unified diff it would make to that
@@ -504,7 +531,7 @@ Codex 0.160 has no `codex --help hooks`; the names come from the binary. Not yet
504
531
  loads a USER-level `~/.codex/hooks.json` the way it loads a project's (the only hooks.json files measured here are
505
532
  project files), and what a hook's parent process is under each harness (lane-signal skips one shell either way).
506
533
 
507
- Known limit (DEF-1434): after you approve a permission prompt, the lane shows `needs_approval` until the approved
534
+ Known limit (DEF-1515): after you approve a permission prompt, the lane shows `needs_approval` until the approved
508
535
  tool call returns (`PostToolUse`), because neither harness signals "approved". A long approved command therefore
509
536
  reads as waiting for you until it finishes. A lane whose agent crashed loses its sidecar at once (dead pid); one
510
537
  whose agent is alive but silent keeps the last word for up to an hour.
@@ -535,7 +562,7 @@ in priority order and whole parts dropped from the end to fit 34 cells:
535
562
 
536
563
  A leading `?` and an all-dim line mean the figures are frozen: the poller kept its last fleet strip because `/status`
537
564
  failed (`"stale": true`), or the poller stopped — the poller rewrites `economy.json` at least every 60 s, and a sidebar
538
- treats a document more than 180 s old by its own clock as frozen (DEF-1447). A lane's tail cell follows the same rule:
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:
539
566
  a `⚠` or `↓` drawn dim (not amber) is the last known figure, not a live alarm — the poller kept that lane's record
540
567
  because `/sessions` failed (`"stale": true` on the lane), or the whole document is frozen. With
541
568
  `LANES_THROTTLE_CONTROL=none` every fleet figure is `null`: the strip row then shows fleet attention exactly as without
@@ -553,7 +580,7 @@ default reaches them from the host:
553
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
554
581
  `http://127.0.0.1:19181` on the host is a different loopback. Until the control port is reachable from the host —
555
582
  an operator change to the daemon's bind or forwarding, outside lanes — set `LANES_THROTTLE_CONTROL=none`: the
556
- fleet strip and live per-lane figures stay `null`, the log fold still works (DEF-1444, open).
583
+ fleet strip and live per-lane figures stay `null`, the log fold still works (DEF-1525, open).
557
584
  - The log lives in the container's home. Set `LANES_THROTTLE_CONTAINER=<container name>`: the poller reads
558
585
  `/home/coder/.local/share/throttle` through that container's home volume as `lanes sync` recorded it in
559
586
  `~/.local/state/lanes/containers.json` (the container must be on a board). The host user's own
@@ -565,7 +592,7 @@ Check what the poller can see before you enable it — `--once` names every sour
565
592
  volume sits under the docker data root (`docker info -f '{{.DockerRootDir}}'`: `/var/lib/docker` by default,
566
593
  `/data/docker` on ceobox, where the workspace homes are named volumes at `<root>/volumes/coder-<id>-home/_data`),
567
594
  which only root normally traverses. The log half then needs the same operator decision as the control half
568
- (DEF-1444): point `LANES_THROTTLE_LOG_DIR` at a directory this user can traverse, or run the poller where the daemon
595
+ (DEF-1525): point `LANES_THROTTLE_LOG_DIR` at a directory this user can traverse, or run the poller where the daemon
569
596
  runs:
570
597
 
571
598
  ```sh
@@ -575,10 +602,11 @@ systemctl --user enable --now lanes-economy.service
575
602
  journalctl --user -u lanes-economy.service # one line each time a source goes down or comes back
576
603
  ```
577
604
 
578
- Before you enable it, settle DEF-1479 (the lane-stable trace's fairness consequence) if any lane was created with
605
+ Before you enable it, settle DEF-1560 (the lane-stable trace's fairness consequence) if any lane was created with
579
606
  the identity recipe; lanes without it are joined to their requests by `agent.session_id` alone.
580
607
 
581
- Settings (environment of the installer — `install/services.py` renders them into this unit only — or a
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
582
610
  `systemctl --user edit lanes-economy.service` drop-in):
583
611
 
584
612
  | Variable | Default | Meaning |
@@ -620,17 +648,18 @@ When nothing is sent:
620
648
  (`clients/<device>.<client>.pid`), or a lane opened from the sidebar of a desk device
621
649
  (`clients/<device>.seen.json`, counted only for a device that has a `.pid` — one `lanes attach` has run for —
622
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
623
- opening a lane the pushes resume (DEF-1451). A suppressed transition is dropped, not sent later. Phone presence
651
+ opening a lane the pushes resume (DEF-1532). A suppressed transition is dropped, not sent later. Phone presence
624
652
  is decided by the device's mobile layout, not by how it attached: a Safari phone writes no `.pid`, and an
625
653
  SSH-only phone that ran `lanes attach phone --mobile` (below) is still a mobile device, so opening a lane from
626
- the phone's picker never mutes pushes — you keep getting them while you are away from every desk (DEF-1452).
654
+ the phone's picker never mutes pushes — you keep getting them while you are away from every desk (DEF-1533).
627
655
  Only a phone's own `lanes attach` in the last two minutes counts, like any attach.
628
656
 
629
657
  A failed send (server down, `HTTP 429`, no network) is one journal line naming the lane and the failure class — the
630
658
  topic and the token are never logged — and is not retried.
631
659
 
632
- Config `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` in the server installer's environment renders another
633
- path into this unit only), `key=value` lines, `#` comments, re-read on change:
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:
634
663
 
635
664
  | Key | Default | Meaning |
636
665
  |---|---|---|
@@ -763,12 +792,12 @@ what the agent needs and since when with its harness (`Needs you · 25m · claud
763
792
  agent's task. Its first line counts the lanes that need you (`◆1 need approval · ◇2 need you · ▸6 working`). The same
764
793
  counts come from the picker pane's title (see "Title and notifications" above): the header subtitle on the picker,
765
794
  the page title, a number on the **Lanes** button for lanes needing approval or input, and `◆ 1 · ◇ 2` on each
766
- workspace card. Nothing is fetched or polled for them. Not yet confirmed (DEF-1450): until plan Task 26 (2026-10-04)
795
+ workspace card. Nothing is fetched or polled for them. Not yet confirmed (DEF-1531): until plan Task 26 (2026-10-04)
767
796
  the phone's picker pane had the fixed layout name `Lanes`, which Zellij reports instead of the sidebar's title, so the
768
797
  phone showed no counts. Task 26 removed that name from the generated layout, which reaches a phone only when its
769
798
  session is laid out again (`lanes rebuild <device>`; see the device check below); whether the title
770
799
  now reaches the phone is still unchecked in a browser — until then only the picker's own first line is known to
771
- show them (DEF-1450).
800
+ show them (DEF-1531).
772
801
 
773
802
  The phone's light/dark toggle repaints the whole terminal, not only the shell: the page applies its own sixteen-colour
774
803
  palette on attach, on every toggle and after every Zellij config reload (DEF-1413; not yet looked at on a device).
@@ -780,9 +809,9 @@ sends its text as UTF-8, so a filter like `ø` matches.
780
809
 
781
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
782
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
783
- on `n`/`^C` sends nothing and a hold sends it, and (plan Task 26 has landed; DEF-1450 waits on this) that the Workspaces
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
784
813
  sheet shows each picker's counts. A phone device created before Task 26 keeps the old picker pane (`upgrade-session`
785
- does not re-lay-out a tab): run `lanes rebuild <device>` first (`lanes doctor` must not list it under DEF-1450), or no
814
+ does not re-lay-out a tab): run `lanes rebuild <device>` first (`lanes doctor` must not list it under DEF-1531), or no
786
815
  counts appear and the check says nothing about the title path. Record the
787
816
  device, iOS version and result here. The unit suite covers only the picker's text, not the browser.
788
817