@vibelord/lanes 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +92 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +928 -150
  5. package/payload/lanes/client/ghostty/common.conf +3 -0
  6. package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
  7. package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
  8. package/payload/lanes/client/ghostty/omarchy.conf +3 -2
  9. package/payload/lanes/client/lanes-launch +62 -5
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +8 -0
  12. package/payload/lanes/container/hooks/claude-settings.json +10 -0
  13. package/payload/lanes/container/hooks/codex-hooks.json +22 -0
  14. package/payload/lanes/container/lane +326 -25
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +158 -6
  17. package/payload/lanes/docs/DESIGN.md +250 -0
  18. package/payload/lanes/docs/INSTALL.md +39 -0
  19. package/payload/lanes/docs/RUNBOOK.md +507 -24
  20. package/payload/lanes/docs/SHARING.md +1 -1
  21. package/payload/lanes/holders/exec.sh +2 -5
  22. package/payload/lanes/holders/ssh.sh +18 -0
  23. package/payload/lanes/holders.lock +3 -0
  24. package/payload/lanes/install/enable-phone.sh +15 -6
  25. package/payload/lanes/install/enable-web.sh +19 -6
  26. package/payload/lanes/install/host-install.sh +10 -1
  27. package/payload/lanes/install/services.py +30 -3
  28. package/payload/lanes/install.sh +108 -3
  29. package/payload/lanes/libexec/agent_state.py +246 -0
  30. package/payload/lanes/libexec/connection.py +178 -0
  31. package/payload/lanes/libexec/cursor_sync.py +147 -6
  32. package/payload/lanes/libexec/device_sync.py +134 -0
  33. package/payload/lanes/libexec/economy_poll.py +685 -0
  34. package/payload/lanes/libexec/guard.py +27 -1
  35. package/payload/lanes/libexec/lanes_lib.py +436 -36
  36. package/payload/lanes/libexec/notify.py +317 -0
  37. package/payload/lanes/libexec/registrations.py +148 -0
  38. package/payload/lanes/libexec/session_actions.py +53 -22
  39. package/payload/lanes/libexec/sidebar.py +1190 -186
  40. package/payload/lanes/libexec/statusd.py +352 -90
  41. package/payload/lanes/libexec/tokens.py +310 -0
  42. package/payload/lanes/libexec/tokens.sh +11 -0
  43. package/payload/lanes/libexec/view.sh +22 -11
  44. package/payload/lanes/systemd/lanes-economy.service +20 -0
  45. package/payload/lanes/systemd/lanes-notify.service +19 -0
  46. package/payload/lanes/systemd/lanes-sync.service +3 -0
  47. package/payload/lanes/web/README.md +96 -2
  48. package/payload/lanes/web/browser.css +22 -0
  49. package/payload/lanes/web/browser.js +73 -0
  50. package/payload/lanes/web/index.html +2 -1
  51. package/payload/lanes/web/mobile.css +108 -221
  52. package/payload/lanes/web/mobile.js +105 -55
  53. package/payload/lanes/workspaces.example/README.md +9 -3
  54. package/payload/lanes/zellij/config.kdl +128 -19
  55. package/payload/manifest.json +7 -3
  56. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -1,17 +1,27 @@
1
1
  #!/usr/bin/env python3
2
2
  """lanes — host-level workspace server CLI. Python 3 stdlib only.
3
3
 
4
- lanes sync rebuild registry.json from the repos' terminal registries (read-only)
4
+ lanes sync refresh the host catalog and reconcile workspaces on every known device
5
5
  lanes ls [--json] [workspace] list lanes with live status
6
6
  lanes resolve <lane-id> exact target of one lane as JSON (contract for the web harness)
7
7
  lanes status one-line-per-workspace health summary
8
8
  lanes attach <device> [--client ID] [--mobile] [--viewports N]
9
- attach this terminal to the device's view session (creates it if needed)
10
- lanes prepare <device> [--mobile] create a device view without attaching a terminal (eg. Safari on a phone)
9
+ [--ascii] [--glyphs unicode|ascii|emoji] [--theme dark|light|auto] [--color truecolor|16|none]
10
+ attach this terminal to the device's view session (creates it if needed); the look
11
+ flags are remembered in devices/<device>.json and reach its running sidebars in a tick
12
+ lanes prepare <device> [--mobile|--desktop]
13
+ create a device view without attaching a terminal (eg. Safari on a phone); an explicit
14
+ layout that differs from the running view is refused, never rebuilt
15
+ lanes web <device> [--desktop|--mobile] [--viewports N] [--token | --print-only]
16
+ browser view in one step: prepare (desktop by default), enable the loopback web
17
+ services (install/enable-web.sh), print the private https://<tailnet name>:8443/<device>
18
+ URL; --token then shows a login token in YOUR terminal only
11
19
  lanes open <lane-id> attach this terminal straight to one lane (no multiplexer in between)
20
+ lanes register <lane-id> [ws] add an unregistered session to a workspace without restarting it
21
+ use lanes ls --unregistered [--json] to find its ID
12
22
  lanes layout <device> [--mobile] print the generated zellij layout
13
23
  lanes rebuild <device> recreate a device's view session (agents are never touched)
14
- lanes upgrade-session [device…] add tabs a newer layout has (e.g. Throttle) to LIVE sessions, without a rebuild
24
+ lanes upgrade-session [device…] sync catalog tabs and labels to live sessions without a rebuild
15
25
  lanes ensure recreate any known device session that is missing (never when zellij did not answer:
16
26
  an unknown session list is not an empty one)
17
27
  lanes reattach make every view re-attach its lane (after changing flags); agents untouched
@@ -19,17 +29,26 @@
19
29
  read a tar stream on stdin into that board's uploads/ folder (the same folder Cursor
20
30
  users drop files into); prints the in-container paths. --paste also types the path(s)
21
31
  into the lane that device is looking at. `lanes workspaces` lists board ids.
22
- lanes cursor-sync [ws…] [--write] [--diff]
32
+ lanes cursor-sync [ws…] [--write] [--diff] [--canonicalize] [--identity]
23
33
  make Cursor/VS Code terminals open exactly the board's lanes through `lane open`
24
- (dry-run by default: prints the git-style diff; --write applies with a backup)
34
+ (dry-run by default: prints the git-style diff; --write applies with a backup).
35
+ --canonicalize also replaces a create recipe that cannot create its tmux lane (null,
36
+ attach-only, missing script) with the canonical one whose `tmux new -e` flags give the
37
+ lane LANE_ID/LANE_WS and the lanes/<ws>/<id> trace; --identity adds those flags to a
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)
25
40
  lanes cursor-commit [ws…] [--push]
26
41
  commit the normalized terminals.json on top of each repo's REMOTE branch tip and push it,
27
42
  with git plumbing only (no working tree or index is touched). Dry-run without --push.
28
43
  lanes hello [device] what the client installer calls to prove the path, the key and the server version
29
44
  lanes client-config site facts for clients (KEY=VALUE from ~/.config/lanes/site.conf on the server)
30
- lanes install-container <name> install the `lane` contract + pinned zmx into a container's home volume (additive)
45
+ lanes install-container <name> install the `lane` contract (lane, lane-run, lane-signal, hooks/) + pinned zmx into a
46
+ container's home volume (additive; installs no agent hook)
47
+ lanes hooks install --agent claude|codex (--container <name> | --home <dir>) [--dry-run | --write]
48
+ merge the lane-signal hooks into that home's ~/.claude/settings.json or ~/.codex/hooks.json
49
+ (+ a top-level Codex notify when none is set); prints the diff, writes only with --write
50
+ (backup kept). Opt-in, per container; Codex still asks you to trust new hooks
31
51
  lanes invariant save|check snapshot / verify every live pane's pid and window size
32
- lanes verify-legacy compare the registry with the old in-container coder-launch layouts
33
52
  lanes gc truncate oversized zmx logs, drop stale dynamic resolve files
34
53
  lanes sockets [check|repair] lanes that run but cannot be reached: tmux servers whose socket file was deleted
35
54
  (someone emptied the container's /tmp); repair = tmux's SIGUSR1, nothing restarts
@@ -42,7 +61,32 @@
42
61
  one pass, by hand, that makes the next in-container out-of-memory kill take a build, never
43
62
  a lane: raises oom_score_adj of compilers / test runners by exact name (libexec/guard.py).
44
63
  No timer runs it: look at --dry-run first. The recurring half is sudo/host-guard.sh
45
- lanes doctor full health check (every external call is bounded; includes the pressure summary)
64
+ lanes economy --once [--dry-run] [--status-file F --sessions-file F]
65
+ one economy poll (libexec/economy_poll.py): the Throttle daemon's loopback /status and
66
+ /sessions plus today's proxy.log fold, printed as the economy.json v1 projection
67
+ (--dry-run writes neither economy.json nor its cursor). LANES_THROTTLE_CONTROL=none
68
+ contacts no daemon; the --*-file options read fixture documents instead; stderr names
69
+ each source it could not read. LANES_THROTTLE_CONTAINER=NAME reads that container's
70
+ log through its home volume. The recurring poller is lanes-economy.service, which no
71
+ install enables (operator gate, RUNBOOK)
72
+ lanes next|prev --focused [--device D]
73
+ what Ctrl-Alt-Down / Ctrl-Alt-Up run in a floating pane that closes on exit: point the
74
+ focused viewport (the first one when the sidebar has the focus) at the next / previous
75
+ lane of its board in registry order, wrapping, and wake its view.sh (SIGUSR1) — the
76
+ same write as Enter in the sidebar. Reads two zellij listings; moves no focus
77
+ lanes preview --focused [--device D]
78
+ what Ctrl-Alt-m runs in a floating pane: the markdown files the focused lane named in
79
+ its last 300 lines (guarded tmux capture, resolved against its cwd, existing ones only),
80
+ newest first; one is shown at once, several give a numbered menu. glow (pinned in
81
+ holders.lock) in the device's dark/light theme, else less -R
82
+ lanes doctor full health check (every external call is bounded; includes the pressure summary), with
83
+ the lanes per create-recipe class (idempotent / attach-only / null / external /
84
+ retired-path), lane identity, numeric session names, crowded `default` sockets and
85
+ orphan tmux socket files (report only; in a container `lane gc --sockets` removes them),
86
+ and any record left in the retired legacy-sessions/ directory
87
+ lanes device list | forget <name>
88
+ the devices `lanes ensure` recreates view sessions for; forget removes devices/<name>.json
89
+ (a running view session is left alone and is not brought back once it ends)
46
90
  """
47
91
  import json
48
92
  import os
@@ -83,16 +127,10 @@ def q(text):
83
127
  # ------------------------------------------------------------------- registry
84
128
 
85
129
  def cmd_sync(_args):
86
- previous = L.read_json(L.STATE / 'registry.json')
87
- registry = L.build_registry(previous)
88
- L.atomic_write(L.STATE / 'registry.json', json.dumps(registry, indent=1, ensure_ascii=False) + '\n')
89
- L.write_resolve_files(registry)
90
- L.export_container_registries(registry)
91
- for workspace in registry['workspaces']:
92
- focus = next((lane for lane in workspace['lanes'] if lane.get('focus')), None)
93
- candidates = workspace['lanes'] or workspace['extras']
94
- first = focus or (candidates[0] if candidates else None)
95
- L.atomic_write(L.STATE / 'defaults' / workspace['id'], (first['id'] if first else '!empty') + '\n')
130
+ with L.registry_lock():
131
+ previous = L.read_json(L.STATE / 'registry.json')
132
+ registry = L.build_registry(previous)
133
+ L.publish_registry(registry)
96
134
  total = sum(len(w['lanes']) for w in registry['workspaces'])
97
135
  failed = [w for w in registry['workspaces'] if w['errors']]
98
136
  for workspace in registry['workspaces']:
@@ -101,18 +139,37 @@ def cmd_sync(_args):
101
139
  for error in workspace['errors']:
102
140
  print(f' ⚠ {error}' + (' (kept previous lanes)' if workspace.get('stale') else ''))
103
141
  print(f"{total} lanes in {len(registry['workspaces'])} workspaces" + (f'; {len(failed)} with errors' if failed else ''))
142
+ if any((L.STATE / 'devices').glob('*.json')):
143
+ cmd_upgrade_session([])
104
144
  return 1 if failed else 0
105
145
 
106
146
 
147
+ def cmd_register(args):
148
+ if not 1 <= len(args) <= 2:
149
+ raise L.LanesError('Usage: lanes register <lane-id> [workspace]')
150
+ import registrations
151
+ workspace, entry = L.find_lane(L.load_registry(), args[0])
152
+ lane = registrations.register(workspace['id'], entry, args[1] if len(args) == 2 else None)
153
+ print(f"Registered {lane['name']} in {lane['workspace']}")
154
+
155
+
107
156
  def cmd_ls(args):
108
157
  registry = L.load_registry()
109
- status = (L.read_json(L.STATE / 'status.json') or {}).get('lanes', {})
158
+ snapshot = L.read_json(L.STATE / 'status.json') or {}
159
+ status = snapshot.get('lanes', {})
160
+ import registrations
161
+ registered = {registrations.key(lane) for w in registry['workspaces'] for lane in w['lanes']
162
+ if lane.get('holder') == 'tmux'}
110
163
  wanted = [a for a in args if not a.startswith('-')]
111
164
  rows = []
112
165
  for workspace in registry['workspaces']:
113
166
  if wanted and workspace['id'] not in wanted:
114
167
  continue
115
- for lane in workspace['lanes']:
168
+ entries = ((snapshot.get('unregistered') or {}).get(workspace['id'], [])
169
+ if '--unregistered' in args else workspace['lanes'])
170
+ for lane in entries:
171
+ if '--unregistered' in args and registrations.eligible(lane) and registrations.key(lane) in registered:
172
+ continue
116
173
  rows.append(dict(lane, workspace=workspace['id'], status=status.get(lane['id'], {'state': 'unknown'})))
117
174
  if '--json' in args:
118
175
  print(json.dumps(rows, indent=1, ensure_ascii=False))
@@ -154,47 +211,121 @@ def cmd_status(_args):
154
211
 
155
212
  # --------------------------------------------------------------------- views
156
213
 
157
- def layout_kdl(device, mobile=False, viewports=1):
158
- registry = L.load_registry()
159
- sidebar, view = str(L.REPO / 'libexec/sidebar'), str(L.REPO / 'libexec/view.sh')
160
- lines = ['// Generated by `lanes layout`. Lanes are NOT part of this layout: adding, removing or',
161
- '// reordering lanes only changes registry.json, so this layout never needs live surgery.',
162
- 'layout {',
163
- ' default_tab_template {',
164
- ' children',
165
- ' pane size=1 borderless=true {',
166
- ' plugin location="zellij:compact-bar"',
167
- ' }',
168
- ' }']
169
- for index, workspace in enumerate(registry['workspaces']):
170
- focus = ' focus=true' if index == 0 else ''
214
+ def tab_layout(lines):
215
+ return '\n'.join(['layout {', ' default_tab_template {', ' children',
216
+ ' pane size=1 borderless=true {', ' plugin location="zellij:compact-bar"',
217
+ ' }', ' }'] + lines + ['}']) + '\n'
218
+
219
+
220
+ def tab_specs(device, registry, mobile=False, viewports=1):
221
+ result = []
222
+ def add(key, title, lines):
223
+ result.append({'key': key, 'title': title, 'lines': lines, 'layout': tab_layout(lines)})
224
+ def board(workspace):
171
225
  if mobile:
172
- lines += [f' tab name={q(workspace["title"] + " ☰")}{focus} {{',
173
- f' pane borderless=true name="Lanes" command={q(sidebar)} {{',
174
- f' args {q(device)} {q(workspace["id"])}',
175
- ' }', ' }',
176
- f' tab name={q(workspace["title"])} {{',
177
- f' pane borderless=true name={q(workspace["title"])} command={q(view)} {{',
178
- f' args {q(device)} {q(workspace["id"])} "main" "--mobile"',
179
- ' }', ' }']
226
+ for picker in (True, False):
227
+ role = 'picker' if picker else 'view'
228
+ title = workspace['title'] + (' ☰' if picker else '')
229
+ add(f"workspace/{workspace['id']}/{role}", title, mobile_workspace_tab(device, workspace, picker))
230
+ else:
231
+ add(f"workspace/{workspace['id']}/view", workspace['title'], workspace_tab_kdl(device, workspace, False, viewports))
232
+ for workspace in registry['workspaces']:
233
+ if workspace.get('position') != 'before-host': board(workspace)
234
+ for workspace in registry.get('ssh_workspaces', []):
235
+ add('ssh/' + workspace['id'], workspace['title'], ssh_workspace_tab_kdl(workspace))
236
+ for tool in registry.get('tools', []):
237
+ add('tool/' + tool['view'], tool['tab'], tool_tab_kdl(device, tool, mobile=mobile))
238
+ for workspace in registry['workspaces']:
239
+ if workspace.get('position') == 'before-host': board(workspace)
240
+ add('host', 'Host', [' tab name="Host" {', ' pane command="/bin/bash" {', ' args "-l"', ' }', ' }'])
241
+ return result
242
+
243
+
244
+ def layout_kdl(device, mobile=False, viewports=1):
245
+ specs = tab_specs(device, L.load_registry(), mobile, viewports)
246
+ lines = []
247
+ for index, spec in enumerate(specs):
248
+ tab = list(spec['lines'])
249
+ if index == 0:
250
+ tab[0] = tab[0].replace(' {', ' focus=true {', 1)
251
+ lines += tab
252
+ return tab_layout(lines)
253
+
254
+
255
+ def mobile_workspace_tab(device, workspace, picker):
256
+ title = workspace['title'] + (' ☰' if picker else '')
257
+ command = str(L.REPO / 'libexec' / ('sidebar' if picker else 'view.sh'))
258
+ args = f'{q(device)} {q(workspace["id"])}' + ('' if picker else ' "main" "--mobile"')
259
+ # 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).
261
+ name = '' if picker else f' name={q(workspace["title"])}'
262
+ return [f' tab name={q(title)} {{',
263
+ f' pane borderless=true{name} command={q(command)} {{',
264
+ f' args {args}', ' }', ' }']
265
+
266
+
267
+ def stale_picker_layouts():
268
+ """Phone devices whose stored layout still names the picker's sidebar pane (the pre-Task 26 `name="Lanes"`).
269
+
270
+ `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).
272
+ `create_session` is the only writer of layouts/<device>.kdl, so the file is the layout the session was built from.
273
+ """
274
+ sidebar = str(L.REPO / 'libexec' / 'sidebar')
275
+ stale = []
276
+ for meta_file in sorted((L.STATE / 'devices').glob('*.json')):
277
+ if not (L.read_json(meta_file) or {}).get('mobile'):
180
278
  continue
181
- lines += [f' tab name={q(workspace["title"])}{focus} {{',
182
- ' pane split_direction="vertical" {',
183
- f' pane size=36 borderless=true name="Lanes" command={q(sidebar)} {{',
184
- f' args {q(device)} {q(workspace["id"])} "main" {q(str(viewports))}',
279
+ try:
280
+ text = (L.STATE / 'layouts' / f'{meta_file.stem}.kdl').read_text()
281
+ except OSError:
282
+ continue
283
+ if any('name="Lanes"' in line and f'command={q(sidebar)}' in line for line in text.splitlines()):
284
+ stale.append(meta_file.stem)
285
+ return stale
286
+
287
+
288
+ def workspace_tab_kdl(device, workspace, mobile=False, viewports=1, focused=False):
289
+ sidebar, view = str(L.REPO / 'libexec/sidebar'), str(L.REPO / 'libexec/view.sh')
290
+ focus = ' focus=true' if focused else ''
291
+ lines = []
292
+ if mobile:
293
+ return mobile_workspace_tab(device, workspace, True) + mobile_workspace_tab(device, workspace, False)
294
+ lines += [f' tab name={q(workspace["title"])}{focus} {{',
295
+ ' pane split_direction="vertical" {',
296
+ f' pane size=36 borderless=true name="Lanes" command={q(sidebar)} {{',
297
+ f' args {q(device)} {q(workspace["id"])} "main" {q(str(viewports))}',
298
+ ' }']
299
+ # Wide devices: several viewports side by side, each with its own target (slots main, b, c).
300
+ for index, slot in enumerate(SLOTS[:viewports]):
301
+ title = workspace['title'] if index == 0 else f"{workspace['title']} ·{index + 1}"
302
+ pane_focus = ' focus=true' if index == 0 else ''
303
+ lines += [f' pane borderless={"true" if viewports == 1 else "false"}{pane_focus} name={q(title)} command={q(view)} {{',
304
+ f' args {q(device)} {q(workspace["id"])} {q(slot)}',
185
305
  ' }']
186
- # Wide devices: several viewports side by side, each with its own target (slots main, b, c).
187
- for index, slot in enumerate(SLOTS[:viewports]):
188
- title = workspace['title'] if index == 0 else f"{workspace['title']} ·{index + 1}"
189
- pane_focus = ' focus=true' if index == 0 else ''
190
- lines += [f' pane borderless={"true" if viewports == 1 else "false"}{pane_focus} name={q(title)} command={q(view)} {{',
191
- f' args {q(device)} {q(workspace["id"])} {q(slot)}',
192
- ' }']
193
- lines += [' }', ' }']
194
- for tool in registry.get('tools', []):
195
- lines += tool_tab_kdl(device, tool, mobile=mobile)
196
- lines += [' tab name="Host" {', ' pane command="/bin/bash" {', ' args "-l"', ' }', ' }', '}']
197
- return '\n'.join(lines) + '\n'
306
+ lines += [' }', ' }']
307
+ return lines
308
+
309
+
310
+ def ssh_workspace_tab_kdl(workspace, indent=' '):
311
+ """Main terminal on the left; up to two worker terminals stacked on the right."""
312
+ holder = str(L.REPO / 'holders/ssh.sh')
313
+ panes = workspace['panes']
314
+ lines = [f'{indent}tab name={q(workspace["title"])} split_direction="vertical" {{']
315
+
316
+ def pane_lines(pane, prefix, size=''):
317
+ return [f'{prefix}pane name={q(pane["name"])}{size} command={q(holder)} {{',
318
+ f'{prefix} args {q(workspace["host"])} {q(workspace["command"])} {q(pane["id"])}',
319
+ f'{prefix}}}']
320
+
321
+ lines += pane_lines(panes[0], indent + ' ', ' size="60%"' if len(panes) > 1 else '')
322
+ if len(panes) > 1:
323
+ lines.append(f'{indent} pane split_direction="horizontal" {{')
324
+ for pane in panes[1:]:
325
+ lines += pane_lines(pane, indent + ' ')
326
+ lines.append(f'{indent} }}')
327
+ lines.append(f'{indent}}}')
328
+ return lines
198
329
 
199
330
 
200
331
  def tool_tab_kdl(device, tool, indent=' ', mobile=False):
@@ -249,9 +380,16 @@ def create_session(device, mobile, viewports=1):
249
380
  L.atomic_write(layout_file, layout_kdl(device, mobile, viewports))
250
381
  meta_file = L.STATE / 'devices' / f'{device}.json'
251
382
  meta = {'version': 1, 'mobile': mobile, 'viewports': viewports, 'created_at': time.time()}
252
- base = (L.read_json(meta_file) or {}).get('base')
253
- if base: # learnt at attach (see base_device); `ensure` and `rebuild` must not forget it
254
- meta['base'] = base
383
+ old = L.read_json(meta_file) if meta_file.exists() else None
384
+ old = old if isinstance(old, dict) else {}
385
+ if old.get('base'): # learnt at attach (see base_device); `ensure` and `rebuild` must not forget it
386
+ meta['base'] = old['base']
387
+ # The look `lanes attach --ascii|--glyphs|--theme|--color` recorded (record_look) is the operator's choice, not
388
+ # session state: a recreation (ensure after a reboot, rebuild) keeps every valid one, or every sidebar of the
389
+ # device would flip back to the environment defaults within one tick of this rewrite.
390
+ for key, allowed in LOOK_KEYS.values():
391
+ if old.get(key) in allowed:
392
+ meta[key] = old[key]
255
393
  L.atomic_write(meta_file, json.dumps(meta) + '\n')
256
394
  command = ZELLIJ_BASE + ['--layout', str(layout_file), 'attach', '--create-background', device]
257
395
  if shutil.which('systemd-run'):
@@ -282,6 +420,39 @@ def viewports_arg(args, default=1):
282
420
  return default
283
421
 
284
422
 
423
+ LOOK_KEYS = {'--theme': ('theme', ('dark', 'light', 'auto')), '--color': ('color', ('truecolor', '16', 'none')),
424
+ '--glyphs': ('glyphs', ('unicode', 'ascii', 'emoji'))}
425
+
426
+
427
+ def look_args(args):
428
+ """The look `lanes attach` was asked for: {'glyphs'|'theme'|'color': value}, validated (spec §2.4)."""
429
+ look = {}
430
+ if '--ascii' in args:
431
+ look['glyphs'] = 'ascii'
432
+ for flag, (key, allowed) in LOOK_KEYS.items():
433
+ if flag in args:
434
+ value = args[args.index(flag) + 1] if flag in args[:-1] else ''
435
+ if value not in allowed:
436
+ raise L.LanesError(f'{flag} must be one of {", ".join(allowed)}')
437
+ look[key] = value
438
+ return look
439
+
440
+
441
+ def record_look(device, args):
442
+ """`lanes attach DEVICE --ascii|--glyphs M|--theme T|--color C`: remembered in devices/<device>.json, the file every
443
+ sidebar of that device re-reads when it changes (its reload() stamp), so a RUNNING session follows within one tick.
444
+ Returns what was recorded: empty when nothing was asked — or when the device has no file yet, because `ensure`
445
+ builds a session for every devices/*.json and a look flag must never create that stub on its own."""
446
+ look = look_args(args)
447
+ meta_file = L.STATE / 'devices' / f'{device}.json'
448
+ meta = L.read_json(meta_file) if meta_file.exists() else None
449
+ if not look or not isinstance(meta, dict):
450
+ return {}
451
+ if any(meta.get(key) != value for key, value in look.items()):
452
+ L.atomic_write(meta_file, json.dumps(dict(meta, **look)) + '\n')
453
+ return look
454
+
455
+
285
456
  def cmd_layout(args):
286
457
  print(layout_kdl(device_name(args[0]), '--mobile' in args, viewports_arg(args)), end='')
287
458
  return 0
@@ -289,12 +460,17 @@ def cmd_layout(args):
289
460
 
290
461
  def cmd_attach(args):
291
462
  device = device_name(args[0])
463
+ look_args(args) # a bad --theme/--color/--glyphs refuses here, before any session is created
292
464
  # Old muscle memory: `coder-launch worlds`. A workspace id is not a device: open the caller's normal
293
465
  # device session and bring that workspace's tab to the front.
294
466
  tab = None
295
467
  default = args[args.index('--default-device') + 1] if '--default-device' in args[:-1] else None
296
468
  try:
297
- workspace = next((w for w in L.load_registry()['workspaces'] if w['id'] == device), None)
469
+ registry = L.load_registry()
470
+ boards = registry['workspaces'] + registry.get('ssh_workspaces', [])
471
+ workspace = next((w for w in boards if w['id'] == device), None)
472
+ if device == default or (not default and (L.STATE / 'devices' / f'{device}.json').exists()):
473
+ workspace = None
298
474
  except L.LanesError:
299
475
  workspace = None
300
476
  if workspace:
@@ -325,6 +501,7 @@ def cmd_attach(args):
325
501
  meta = L.read_json(meta_file) or {}
326
502
  if default and meta and meta.get('base') != base_device(device, default):
327
503
  L.atomic_write(meta_file, json.dumps(dict(meta, base=base_device(device, default))) + '\n')
504
+ record_look(device, args)
328
505
  # Views idle until a real client (with a real size) has attached once. We exec into the zellij
329
506
  # client below, so a detached helper raises the flag shortly after the client has sized the session.
330
507
  ready = L.STATE / 'clients' / f'{device}.ready'
@@ -371,7 +548,7 @@ def cmd_open(args):
371
548
  if not resolve.exists():
372
549
  raise L.LanesError(f'Unknown lane: {lane_id}')
373
550
  holder, a, b, c = resolve.read_text().split('\n')[:4]
374
- if holder not in ('tmux', 'zmx', 'exec'):
551
+ if holder not in ('tmux', 'zmx', 'exec', 'ssh'):
375
552
  raise L.LanesError(f'Unknown holder: {holder}')
376
553
  env = clean_env({'TERM': 'xterm-256color', 'LANES_WS': lane_id.split('--')[0]})
377
554
  os.execve(str(L.REPO / 'holders' / f'{holder}.sh'), [f'{holder}.sh', a, b, c], env)
@@ -405,8 +582,13 @@ def cmd_install_container(args):
405
582
  root = Path(info['home']) / '.lz'
406
583
  for sub in ('bin', 's', 'meta', 'state'):
407
584
  (root / sub).mkdir(parents=True, exist_ok=True)
408
- for source in (L.REPO / 'container/lane', L.REPO / 'container/lane-run'):
585
+ for source in (L.REPO / 'container/lane', L.REPO / 'container/lane-run', L.REPO / 'container/lane-signal'):
409
586
  shutil.copy2(source, root / 'bin' / source.name)
587
+ # The hook DEFINITIONS travel with the contract for reference; no hook is installed here — that is the opt-in,
588
+ # per-container `lanes hooks install --agent claude|codex --container <name> --write` (RUNBOOK).
589
+ (root / 'hooks').mkdir(exist_ok=True)
590
+ for source in sorted((L.REPO / 'container/hooks').glob('*.json')):
591
+ shutil.copy2(source, root / 'hooks' / source.name)
410
592
  env_file = L.REPO / 'container/env.sh' # TMPDIR off the RAM-backed /tmp; only NEW shells and agents read it
411
593
  if env_file.exists():
412
594
  shutil.copy2(env_file, root / 'env.sh')
@@ -420,8 +602,139 @@ def cmd_install_container(args):
420
602
  if link.is_symlink() or link.exists():
421
603
  link.unlink()
422
604
  link.symlink_to(root)
423
- print(f'installed {root}/bin/{{lane,lane-run,zmx-*}}' + (f' and {root}/env.sh' if env_file.exists() else '')
424
- + f'; host link {link} -> {root}')
605
+ print(f'installed {root}/bin/{{lane,lane-run,lane-signal,zmx-*}}, {root}/hooks/'
606
+ + (f' and {root}/env.sh' if env_file.exists() else '') + f'; host link {link} -> {root}')
607
+ return 0
608
+
609
+
610
+ HOOK_TARGETS = {'claude': ('claude-settings.json', '.claude/settings.json'),
611
+ 'codex': ('codex-hooks.json', '.codex/hooks.json')}
612
+ HOOKS_USAGE = ('usage: lanes hooks install --agent claude|codex (--container <name> | --home <dir>) '
613
+ '[--dry-run | --write]')
614
+
615
+
616
+ def render_hooks(agent):
617
+ """The shipped hook definitions with `~/` spelled as the container home: the command reaches lane-signal whether
618
+ or not the harness runs it through a shell that expands `~`."""
619
+ doc = json.loads((L.REPO / 'container/hooks' / HOOK_TARGETS[agent][0]).read_text())
620
+ for groups in doc['hooks'].values():
621
+ for group in groups:
622
+ for hook in group['hooks']:
623
+ hook['command'] = hook['command'].replace('~/', L.CONTAINER_HOME + '/', 1)
624
+ return doc
625
+
626
+
627
+ def is_lane_signal_group(group):
628
+ hooks = group.get('hooks') if isinstance(group, dict) else None
629
+ return bool(hooks) and all(isinstance(h, dict) and 'lane-signal' in str(h.get('command') or '') for h in hooks)
630
+
631
+
632
+ def merge_hooks(existing, ours):
633
+ """`existing` (a settings.json / hooks.json document) with every lane-signal group from an earlier install removed
634
+ and `ours` appended per event. Every other key, event and group stays exactly where it was."""
635
+ merged = dict(existing)
636
+ hooks = existing.get('hooks', {})
637
+ if not isinstance(hooks, dict) or not all(isinstance(groups, list) for groups in hooks.values()):
638
+ raise L.LanesError('the existing file has a "hooks" value that is not {event: [groups]} — fix it by hand first')
639
+ out = {}
640
+ for event, groups in hooks.items():
641
+ kept = [group for group in groups if not is_lane_signal_group(group)]
642
+ if kept or event in ours['hooks']:
643
+ out[event] = kept
644
+ for event, groups in ours['hooks'].items():
645
+ out[event] = out.get(event, []) + groups
646
+ merged['hooks'] = out
647
+ return merged
648
+
649
+
650
+ def codex_notify(text):
651
+ """config.toml with a top-level `notify` that hands Codex's turn-complete to lane-signal, or `text` unchanged when
652
+ a top-level `notify` already exists (an operator's own notifier is never replaced). The line goes first, so it is
653
+ top level whatever tables follow; a `notify` inside a table is not Codex's notify."""
654
+ for line in text.splitlines():
655
+ stripped = line.strip()
656
+ if stripped.startswith('['):
657
+ break
658
+ if re.match(r'notify\s*=', stripped):
659
+ return text
660
+ argv = [f'{L.CONTAINER_HOME}/.lz/bin/lane-signal', 'done', '--harness', 'codex', '--event', 'notify']
661
+ return ('# lanes: Codex turn-complete -> lane-signal (lanes hooks install)\nnotify = '
662
+ + json.dumps(argv) + '\n' + text)
663
+
664
+
665
+ def show_change(path, before, after):
666
+ import difflib
667
+ if before == after:
668
+ print(f'{path}: no change')
669
+ return False
670
+ sys.stdout.writelines(difflib.unified_diff(before.splitlines(True), after.splitlines(True),
671
+ f'{path} (now)', f'{path} (with lanes hooks)'))
672
+ return True
673
+
674
+
675
+ def write_with_backup(path, before, after):
676
+ """Atomic replace, the previous bytes kept beside it first. `before` is what the diff was computed from."""
677
+ if path.exists():
678
+ backup = path.with_name(f'{path.name}.lanes-bak-{time.strftime("%Y%m%dT%H%M%S")}')
679
+ shutil.copy2(path, backup)
680
+ print(f'backup {backup}')
681
+ elif before:
682
+ raise L.LanesError(f'{path} vanished while the diff was printed — run again')
683
+ L.atomic_write(path, after, mode=(path.stat().st_mode & 0o777) if path.exists() else 0o600)
684
+ print(f'wrote {path}')
685
+
686
+
687
+ def cmd_hooks(args):
688
+ """`lanes hooks install`: merge the lane-signal hook definitions into ONE container home's agent config. Dry run
689
+ (the printed diff) unless --write; never touches Codex's hook trust (`[hooks.state]` is Codex's own decision)."""
690
+ if not args or args[0] != 'install':
691
+ raise L.LanesError(HOOKS_USAGE)
692
+ opts, rest = {}, list(args[1:])
693
+ while rest:
694
+ word = rest.pop(0)
695
+ if word in ('--agent', '--container', '--home') and rest:
696
+ opts[word] = rest.pop(0)
697
+ elif word in ('--dry-run', '--write'):
698
+ opts[word] = True
699
+ else:
700
+ raise L.LanesError(HOOKS_USAGE)
701
+ agent = opts.get('--agent')
702
+ if agent not in HOOK_TARGETS or ('--container' in opts) == ('--home' in opts) or \
703
+ ('--dry-run' in opts and '--write' in opts):
704
+ raise L.LanesError(HOOKS_USAGE)
705
+ if '--container' in opts:
706
+ home = (L.container_info(opts['--container']) or {}).get('home')
707
+ if not home:
708
+ raise L.LanesError(f"{opts['--container']}: home volume not found")
709
+ home = Path(home)
710
+ else:
711
+ home = Path(opts['--home'])
712
+ if not home.is_dir():
713
+ raise L.LanesError(f'{home}: not a directory')
714
+ target = home / HOOK_TARGETS[agent][1]
715
+ before = target.read_text() if target.exists() else ''
716
+ try:
717
+ existing = json.loads(before) if before.strip() else {}
718
+ except ValueError as error:
719
+ raise L.LanesError(f'{target}: not valid JSON ({error}) — refusing to rewrite it')
720
+ if not isinstance(existing, dict):
721
+ raise L.LanesError(f'{target}: not a JSON object — refusing to rewrite it')
722
+ after = json.dumps(merge_hooks(existing, render_hooks(agent)), indent=2, ensure_ascii=False) + '\n'
723
+ changes = [(target, before, after)] if show_change(target, before, after) else []
724
+ if agent == 'codex':
725
+ config = home / '.codex/config.toml'
726
+ text = config.read_text() if config.exists() else ''
727
+ patched = codex_notify(text)
728
+ if patched == text:
729
+ print(f'{config}: notify already set; left alone (lane-signal still hears Stop)')
730
+ elif show_change(config, text, patched):
731
+ changes.append((config, text, patched))
732
+ print('Codex asks you to review and trust new hooks before it runs them; lanes never writes that trust.')
733
+ if not opts.get('--write'):
734
+ print('dry run: nothing written (add --write to apply)')
735
+ return 0
736
+ for path, old, new in changes:
737
+ write_with_backup(path, old, new)
425
738
  return 0
426
739
 
427
740
 
@@ -441,31 +754,23 @@ def cmd_rebuild(args):
441
754
 
442
755
 
443
756
  def cmd_upgrade_session(args):
757
+ import device_sync
444
758
  registry = L.load_registry()
445
759
  live = live_sessions()
446
760
  devices = [device_name(a) for a in args] or [p.stem for p in sorted((L.STATE / 'devices').glob('*.json'))]
761
+ failures = []
447
762
  for device in devices:
448
763
  if device not in live:
449
- print(f'{device}: not running (it gets the current layout when it is next created)')
450
- continue
451
- rc, out, _err = zellij(['-s', device, 'action', 'list-tabs', '-j'], 20)
452
- if rc != 0: # unknown tabs are not missing tabs: add nothing
453
- print(f'{device}: zellij did not answer; left alone')
454
- continue
455
- tabs = json.loads(out or '[]')
456
- names = {tab.get('name') for tab in tabs}
457
- for tool in registry.get('tools', []):
458
- if tool['tab'] in names:
459
- print(f"{device}: tab '{tool['tab']}' already present")
460
- continue
461
- layout = '\n'.join(['layout {', ' default_tab_template {', ' children',
462
- ' pane size=1 borderless=true {', ' plugin location="zellij:compact-bar"',
463
- ' }', ' }'] + tool_tab_kdl(device, tool) + ['}'])
464
- active = next((tab for tab in tabs if tab.get('active')), None)
465
- zellij(['-s', device, 'action', 'new-tab', '--name', tool['tab'], '--layout-string', layout], 30)
466
- if active: # new-tab takes the focus: hand it back to where the user was
467
- zellij(['-s', device, 'action', 'go-to-tab-name', active['name']], 15)
468
- print(f"{device}: added tab '{tool['tab']}'")
764
+ continue # ensure recreates missing sessions with the same catalog
765
+ meta = L.read_json(L.STATE / 'devices' / (device + '.json')) or {}
766
+ mobile, viewports = bool(meta.get('mobile')), int(meta.get('viewports') or 1)
767
+ try:
768
+ for change in device_sync.reconcile(device, registry, tab_specs(device, registry, mobile, viewports), mobile, zellij):
769
+ print(f'{device}: {change}')
770
+ except (L.LanesError, ValueError, KeyError) as error:
771
+ failures.append(str(error))
772
+ if failures:
773
+ raise L.LanesError('; '.join(failures))
469
774
  return 0
470
775
 
471
776
 
@@ -493,36 +798,133 @@ def cmd_ensure(_args):
493
798
  meta = L.read_json(meta_file) or {}
494
799
  create_session(device, bool(meta.get('mobile')), int(meta.get('viewports') or 1))
495
800
  print(f'recreated view session {device}')
496
- return 0
801
+ return cmd_upgrade_session([])
497
802
 
498
803
 
499
- def cmd_prepare(args):
500
- device = device_name(args[0])
804
+ def prepare_view(device, mobile, viewports=1, explicit=False):
805
+ """Create the device's view session unless it runs -> 'created' | 'reused'. A running view is never rebuilt:
806
+ a mobile request refuses a desktop view, and an EXPLICIT desktop request (`--desktop`, `lanes web`) refuses a
807
+ mobile one. Without a flag, `prepare` keeps reusing whatever layout runs."""
501
808
  if device not in live_sessions():
502
- create_session(device, '--mobile' in args, viewports_arg(args))
503
- elif '--mobile' in args and not (L.read_json(L.STATE / 'devices' / f'{device}.json') or {}).get('mobile'):
809
+ create_session(device, mobile, viewports)
810
+ return 'created'
811
+ running_mobile = bool((L.read_json(L.STATE / 'devices' / f'{device}.json') or {}).get('mobile'))
812
+ if mobile and not running_mobile:
504
813
  raise L.LanesError(f'{device} already has a desktop layout; choose another device name')
814
+ if explicit and not mobile and running_mobile:
815
+ raise L.LanesError(f'{device} already has a mobile layout; choose another device name')
816
+ return 'reused'
817
+
818
+
819
+ def cmd_prepare(args):
820
+ device = device_name(args[0])
821
+ if '--mobile' in args and '--desktop' in args:
822
+ raise L.LanesError('--mobile and --desktop are exclusive')
823
+ prepare_view(device, '--mobile' in args, viewports_arg(args), explicit='--desktop' in args)
505
824
  print(f'ready: {device}')
506
825
  return 0
507
826
 
508
827
 
828
+ WEB_LOOPBACK = 'http://127.0.0.1:8083' # lanes-mobile.service (web/Caddyfile); Tailscale Serve forwards :8443 here
829
+ WEB_USAGE = 'usage: lanes web <device> [--desktop|--mobile] [--viewports N] [--token | --print-only]'
830
+
831
+
832
+ def tailnet_name():
833
+ """This node's MagicDNS name, read the way install/enable-phone.sh reads it; None when tailscale is absent,
834
+ silent or has no name. Read-only: Tailscale Serve itself is configured once by enable-phone.sh."""
835
+ if not shutil.which('tailscale'):
836
+ return None
837
+ rc, out, _err = L.run_bounded(['tailscale', 'status', '--json'], 10)
838
+ if rc != 0:
839
+ return None
840
+ try:
841
+ name = (json.loads(out).get('Self') or {}).get('DNSName') or ''
842
+ except (ValueError, AttributeError):
843
+ return None
844
+ return name.rstrip('.') or None
845
+
846
+
847
+ def cmd_web(args):
848
+ """lanes web <device> [--desktop|--mobile] [--viewports N] [--token | --print-only]
849
+
850
+ One command for a browser view (laptop, tablet, phone): prepare the device's view (idempotent; a running view
851
+ of the other layout is refused, never rebuilt), enable the loopback web services through install/enable-web.sh,
852
+ print the private URL. Without --desktop/--mobile a known device (devices/<device>.json) keeps its recorded
853
+ layout, exactly as a flagless `lanes prepare` reuses whatever runs; only a new device defaults to desktop.
854
+ --token execs the pinned `zellij web --create-token` exactly as enable-phone.sh does, in the caller's terminal
855
+ only; lanes never prints or logs a token. --print-only changes nothing. Output: `key: value` lines
856
+ (DM-LANES-WEB-CLI v1)."""
857
+ words, skip = [], False
858
+ for arg in args:
859
+ if skip:
860
+ skip = False
861
+ elif arg == '--viewports':
862
+ skip = True
863
+ elif arg.startswith('-'):
864
+ if arg not in ('--desktop', '--mobile', '--token', '--print-only'):
865
+ raise L.LanesError(f'unknown option {arg}; {WEB_USAGE}')
866
+ else:
867
+ words.append(arg)
868
+ if len(words) != 1:
869
+ raise L.LanesError(WEB_USAGE)
870
+ device = device_name(words[0])
871
+ if '--mobile' in args and '--desktop' in args:
872
+ raise L.LanesError('--mobile and --desktop are exclusive')
873
+ explicit = '--mobile' in args or '--desktop' in args
874
+ known = None if explicit else L.read_json(L.STATE / 'devices' / f'{device}.json')
875
+ mobile = bool(known.get('mobile')) if isinstance(known, dict) else '--mobile' in args
876
+ viewports = viewports_arg(args)
877
+ token, print_only = '--token' in args, '--print-only' in args
878
+ if token and print_only:
879
+ raise L.LanesError('--token and --print-only are exclusive')
880
+ if token and not (sys.stdin.isatty() and sys.stdout.isatty()):
881
+ print('lanes: run `lanes web --token` in your own terminal; it displays a private login token.', file=sys.stderr)
882
+ return 64
883
+ if token and not Path(ZELLIJ_BASE[0]).exists():
884
+ raise L.LanesError(f'{ZELLIJ_BASE[0]} is not installed: run install/host-install.sh')
885
+ flag = '--mobile' if mobile else '--desktop'
886
+ prepare = ['lanes', 'prepare', device, flag] + (['--viewports', str(viewports)] if viewports != 1 else [])
887
+ state = 'planned'
888
+ if not print_only:
889
+ state = prepare_view(device, mobile, viewports, explicit=explicit)
890
+ rc, out, err = L.run_bounded(['sh', str(L.REPO / 'install/enable-web.sh'), device, flag], 180)
891
+ if rc != 0:
892
+ raise L.LanesError('install/enable-web.sh: ' + ('no answer in 180 s' if rc is None
893
+ else err.strip() or out.strip() or f'exit {rc}'))
894
+ dns = tailnet_name()
895
+ lines = [('device', device), ('layout', 'mobile' if mobile else 'desktop'), ('state', state),
896
+ ('prepare', ' '.join(prepare)), ('services', f'sh install/enable-web.sh {device} {flag}'),
897
+ ('url', f'https://{dns}:8443/{device}' if dns else f'{WEB_LOOPBACK}/{device}')]
898
+ if not dns:
899
+ lines.append(('note', 'no Tailscale name on this host: the loopback URL opens only here; '
900
+ 'there is no public fallback by design (docs/INSTALL.md)'))
901
+ if not token:
902
+ lines.append(('next', f'lanes web {device} {flag} --token (in your own terminal: a login token)'))
903
+ print(''.join(f'{key}: {value}\n' for key, value in lines), end='')
904
+ if token:
905
+ print('\nThe last line is NAME: TOKEN. Paste only TOKEN into the sign-in screen.\n', flush=True)
906
+ # Zellij 0.45.1 requires --create-token by itself; --token-name cannot accompany it (enable-phone.sh).
907
+ os.execv(ZELLIJ_BASE[0], ZELLIJ_BASE + ['web', '--create-token'])
908
+ return 0
909
+
910
+
509
911
  # ------------------------------------------------------------------ safety
510
912
 
511
913
  PANE_FORMAT = '#{session_name}\t#{window_index}.#{pane_index}\t#{pane_pid}\t#{window_width}x#{window_height}'
512
914
 
513
915
 
514
916
  def all_panes():
515
- """Every pane on every LIVE tmux server that the registry references (ps-discovered, read-only).
516
- Ephemeral test-harness sockets come and go on their own and are not lanes.
517
- Raises when ps or a live server did not ANSWER: a partial list would be saved as a weaker invariant, or
518
- compared as panes that are "gone" — on a busy host both are wrong."""
917
+ """Every pane on every LIVE tmux server that the registry references (found in the containers' cgroups the way
918
+ statusd finds them, read-only). Ephemeral test-harness sockets come and go on their own and are not lanes.
919
+ Raises when a container's cgroup could not be read or a live server did not ANSWER: a partial list would be
920
+ saved as a weaker invariant, or compared as panes that are "gone" — on a busy host both are wrong."""
519
921
  sys.path.insert(0, str(L.REPO / 'libexec'))
520
922
  import statusd
521
923
  unknown = 'the panes are UNKNOWN, not gone — nothing saved or compared; try again'
522
- live = statusd.live_tmux_sockets()
523
- if live is None:
524
- raise L.LanesError(f'ps did not answer: {unknown}')
525
924
  containers = L.read_json(L.STATE / 'containers.json') or {}
925
+ live = statusd.live_tmux_sockets(containers)
926
+ if live is None:
927
+ raise L.LanesError(f'the live tmux servers could not be read from the container cgroups: {unknown}')
526
928
  referenced = {}
527
929
  for workspace in L.load_registry()['workspaces']:
528
930
  for lane in workspace['lanes']:
@@ -566,32 +968,6 @@ def cmd_invariant(args):
566
968
  return 1 if (gone or changed_pid or (resized and sizes_matter)) else 0
567
969
 
568
970
 
569
- def cmd_verify_legacy(_args):
570
- registry = L.load_registry()
571
- containers = registry['containers']
572
- bad = 0
573
- for workspace in registry['workspaces']:
574
- info = containers.get(workspace['container']) or {}
575
- layout = Path(info.get('home', '')) / '.config/coder-launch/layouts' / f"{workspace['id']}.kdl"
576
- try:
577
- text = layout.read_text()
578
- except OSError:
579
- print(f"? {workspace['id']:<18} no deployed legacy layout")
580
- continue
581
- deployed = {}
582
- for match in re.finditer(r'tab name=("(?:[^"\\]|\\.)*")[^\n]*\{\s*pane [^\n]*coder-launch/connect"\s*\{\s*args ("(?:[^"\\]|\\.)*") ("(?:[^"\\]|\\.)*")', text):
583
- name, socket, session = (json.loads(g) for g in match.groups())
584
- deployed[name] = (socket, session)
585
- ours = {lane['name']: (lane['tmux']['socket'], lane['tmux']['session'])
586
- for lane in workspace['lanes'] if lane['holder'] == 'tmux'}
587
- diffs = [n for n in sorted(set(ours) | set(deployed)) if ours.get(n) != deployed.get(n)]
588
- bad += len(diffs)
589
- print(f"{'✓' if not diffs else '✗'} {workspace['id']:<18} registry={len(ours):>3} deployed={len(deployed):>3}")
590
- for name in diffs:
591
- print(f' {name!r}: registry={ours.get(name)} deployed={deployed.get(name)}')
592
- return 1 if bad else 0
593
-
594
-
595
971
  def active_workspace(registry, device):
596
972
  """The board the device's view is showing right now (its active zellij tab)."""
597
973
  out = zellij(['-s', device, 'action', 'list-tabs', '-j'], 15)[1]
@@ -834,7 +1210,15 @@ def cmd_gc(_args):
834
1210
  except OSError as error:
835
1211
  print(f'skip {log}: {error}')
836
1212
  status = L.read_json(L.STATE / 'status.json') or {}
837
- known = set(status.get('lanes', {}))
1213
+ # Every id statusd publishes. statusd already mirrors each id it lists under `unregistered` (tmux ghosts, legacy
1214
+ # shells) and `zmx` into `lanes` (statusd.observe/build_payload; pinned by test_statusd
1215
+ # Cycle.test_every_published_row_id_is_also_a_lanes_key), so `lanes` alone covers them today. The union is
1216
+ # 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).
1218
+ known = set(status.get('lanes') or {})
1219
+ for key in ('unregistered', 'zmx'):
1220
+ for entries in (status.get(key) or {}).values():
1221
+ known.update(e['id'] for e in entries or () if isinstance(e, dict) and e.get('id'))
838
1222
  for stale in (L.STATE / 'resolve.dynamic').glob('*'):
839
1223
  if stale.name not in known and time.time() - stale.stat().st_mtime > 3600:
840
1224
  stale.unlink()
@@ -891,12 +1275,142 @@ def cmd_sockets(args):
891
1275
  return 1 if guard.sockets(env, guard.container_names(env), repair=verb == 'repair') else 0
892
1276
 
893
1277
 
1278
+ def cmd_economy(args):
1279
+ if '--once' not in args:
1280
+ raise L.LanesError('usage: lanes economy --once [--dry-run] [--status-file F --sessions-file F] '
1281
+ '(the recurring poller is lanes-economy.service, opt-in)')
1282
+ return libexec_module('economy_poll').main(args)
1283
+
1284
+
894
1285
  def cmd_pressure(_args):
895
1286
  health = L.compute_health()
896
1287
  print('\n'.join(pressure_lines(health, L.read_json(L.STATE / 'containers.json') or {})))
897
1288
  return 0 if health.get('level') == 'ok' else 1
898
1289
 
899
1290
 
1291
+ # Past this many lanes on one container's tmux socket `default` the doctor warns: that is the server every plain
1292
+ # `tmux` typed in the container talks to, so one stray `tmux kill-server` (or a crash of that one server) ends them all.
1293
+ DEFAULT_SOCKET_LANES = 4
1294
+
1295
+
1296
+ def recipe_report(registry):
1297
+ """(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
1299
+ numeric session name, and crowded `default` sockets. Reads registry.json only: no tmux, no container."""
1300
+ counts, identity, total = dict.fromkeys(L.RECIPE_CLASSES, 0), 0, 0
1301
+ broken, numeric, on_default = [], [], {}
1302
+ for workspace in registry.get('workspaces') or []:
1303
+ for lane in workspace.get('lanes') or []:
1304
+ if lane.get('holder') != 'tmux':
1305
+ continue
1306
+ total += 1
1307
+ kind = L.classify_recipe(lane.get('create'))
1308
+ counts[kind] += 1
1309
+ identity += 1 if L.recipe_identity(lane.get('create')) else 0
1310
+ if kind in ('attach-only', 'null', 'retired-path'):
1311
+ broken.append(lane['id'])
1312
+ tmux = lane.get('tmux') or {}
1313
+ if str(tmux.get('session') or '').isdigit():
1314
+ numeric.append(lane['id'])
1315
+ if tmux.get('socket') == 'default':
1316
+ on_default.setdefault(lane.get('container') or '?', []).append(lane['id'])
1317
+
1318
+ def some(ids):
1319
+ return ', '.join(ids[:3]) + (' …' if len(ids) > 3 else '')
1320
+ lines = ['create recipes: ' + ' · '.join(f'{k} {counts[k]}' for k in L.RECIPE_CLASSES) + f' (of {total} tmux lanes)',
1321
+ f'lane identity: {identity} of {total} tmux lanes create with LANE_ID / LANE_WS and the lanes/<ws>/<id> trace '
1322
+ '(lanes cursor-sync --identity / --canonicalize add it)']
1323
+ warnings = []
1324
+ if broken:
1325
+ warnings.append(f'{len(broken)} lanes that `lane open` cannot recreate once they end (attach-only, no recipe, or '
1326
+ f'the retired coder-launch path): lanes cursor-sync --canonicalize — {some(broken)}')
1327
+ if numeric:
1328
+ warnings.append(f'{len(numeric)} lanes bound to a numeric tmux session name (tmux numbers unnamed sessions '
1329
+ f'itself, so a recreated one gets another number): {some(numeric)}')
1330
+ for container, ids in sorted(on_default.items()):
1331
+ if len(ids) > DEFAULT_SOCKET_LANES:
1332
+ warnings.append(f'{container}: {len(ids)} lanes on the tmux socket `default` (more than {DEFAULT_SOCKET_LANES}): '
1333
+ f'every plain `tmux` in the container talks to that one server — {some(ids)}')
1334
+ return lines, warnings
1335
+
1336
+
1337
+ def socket_report(containers, live_by_container):
1338
+ """Warnings for `lanes doctor`: tmux socket FILES in a container's /tmp/tmux-<uid> that no running tmux server
1339
+ (from the container's cgroup, `live_by_container()`, called only when some container has socket files) owns.
1340
+ REPORT ONLY: the host never unlinks a container's socket (RUNBOOK NEVER rule); `lane gc --sockets` in the
1341
+ container does, after tmux itself is refused on the file (ECONNREFUSED). A container whose servers cannot be read
1342
+ is said to be not checked, never concluded."""
1343
+ found = {}
1344
+ for name, info in sorted(containers.items()):
1345
+ if not (info or {}).get('pid'):
1346
+ continue
1347
+ directory = PROC / str(info['pid']) / 'root/tmp' / f'tmux-{L.CONTAINER_UID}'
1348
+ try:
1349
+ files = sorted(path.name for path in directory.iterdir() if path.is_socket())
1350
+ except OSError:
1351
+ continue
1352
+ if files:
1353
+ found[name] = files
1354
+ if not found:
1355
+ return []
1356
+ live = live_by_container({name: containers[name] for name in found}) or {}
1357
+ warnings = []
1358
+ for name, files in found.items():
1359
+ servers = live.get(name)
1360
+ if servers is None:
1361
+ warnings.append(f'{name}: {len(files)} tmux socket files; its tmux servers could not be read from its cgroup: '
1362
+ 'not checked')
1363
+ continue
1364
+ orphans = [f for f in files if f not in servers]
1365
+ if orphans:
1366
+ warnings.append(f'{name}: {len(orphans)} tmux socket files with no server found for them ({" ".join(orphans)}) '
1367
+ '— report only; to remove the ones tmux is refused on, in the container: ~/.lz/bin/lane gc --sockets')
1368
+ return warnings
1369
+
1370
+
1371
+ def cmd_device(args):
1372
+ """lanes device list | lanes device forget <name>: the devices/<name>.json files `lanes ensure` (lanes-ensure.timer)
1373
+ recreates a view session from, and `sync`/`upgrade-session` update. Forgetting removes that file only: a running
1374
+ view session is never ended here."""
1375
+ usage = 'usage: lanes device list | lanes device forget <name>'
1376
+ devices = L.STATE / 'devices'
1377
+ if args == ['list']:
1378
+ try:
1379
+ live = live_sessions()
1380
+ except L.LanesError:
1381
+ live = None
1382
+ for meta_file in sorted(devices.glob('*.json')):
1383
+ meta = L.read_json(meta_file) or {}
1384
+ kind = 'phone' if meta.get('mobile') else f"desktop, {int(meta.get('viewports') or 1)} viewports"
1385
+ running = 'unknown (zellij did not answer)' if live is None else (
1386
+ 'running' if meta_file.stem in live else 'not running')
1387
+ print(f'{meta_file.stem:<16} {kind:<24} {running}' + (f" base {meta['base']}" if meta.get('base') else ''))
1388
+ return 0
1389
+ if len(args) != 2 or args[0] != 'forget':
1390
+ raise L.LanesError(usage)
1391
+ device = device_name(args[1])
1392
+ meta_file = devices / f'{device}.json'
1393
+ if not meta_file.exists():
1394
+ raise L.LanesError(f'no device {device!r} is known (lanes device list)')
1395
+ meta = L.read_json(meta_file) or {}
1396
+ try:
1397
+ live = device in live_sessions()
1398
+ except L.LanesError:
1399
+ live = None
1400
+ meta_file.unlink()
1401
+ kind = 'phone' if meta.get('mobile') else f"desktop, {int(meta.get('viewports') or 1)} viewports"
1402
+ print(f'forgot {device} ({kind}): `lanes ensure` (lanes-ensure.timer) no longer recreates its view session, and '
1403
+ '`lanes sync` / `upgrade-session` no longer update it')
1404
+ if live:
1405
+ print(f'its view session {device} is still running and was left alone; once it ends, nothing brings it back')
1406
+ elif live is None:
1407
+ print(f'zellij did not answer: whether a view session {device} runs now is unknown (nothing was touched)')
1408
+ else:
1409
+ print(f'no view session {device} is running')
1410
+ print(f'`lanes attach {device}` or `lanes prepare {device}` creates it again')
1411
+ return 0
1412
+
1413
+
900
1414
  def cmd_doctor(_args):
901
1415
  problems = 0
902
1416
 
@@ -911,23 +1425,39 @@ def cmd_doctor(_args):
911
1425
  if registry:
912
1426
  errors = [(w['id'], e) for w in registry['workspaces'] for e in w['errors']]
913
1427
  check(not errors, 'registry clean', '; '.join(f'{w}: {e}' for w, e in errors))
914
- # Stale registry check: any terminals.json newer than the last sync?
915
- generated_at = registry.get('generated_at', 0)
1428
+ # Stale registry check: any terminals.json newer than the last sync? A lane's `source` is the container's
1429
+ # spelling; doctor runs on the host, so it stats the file under the container's home volume.
1430
+ sys.path.insert(0, str(L.REPO / 'libexec'))
1431
+ import statusd as SD
1432
+ # A sync that found nothing to rewrite keeps registry.json and its old `generated_at` (Task 17) and
1433
+ # records itself in registry.synced: the later of the two is when the registry was last known current.
1434
+ generated_at = max(registry.get('generated_at', 0), L.registry_synced_at())
1435
+ containers = L.read_json(L.STATE / 'containers.json') or {}
916
1436
  stale_sources = []
917
- for w in registry['workspaces']:
918
- for lane in w.get('lanes', []):
919
- src = lane.get('source')
920
- if src:
921
- try:
922
- if Path(src).stat().st_mtime > generated_at:
923
- stale_sources.append(src)
924
- except OSError:
925
- pass
926
- unique_stale = list(dict.fromkeys(stale_sources))
927
- check(not unique_stale, 'registry up to date',
928
- 'run lanes sync — ' + '; '.join(unique_stale[:3]) + (' …' if len(unique_stale) > 3 else ''))
929
- for name, info in (L.read_json(L.STATE / 'containers.json') or {}).items():
1437
+ for src, path in SD.registry_source_paths(registry, containers):
1438
+ try:
1439
+ if path.stat().st_mtime > generated_at:
1440
+ stale_sources.append(src)
1441
+ except OSError:
1442
+ pass
1443
+ check(not stale_sources, 'registry up to date',
1444
+ 'run lanes sync — ' + '; '.join(stale_sources[:3]) + (' …' if len(stale_sources) > 3 else ''))
1445
+ for name, info in containers.items():
930
1446
  check(bool(info.get('running')), f'container {name} running', f"init pid {info.get('pid')}")
1447
+ # Recipes, identity, session names, crowded `default` sockets, orphan socket files: warnings, never a ✗
1448
+ # (they describe the boards' declarations and the containers' /tmp, which doctor reports and never changes).
1449
+ lines, warnings = recipe_report(registry)
1450
+ for line in lines:
1451
+ print(f' {line}')
1452
+ for warning in warnings + socket_report(containers, SD.live_by_container):
1453
+ print(f'⚠ {warning}')
1454
+ # Plan Task 19 retired the pre-persistence Docker-shell tracker. A record still in its directory is inert (never
1455
+ # observed, never closable from the sidebar): name it, so the operator ends that shell by hand and removes it.
1456
+ legacy = sorted(p.stem for p in (L.STATE / 'legacy-sessions').glob('*.json'))
1457
+ if legacy:
1458
+ print(f'⚠ legacy-sessions/ holds {len(legacy)} record{"s" if len(legacy) != 1 else ""} the retired legacy-shell '
1459
+ f'tracker no longer reads: {", ".join(legacy[:5])}{" …" if len(legacy) > 5 else ""} — end those shells '
1460
+ 'in their containers by hand, then delete the records')
931
1461
  status = L.read_json(L.STATE / 'status.json') or {}
932
1462
  age = time.time() - status.get('updated_at', 0)
933
1463
  check(age < 30, 'statusd heartbeat', f'{age:.0f}s old')
@@ -963,10 +1493,15 @@ def cmd_doctor(_args):
963
1493
  zmx_lanes = sum(len(v) for v in (status.get('zmx') or {}).values())
964
1494
  installed = [link.name for link in sorted((Path.home() / '.lz').glob('*')) if (link / 'bin/lane').exists()]
965
1495
  print(f" lane contract installed in: {', '.join(installed) or 'no container yet'}; zmx-held lanes: {zmx_lanes}")
1496
+ print(f' preview renderer: {preview_renderer()}')
966
1497
  try:
967
1498
  print(' view sessions:', ', '.join(sorted(live_sessions())) or 'none')
968
1499
  except (OSError, L.LanesError) as error:
969
1500
  check(False, 'zellij reachable', str(error))
1501
+ stale_pickers = stale_picker_layouts()
1502
+ check(not stale_pickers, 'phone picker panes report their own title (DEF-1450)',
1503
+ 'built from a layout that names the picker pane, so the phone shows no counts; upgrade-session does not '
1504
+ 're-lay-out a tab — run ' + '; '.join(f'lanes rebuild {d}' for d in stale_pickers))
970
1505
  print('PRESSURE')
971
1506
  health = L.compute_health()
972
1507
  print('\n'.join(pressure_lines(health, L.read_json(L.STATE / 'containers.json') or {})))
@@ -980,7 +1515,8 @@ def cmd_rename(args):
980
1515
 
981
1516
  Rename a lane's display name.
982
1517
  - Unregistered sessions: writes user-sessions metadata + updates resolve.dynamic.
983
- - Registered lanes: writes a displayNames override to the workspace manifest and syncs the registry.
1518
+ - Registered sessions (adopted): writes the record's name in registered-sessions.json and publishes.
1519
+ - Repository lanes: writes a displayNames override to the workspace manifest and syncs the registry.
984
1520
  """
985
1521
  if len(args) < 2:
986
1522
  raise L.LanesError('usage: lanes rename <lane-id> <new-name>')
@@ -1008,21 +1544,263 @@ def cmd_rename(args):
1008
1544
  return 0
1009
1545
  if not lane:
1010
1546
  raise L.LanesError(f'Unknown lane: {lane_id}')
1011
- # Registered lane: update displayNames in manifest
1012
- source = lane.get('source')
1013
- if not source:
1014
- raise L.LanesError('No manifest source for this lane; set displayNames in the workspace manifest manually')
1547
+ # 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).
1549
+ if lane.get('registered_session'):
1550
+ import registrations
1551
+ registrations.rename(lane_id, new_name)
1552
+ print(f"renamed registered session {lane_id!r} → {new_name!r}")
1553
+ return 0
1554
+ # Registered lane: a displayNames override in the board's manifest (repositories[i]), never terminals.json
1555
+ # (the lane's `source`, which the reader does not consult for display names). The lane id does not change.
1556
+ if not lane.get('repo'):
1557
+ raise L.LanesError('No repository for this lane; set displayNames in the workspace manifest manually')
1015
1558
  registry_name = lane.get('registry_name') or lane.get('name', '')
1016
- SA.rename_registered(source, registry_name, new_name)
1559
+ SA.rename_registered(L.manifest_path(workspace_obj['id']), lane['repo'], registry_name, new_name)
1017
1560
  cmd_sync([]) # rebuild registry so all consumers see the new name
1018
1561
  print(f"renamed registered lane {lane_id!r} ({registry_name!r}) → {new_name!r}")
1019
1562
  return 0
1020
1563
 
1021
1564
 
1565
+ # ------------------------------------------------------------------ preview
1566
+ # Ctrl-Alt-m (zellij/config.kdl, spec §5.4) opens a floating pane running `lanes preview --focused`: the markdown file
1567
+ # the focused lane printed last, rendered by glow (else `less -R`). Read-only: two zellij listings, one guarded tmux
1568
+ # capture, file tests through /proc/<init>/root.
1569
+
1570
+ PREVIEW_LINES = 300
1571
+
1572
+
1573
+ def pinned_binary(name):
1574
+ """SHARE/bin/<name>-<version> as holders.lock pins it (install/host-install.sh installs it there), or None."""
1575
+ for line in (L.REPO / 'holders.lock').read_text().splitlines():
1576
+ fields = line.split()
1577
+ if len(fields) >= 4 and not line.startswith('#') and fields[0] == name:
1578
+ return L.SHARE / 'bin' / f'{name}-{fields[1]}'
1579
+ return None
1580
+
1581
+
1582
+ def glow_binary():
1583
+ """The pinned glow when installed, else a glow on the VIEW SERVER's PATH (clean_env, which the Run pane inherits;
1584
+ the installer skips the download only when that same PATH finds one), else None. Never the caller's PATH: a glow
1585
+ in ~/.local/bin or ~/go/bin is invisible to the pane, so doctor and the pane must agree on one lookup."""
1586
+ pinned = pinned_binary('glow')
1587
+ if pinned and os.access(pinned, os.X_OK):
1588
+ return str(pinned)
1589
+ return shutil.which('glow', path=clean_env()['PATH'])
1590
+
1591
+
1592
+ def preview_renderer():
1593
+ """What `lanes preview` renders with, as doctor reports it: pinned glow | system glow (<path>) | less -R."""
1594
+ glow = glow_binary()
1595
+ if glow and glow == str(pinned_binary('glow')):
1596
+ return f'pinned glow ({glow})'
1597
+ if glow:
1598
+ return f'system glow ({glow})'
1599
+ return 'less -R (no glow on the view server PATH; ./install.sh --server installs the pinned one)'
1600
+
1601
+
1602
+ def focused_view(device):
1603
+ """(view device, workspace, slot) behind the focused pane of `device`'s active tab. The floating preview pane holds
1604
+ the focus while this runs, so the TILED view pane zellij marks focused decides; a focused sidebar (or none
1605
+ marked) means the tab's first viewport. A tab without a board (Host, tools) is said, never guessed."""
1606
+ import shlex
1607
+ listings = []
1608
+ for action in ('list-tabs', 'list-panes'):
1609
+ rc, out, err = zellij(['-s', device, 'action', action, '-j'] + (['-a'] if action == 'list-panes' else []), 10)
1610
+ if rc != 0:
1611
+ raise L.LanesError(f'zellij did not answer {action} for {device}: {err.strip() or "no answer"}')
1612
+ try:
1613
+ listings.append(json.loads(out))
1614
+ except ValueError:
1615
+ raise L.LanesError(f'zellij answered {action} for {device} with something that is not JSON')
1616
+ tabs, panes = listings
1617
+ active = next((t.get('tab_id') for t in tabs if isinstance(t, dict) and t.get('active')), None)
1618
+ view_sh, sidebar = str(L.REPO / 'libexec/view.sh'), str(L.REPO / 'libexec/sidebar')
1619
+ views, boards = [], []
1620
+ for pane in panes:
1621
+ if not isinstance(pane, dict) or pane.get('is_plugin') or pane.get('is_floating') or pane.get('tab_id') != active:
1622
+ continue
1623
+ try:
1624
+ args = shlex.split(pane.get('terminal_command') or pane.get('pane_command') or '')
1625
+ except ValueError:
1626
+ continue
1627
+ for script in (view_sh, sidebar):
1628
+ tail = args[args.index(script) + 1:] if script in args else []
1629
+ if len(tail) < 2 or not all(L.IDENT.match(part) for part in tail[:2]):
1630
+ continue
1631
+ if script == view_sh:
1632
+ slot = tail[2] if len(tail) > 2 and tail[2] in SLOTS else 'main'
1633
+ views.append((not pane.get('is_focused'), SLOTS.index(slot), (tail[0], tail[1], slot)))
1634
+ else:
1635
+ boards.append((tail[0], tail[1], 'main'))
1636
+ if views:
1637
+ return min(views)[2]
1638
+ if boards:
1639
+ return boards[0]
1640
+ raise L.LanesError('the focused tab shows no board (Host or a tool tab): nothing to preview')
1641
+
1642
+
1643
+ def lane_shown(view_device, workspace, slot):
1644
+ """The lane id a viewport shows: its target file, else (first viewport only) the board's default lane."""
1645
+ for path in (L.STATE / 'views' / view_device / f'{workspace}.{slot}.target',
1646
+ L.STATE / 'defaults' / workspace if slot == 'main' else None):
1647
+ try:
1648
+ lane_id = path.read_text().split('\n')[0].strip() if path else ''
1649
+ except OSError:
1650
+ lane_id = ''
1651
+ if lane_id:
1652
+ return lane_id
1653
+ raise L.LanesError(f'no lane is shown in {workspace} ({slot})')
1654
+
1655
+
1656
+ def lane_scrollback(info, lane):
1657
+ """The last PREVIEW_LINES lines of a tmux lane, wrapped lines joined — through lanes_lib.tmux_probe (-N, scrubbed
1658
+ env, the socket under /proc/<init>/root) with holders/tmux.sh's guards: the `=<session>:` target, and the session
1659
+ that ANSWERS must be the one asked for (an object-id name like `$0` resolves another session, DEF-968)."""
1660
+ socket, session = lane['tmux']['socket'], lane['tmux']['session']
1661
+ target = f'={session}:'
1662
+ outcome, answer = L.tmux_probe(info, socket, ['display-message', '-p', '-t', target, '#{session_name}'])
1663
+ if outcome != 'ok' or answer.rstrip('\n') != session:
1664
+ raise L.LanesError(f"lane {lane['name']} did not answer ({outcome})")
1665
+ outcome, text = L.tmux_probe(info, socket, ['capture-pane', '-p', '-J', '-S', f'-{PREVIEW_LINES}', '-t', target])
1666
+ if outcome != 'ok':
1667
+ raise L.LanesError(f"lane {lane['name']}: no scrollback ({outcome})")
1668
+ return text
1669
+
1670
+
1671
+ def preview_argv(path, device):
1672
+ """(argv, env) that shows `path`: glow in the device's theme (`STATE/clients/<device>.theme`, written by the
1673
+ sidebar from the OSC 11 answer; dark when absent), else `less -R`, else None (print it). glow 3.0.0 ignores
1674
+ GLAMOUR_STYLE (measured 2026-10-04: dark and light rendered identical bytes), so `-s` carries the style too."""
1675
+ try:
1676
+ style = (L.STATE / 'clients' / f'{device}.theme').read_text().strip()
1677
+ except OSError:
1678
+ style = ''
1679
+ style = style if style in ('dark', 'light') else 'dark'
1680
+ env = dict(os.environ, GLAMOUR_STYLE=style)
1681
+ glow = glow_binary()
1682
+ if glow:
1683
+ return [glow, '-s', style, '-p', path], env
1684
+ less = shutil.which('less')
1685
+ return ([less, '-R', path] if less else None), env
1686
+
1687
+
1688
+ def preview_choose(paths):
1689
+ """One path: it. Several: a numbered menu, newest first; Enter = 1, q (or anything unusable) = None."""
1690
+ if len(paths) == 1:
1691
+ return paths[0]
1692
+ newest = list(reversed(paths))
1693
+ print('Markdown files this lane printed (newest first):')
1694
+ for number, path in enumerate(newest, 1):
1695
+ print(f' {number} {path}')
1696
+ print('Number [1], q to close: ', end='', flush=True)
1697
+ answer = sys.stdin.readline().strip()
1698
+ if not answer:
1699
+ return newest[0]
1700
+ return newest[int(answer) - 1] if answer.isdigit() and 1 <= int(answer) <= len(newest) else None
1701
+
1702
+
1703
+ def preview_hold():
1704
+ """close_on_exit closes the pane the moment this exits: let a message be read first (only on a terminal)."""
1705
+ if sys.stdin.isatty():
1706
+ print('\nPress Enter to close.', end='', flush=True)
1707
+ sys.stdin.readline()
1708
+
1709
+
1710
+ def cmd_preview(args):
1711
+ if '--focused' not in args:
1712
+ print('usage: lanes preview --focused [--device D] (the Ctrl-Alt-m floating pane runs this)')
1713
+ return 2
1714
+ device = args[args.index('--device') + 1] if '--device' in args else os.environ.get('ZELLIJ_SESSION_NAME', '')
1715
+ try:
1716
+ device = device_name(device)
1717
+ view_device, workspace, slot = focused_view(device)
1718
+ registry = L.load_registry()
1719
+ board, lane = L.find_lane(registry, lane_shown(view_device, workspace, slot))
1720
+ if lane.get('holder') != 'tmux':
1721
+ raise L.LanesError(f"{lane['name']} is a {lane.get('holder')} lane: preview reads tmux lanes only")
1722
+ info = (L.read_json(L.STATE / 'containers.json') or {}).get(lane['container']) or {}
1723
+ text = lane_scrollback(info, lane)
1724
+ status = ((L.read_json(L.STATE / 'status.json') or {}).get('lanes') or {}).get(lane['id']) or {}
1725
+ cwd = status.get('cwd') or lane.get('folder') or board.get('cwd') or ''
1726
+ root = PROC / str(info.get('pid') or 0) / 'root'
1727
+ found = [p for p in L.markdown_candidates(text, cwd) if info.get('pid') and (root / p.lstrip('/')).is_file()]
1728
+ except L.LanesError as error:
1729
+ print(f'lanes preview: {error}')
1730
+ preview_hold()
1731
+ return 1
1732
+ if not found:
1733
+ print(f"No markdown file named in the last {PREVIEW_LINES} lines of {lane['name']} ({cwd or 'no cwd'}).")
1734
+ preview_hold()
1735
+ return 0
1736
+ chosen = preview_choose(found)
1737
+ if not chosen:
1738
+ return 0
1739
+ host = str(root / chosen.lstrip('/'))
1740
+ argv, env = preview_argv(host, view_device)
1741
+ if argv:
1742
+ os.execve(argv[0], argv, env)
1743
+ return 0 # only reached when execve is a test double
1744
+ sys.stdout.write(Path(host).read_text(errors='replace'))
1745
+ preview_hold()
1746
+ return 0
1747
+
1748
+
1749
+ # ------------------------------------------------------------------ next / prev
1750
+ # Ctrl-Alt-Down / Ctrl-Alt-Up (zellij/config.kdl, plan Task 19) run `lanes next|prev --focused` in a floating pane that
1751
+ # closes when it exits. They used to walk the focus left three times and type J/K into whatever pane that reached
1752
+ # (the sidebar, or — on a tab without one — the lane itself). Now nothing is typed and no focus moves: the viewport
1753
+ # zellij marks focused is pointed at the neighbouring lane exactly as the sidebar's open_lane points it
1754
+ # (lanes_lib.show_lane), and a lane with an agent is marked seen on the device as open_lane marks it.
1755
+
1756
+
1757
+ def step_lane(args, step):
1758
+ verb = 'next' if step > 0 else 'prev'
1759
+ if '--focused' not in args:
1760
+ print(f'usage: lanes {verb} --focused [--device D] (Ctrl-Alt-{"Down" if step > 0 else "Up"} runs this)')
1761
+ return 2
1762
+ device = args[args.index('--device') + 1] if '--device' in args else os.environ.get('ZELLIJ_SESSION_NAME', '')
1763
+ try:
1764
+ device = device_name(device)
1765
+ view_device, workspace, slot = focused_view(device)
1766
+ registry = L.load_registry()
1767
+ board = next((w for w in registry['workspaces'] if w['id'] == workspace), None)
1768
+ ids = [lane['id'] for lane in (board or {}).get('lanes', [])]
1769
+ if not ids:
1770
+ raise L.LanesError(f'{workspace} has no lanes to step through')
1771
+ try:
1772
+ shown = lane_shown(view_device, workspace, slot)
1773
+ except L.LanesError:
1774
+ shown = ''
1775
+ # A lane outside the list (an unregistered session, nothing shown yet): next starts at the first, prev at
1776
+ # the last.
1777
+ index = ids.index(shown) if shown in ids else (-1 if step > 0 else len(ids))
1778
+ lane_id = ids[(index + step) % len(ids)]
1779
+ except L.LanesError as error:
1780
+ print(f'lanes {verb}: {error}')
1781
+ return 1
1782
+ status = (L.read_json(L.STATE / 'status.json') or {}).get('lanes') or {}
1783
+ agent = (status.get(lane_id) or {}).get('agent') if isinstance(status, dict) else None
1784
+ if isinstance(agent, dict) and agent:
1785
+ L.record_seen(L.STATE / 'clients' / f'{view_device}.seen.json', lane_id, agent.get('since'), set(status))
1786
+ if not L.show_lane(str(L.STATE / 'views' / view_device), workspace, slot, lane_id):
1787
+ print('view not running yet')
1788
+ return 0
1789
+
1790
+
1791
+ def cmd_next(args):
1792
+ return step_lane(args, +1)
1793
+
1794
+
1795
+ def cmd_prev(args):
1796
+ return step_lane(args, -1)
1797
+
1798
+
1022
1799
  COMMANDS = {'rebuilt': cmd_rebuilt, 'hello': cmd_hello, 'client-config': cmd_client_config, 'cursor-commit': cmd_cursor_commit, 'upgrade-session': cmd_upgrade_session, 'upload-requests': cmd_upload_requests, 'upload-result': cmd_upload_result, 'upload': cmd_upload, 'workspaces': cmd_workspaces, 'cursor-sync': cmd_cursor_sync, 'reattach': cmd_reattach, 'gc': cmd_gc, 'install-container': cmd_install_container, 'open': cmd_open, 'sync': cmd_sync, 'ls': cmd_ls, 'resolve': cmd_resolve, 'status': cmd_status, 'layout': cmd_layout,
1023
- 'prepare': cmd_prepare, 'attach': cmd_attach, 'rebuild': cmd_rebuild, 'ensure': cmd_ensure, 'invariant': cmd_invariant,
1024
- 'verify-legacy': cmd_verify_legacy, 'doctor': cmd_doctor, 'pressure': cmd_pressure, 'sockets': cmd_sockets, 'tmp': cmd_tmp,
1025
- 'guard': cmd_guard, 'rename': cmd_rename}
1800
+ 'prepare': cmd_prepare, 'web': cmd_web, 'attach': cmd_attach, 'rebuild': cmd_rebuild, 'ensure': cmd_ensure, 'invariant': cmd_invariant,
1801
+ 'doctor': cmd_doctor, 'pressure': cmd_pressure, 'sockets': cmd_sockets, 'tmp': cmd_tmp,
1802
+ '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}
1026
1804
 
1027
1805
  if __name__ == '__main__':
1028
1806
  if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: