@vibelord/lanes 1.0.0 → 1.0.1

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 (34) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +56 -1
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +281 -104
  5. package/payload/lanes/client/lanes-launch +44 -2
  6. package/payload/lanes/container/env.sh +8 -0
  7. package/payload/lanes/container/lane +19 -2
  8. package/payload/lanes/docs/ARCHITECTURE.md +86 -1
  9. package/payload/lanes/docs/DESIGN.md +72 -0
  10. package/payload/lanes/docs/INSTALL.md +39 -0
  11. package/payload/lanes/docs/RUNBOOK.md +94 -1
  12. package/payload/lanes/holders/ssh.sh +18 -0
  13. package/payload/lanes/install/enable-phone.sh +15 -6
  14. package/payload/lanes/install/enable-web.sh +19 -6
  15. package/payload/lanes/libexec/agent_state.py +214 -0
  16. package/payload/lanes/libexec/connection.py +178 -0
  17. package/payload/lanes/libexec/cursor_sync.py +139 -6
  18. package/payload/lanes/libexec/device_sync.py +134 -0
  19. package/payload/lanes/libexec/lanes_lib.py +158 -5
  20. package/payload/lanes/libexec/registrations.py +148 -0
  21. package/payload/lanes/libexec/session_actions.py +47 -15
  22. package/payload/lanes/libexec/sidebar.py +372 -61
  23. package/payload/lanes/libexec/statusd.py +126 -33
  24. package/payload/lanes/libexec/tokens.py +279 -0
  25. package/payload/lanes/libexec/tokens.sh +11 -0
  26. package/payload/lanes/libexec/view.sh +9 -8
  27. package/payload/lanes/web/README.md +15 -0
  28. package/payload/lanes/web/browser.css +22 -0
  29. package/payload/lanes/web/browser.js +73 -0
  30. package/payload/lanes/web/mobile.css +65 -33
  31. package/payload/lanes/web/mobile.js +23 -5
  32. package/payload/lanes/workspaces.example/README.md +9 -3
  33. package/payload/lanes/zellij/config.kdl +61 -3
  34. package/payload/manifest.json +6 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibelord/lanes",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
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",
@@ -51,6 +51,12 @@ profile). Ghostty is recommended for a separate window; `lanes-launch` works in
51
51
  The installer uses an existing SSH key or creates a dedicated one. An administrator authorizes its **public**
52
52
  key on the server. Re-run `./install.sh --check` after authorization. Your selected site is remembered.
53
53
 
54
+ When an ended view was rebuilt on the server, the client probes that state and reconnects
55
+ automatically. The probe's watchdog cancels and reaps its timer when the probe completes;
56
+ a hung probe still times out. This avoids waiting for the watchdog's full ten seconds
57
+ after a successful response. Existing installations receive the fix by re-running the
58
+ client installer after updating their checkout.
59
+
54
60
  **Setting up a server:** on a Linux x86_64 Docker host, as the workspace account:
55
61
 
56
62
  ```sh
@@ -75,9 +81,13 @@ The installer starts an empty catalog safely; it does not assume anyone's contai
75
81
  | Select a lane for pane 2 or 3 | `2` / `3` in the sidebar of a wide view |
76
82
  | Open one lane directly | `lanes-launch lane <lane-id>`; find IDs with `lanes-launch ls` |
77
83
  | Create a persistent workspace shell | **New +** under **Unregistered**, or `+` in the sidebar |
78
- | End an unregistered session and its jobs | **×** beside the session, or select it and press `x` |
84
+ | Register an existing session | **+** beside an unregistered session, or select it and press `a`; choose a workspace |
85
+ | See which agents need you | The sidebar sorts **Needs you** (◆ approval, ✗ error, ◇ waiting for a prompt), **Working** (▸) and **Done** (✓, until you open the lane) above the rest; the header counts them (`◆1 ◇2 ▸6 ✓1`) and every row shows how long (agent turn, else last output) |
86
+ | End an unregistered session and its jobs | **×** beside the session, or select it and press `x`; confirm with `y` (any other key cancels) |
79
87
  | Upload | Drop onto the sidebar upload box, use `~/Lanes Uploads/<workspace>/`, or `lanes-upload <files>` |
80
88
  | Phone setup from a client | `sh install/enable-phone.sh --host lanes` |
89
+ | Laptop or tablet browser view | `lanes web <device> --desktop [--token]` on the server, or `sh install/enable-phone.sh --host lanes --desktop <device>` ([guide](docs/INSTALL.md#laptop-and-tablet-browser)) |
90
+ | Switch workspace or lane in a laptop browser | The **Lanes** chip (top right) opens the workspace and lane pickers |
81
91
  | Detach | Close the window; the agents keep running |
82
92
 
83
93
  Unregistered sessions also appear when they use shared or previously unlisted tmux servers. Reopening a
@@ -85,6 +95,10 @@ session reuses it; closing a view leaves it running. Registered lanes have no **
85
95
  Docker shells labelled **Legacy** are tracked and closable, but a lost Docker connection cannot be reopened;
86
96
  new shells support reconnection. See the [runbook](docs/RUNBOOK.md) for the transition details.
87
97
 
98
+ **Add to workspace** keeps the session running and moves it into the selected workspace's registered list.
99
+ The picker offers workspaces using the same container. Registration keeps its name and open views, persists
100
+ across sync/reconnects, and removes its close control. On a phone, tap the **+** and a workspace; no typing is needed.
101
+
88
102
  On iPhone, open the printed private HTTPS URL in Safari and choose **Share → Add to Home Screen**.
89
103
  The interface provides Workspaces, Lanes and Panes controls, safe areas, portrait/landscape layout and adjustable
90
104
  terminal text. The keyboard opens when you tap **Type here** or another input; swipes and updates leave it closed.
@@ -119,6 +133,8 @@ It retains the previous lanes if a repository's registry is temporarily invalid.
119
133
  `lanes cursor-sync --diff` previews editor integration. `lanes cursor-sync --write` backs up and normalizes
120
134
  terminal commands to `lane open <id>`, retaining each original create recipe. Cursor and Lanes then attach
121
135
  to the same terminal. Registry sync does not copy repositories between machines or automatically create agents.
136
+ Sessions added through the picker are saved in the site's `registered-sessions.json` and included by sync.
137
+ These registrations attach to existing sessions; they do not invent a restart command or edit editor terminal lists.
122
138
  See [workspace configuration](workspaces.example/README.md) and the [architecture](docs/ARCHITECTURE.md).
123
139
 
124
140
  ## What is included
@@ -150,3 +166,42 @@ python3 -m unittest discover -s tests/unit # no Docker or live server needed
150
166
 
151
167
  For integration tests and browser verification, see [tests/README.md](tests/README.md).
152
168
  For recovery, service updates and troubleshooting, see the [runbook](docs/RUNBOOK.md).
169
+
170
+ ### Register a workspace once
171
+
172
+ The host's site catalog (`LANES_WORKSPACES/catalog.json`, normally
173
+ `~/.config/lanes/workspaces/catalog.json`) is the workspace authority. Add its
174
+ manifest there and run `lanes sync`. The existing sync timer refreshes it every
175
+ 2 minutes; the ensure timer also reconciles known device sessions every minute.
176
+ New desktop and phone tabs appear without rebuilding sessions or restarting
177
+ agents. Changing a catalog title renames the existing tab using its workspace ID.
178
+ Phone workspaces receive both their full-width picker and terminal tabs.
179
+ When a device has no attached client, new tabs wait for its next connection and
180
+ the next ensure tick (within a minute). This avoids empty background tabs in
181
+ Zellij 0.45.1; existing workspace names can still update while disconnected.
182
+
183
+ Fixed terminals on another host can use an optional `sshWorkspaces` entry:
184
+
185
+ ```json
186
+ {
187
+ "id": "maintenance",
188
+ "title": "Maintenance",
189
+ "host": "maintenance-views",
190
+ "command": "/home/operator/.local/bin/workspace-session",
191
+ "layout": "lanes",
192
+ "position": "workspace",
193
+ "panes": [{"id": "fixes", "name": "Fixes"}]
194
+ }
195
+ ```
196
+
197
+ Put this object in the catalog's `sshWorkspaces` array. Configure the host SSH
198
+ alias with its dedicated key and pinned host key. The command must attach to an
199
+ existing persistent session. Lanes does not provision or terminate remote agents.
200
+ `layout: "lanes"` uses the standard sidebar; `position: "before-host"` places a
201
+ control workspace immediately before Host. Existing `split` layouts stay supported.
202
+ Split workspaces can share an SSH host and helper when their pane IDs are distinct.
203
+ An endpoint assigned to multiple split workspaces is ambiguous and refuses sync.
204
+
205
+ While a viewer connects or reconnects, it shows a Syncing indicator and discards
206
+ pending input. Mouse reporting is reset before and after the attach client, so
207
+ queued escape sequences cannot become shell input during the connection wait.
@@ -1 +1 @@
1
- 1.0.0
1
+ 1.0.1
@@ -1,17 +1,25 @@
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
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)
10
+ lanes prepare <device> [--mobile|--desktop]
11
+ create a device view without attaching a terminal (eg. Safari on a phone); an explicit
12
+ layout that differs from the running view is refused, never rebuilt
13
+ lanes web <device> [--desktop|--mobile] [--viewports N] [--token | --print-only]
14
+ browser view in one step: prepare (desktop by default), enable the loopback web
15
+ services (install/enable-web.sh), print the private https://<tailnet name>:8443/<device>
16
+ URL; --token then shows a login token in YOUR terminal only
11
17
  lanes open <lane-id> attach this terminal straight to one lane (no multiplexer in between)
18
+ lanes register <lane-id> [ws] add an unregistered session to a workspace without restarting it
19
+ use lanes ls --unregistered [--json] to find its ID
12
20
  lanes layout <device> [--mobile] print the generated zellij layout
13
21
  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
22
+ lanes upgrade-session [device…] sync catalog tabs and labels to live sessions without a rebuild
15
23
  lanes ensure recreate any known device session that is missing (never when zellij did not answer:
16
24
  an unknown session list is not an empty one)
17
25
  lanes reattach make every view re-attach its lane (after changing flags); agents untouched
@@ -19,9 +27,14 @@
19
27
  read a tar stream on stdin into that board's uploads/ folder (the same folder Cursor
20
28
  users drop files into); prints the in-container paths. --paste also types the path(s)
21
29
  into the lane that device is looking at. `lanes workspaces` lists board ids.
22
- lanes cursor-sync [ws…] [--write] [--diff]
30
+ lanes cursor-sync [ws…] [--write] [--diff] [--canonicalize] [--identity]
23
31
  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)
32
+ (dry-run by default: prints the git-style diff; --write applies with a backup).
33
+ --canonicalize also replaces a create recipe that cannot create its tmux lane (null,
34
+ attach-only, missing script) with the canonical one whose `tmux new -e` flags give the
35
+ lane LANE_ID/LANE_WS and the lanes/<ws>/<id> trace; --identity adds those flags to a
36
+ working plain `tmux -L S new -ADs N [-c D] CMD` recipe, keeping CMD. The old recipe is
37
+ kept as createPrevious. Only these -e flags give a lane its identity (DEF-1422)
25
38
  lanes cursor-commit [ws…] [--push]
26
39
  commit the normalized terminals.json on top of each repo's REMOTE branch tip and push it,
27
40
  with git plumbing only (no working tree or index is touched). Dry-run without --push.
@@ -83,16 +96,10 @@ def q(text):
83
96
  # ------------------------------------------------------------------- registry
84
97
 
85
98
  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')
99
+ with L.registry_lock():
100
+ previous = L.read_json(L.STATE / 'registry.json')
101
+ registry = L.build_registry(previous)
102
+ L.publish_registry(registry)
96
103
  total = sum(len(w['lanes']) for w in registry['workspaces'])
97
104
  failed = [w for w in registry['workspaces'] if w['errors']]
98
105
  for workspace in registry['workspaces']:
@@ -101,18 +108,37 @@ def cmd_sync(_args):
101
108
  for error in workspace['errors']:
102
109
  print(f' ⚠ {error}' + (' (kept previous lanes)' if workspace.get('stale') else ''))
103
110
  print(f"{total} lanes in {len(registry['workspaces'])} workspaces" + (f'; {len(failed)} with errors' if failed else ''))
111
+ if any((L.STATE / 'devices').glob('*.json')):
112
+ cmd_upgrade_session([])
104
113
  return 1 if failed else 0
105
114
 
106
115
 
116
+ def cmd_register(args):
117
+ if not 1 <= len(args) <= 2:
118
+ raise L.LanesError('Usage: lanes register <lane-id> [workspace]')
119
+ import registrations
120
+ workspace, entry = L.find_lane(L.load_registry(), args[0])
121
+ lane = registrations.register(workspace['id'], entry, args[1] if len(args) == 2 else None)
122
+ print(f"Registered {lane['name']} in {lane['workspace']}")
123
+
124
+
107
125
  def cmd_ls(args):
108
126
  registry = L.load_registry()
109
- status = (L.read_json(L.STATE / 'status.json') or {}).get('lanes', {})
127
+ snapshot = L.read_json(L.STATE / 'status.json') or {}
128
+ status = snapshot.get('lanes', {})
129
+ import registrations
130
+ registered = {registrations.key(lane) for w in registry['workspaces'] for lane in w['lanes']
131
+ if lane.get('holder') == 'tmux'}
110
132
  wanted = [a for a in args if not a.startswith('-')]
111
133
  rows = []
112
134
  for workspace in registry['workspaces']:
113
135
  if wanted and workspace['id'] not in wanted:
114
136
  continue
115
- for lane in workspace['lanes']:
137
+ entries = ((snapshot.get('unregistered') or {}).get(workspace['id'], [])
138
+ if '--unregistered' in args else workspace['lanes'])
139
+ for lane in entries:
140
+ if '--unregistered' in args and registrations.eligible(lane) and registrations.key(lane) in registered:
141
+ continue
116
142
  rows.append(dict(lane, workspace=workspace['id'], status=status.get(lane['id'], {'state': 'unknown'})))
117
143
  if '--json' in args:
118
144
  print(json.dumps(rows, indent=1, ensure_ascii=False))
@@ -154,47 +180,97 @@ def cmd_status(_args):
154
180
 
155
181
  # --------------------------------------------------------------------- views
156
182
 
183
+ def tab_layout(lines):
184
+ return '\n'.join(['layout {', ' default_tab_template {', ' children',
185
+ ' pane size=1 borderless=true {', ' plugin location="zellij:compact-bar"',
186
+ ' }', ' }'] + lines + ['}']) + '\n'
187
+
188
+
189
+ def tab_specs(device, registry, mobile=False, viewports=1):
190
+ result = []
191
+ def add(key, title, lines):
192
+ result.append({'key': key, 'title': title, 'lines': lines, 'layout': tab_layout(lines)})
193
+ def board(workspace):
194
+ if mobile:
195
+ for picker in (True, False):
196
+ role = 'picker' if picker else 'view'
197
+ title = workspace['title'] + (' ☰' if picker else '')
198
+ add(f"workspace/{workspace['id']}/{role}", title, mobile_workspace_tab(device, workspace, picker))
199
+ else:
200
+ add(f"workspace/{workspace['id']}/view", workspace['title'], workspace_tab_kdl(device, workspace, False, viewports))
201
+ for workspace in registry['workspaces']:
202
+ if workspace.get('position') != 'before-host': board(workspace)
203
+ for workspace in registry.get('ssh_workspaces', []):
204
+ add('ssh/' + workspace['id'], workspace['title'], ssh_workspace_tab_kdl(workspace))
205
+ for tool in registry.get('tools', []):
206
+ add('tool/' + tool['view'], tool['tab'], tool_tab_kdl(device, tool, mobile=mobile))
207
+ for workspace in registry['workspaces']:
208
+ if workspace.get('position') == 'before-host': board(workspace)
209
+ add('host', 'Host', [' tab name="Host" {', ' pane command="/bin/bash" {', ' args "-l"', ' }', ' }'])
210
+ return result
211
+
212
+
157
213
  def layout_kdl(device, mobile=False, viewports=1):
158
- registry = L.load_registry()
214
+ specs = tab_specs(device, L.load_registry(), mobile, viewports)
215
+ lines = []
216
+ for index, spec in enumerate(specs):
217
+ tab = list(spec['lines'])
218
+ if index == 0:
219
+ tab[0] = tab[0].replace(' {', ' focus=true {', 1)
220
+ lines += tab
221
+ return tab_layout(lines)
222
+
223
+
224
+ def mobile_workspace_tab(device, workspace, picker):
225
+ title = workspace['title'] + (' ☰' if picker else '')
226
+ command = str(L.REPO / 'libexec' / ('sidebar' if picker else 'view.sh'))
227
+ args = f'{q(device)} {q(workspace["id"])}' + ('' if picker else ' "main" "--mobile"')
228
+ return [f' tab name={q(title)} {{',
229
+ f' pane borderless=true name={q("Lanes" if picker else workspace["title"])} command={q(command)} {{',
230
+ f' args {args}', ' }', ' }']
231
+
232
+
233
+ def workspace_tab_kdl(device, workspace, mobile=False, viewports=1, focused=False):
159
234
  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 ''
171
- 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
- ' }', ' }']
180
- 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))}',
235
+ focus = ' focus=true' if focused else ''
236
+ lines = []
237
+ if mobile:
238
+ return mobile_workspace_tab(device, workspace, True) + mobile_workspace_tab(device, workspace, False)
239
+ lines += [f' tab name={q(workspace["title"])}{focus} {{',
240
+ ' pane split_direction="vertical" {',
241
+ f' pane size=36 borderless=true name="Lanes" command={q(sidebar)} {{',
242
+ f' args {q(device)} {q(workspace["id"])} "main" {q(str(viewports))}',
243
+ ' }']
244
+ # Wide devices: several viewports side by side, each with its own target (slots main, b, c).
245
+ for index, slot in enumerate(SLOTS[:viewports]):
246
+ title = workspace['title'] if index == 0 else f"{workspace['title']} ·{index + 1}"
247
+ pane_focus = ' focus=true' if index == 0 else ''
248
+ lines += [f' pane borderless={"true" if viewports == 1 else "false"}{pane_focus} name={q(title)} command={q(view)} {{',
249
+ f' args {q(device)} {q(workspace["id"])} {q(slot)}',
185
250
  ' }']
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'
251
+ lines += [' }', ' }']
252
+ return lines
253
+
254
+
255
+ def ssh_workspace_tab_kdl(workspace, indent=' '):
256
+ """Main terminal on the left; up to two worker terminals stacked on the right."""
257
+ holder = str(L.REPO / 'holders/ssh.sh')
258
+ panes = workspace['panes']
259
+ lines = [f'{indent}tab name={q(workspace["title"])} split_direction="vertical" {{']
260
+
261
+ def pane_lines(pane, prefix, size=''):
262
+ return [f'{prefix}pane name={q(pane["name"])}{size} command={q(holder)} {{',
263
+ f'{prefix} args {q(workspace["host"])} {q(workspace["command"])} {q(pane["id"])}',
264
+ f'{prefix}}}']
265
+
266
+ lines += pane_lines(panes[0], indent + ' ', ' size="60%"' if len(panes) > 1 else '')
267
+ if len(panes) > 1:
268
+ lines.append(f'{indent} pane split_direction="horizontal" {{')
269
+ for pane in panes[1:]:
270
+ lines += pane_lines(pane, indent + ' ')
271
+ lines.append(f'{indent} }}')
272
+ lines.append(f'{indent}}}')
273
+ return lines
198
274
 
199
275
 
200
276
  def tool_tab_kdl(device, tool, indent=' ', mobile=False):
@@ -294,7 +370,11 @@ def cmd_attach(args):
294
370
  tab = None
295
371
  default = args[args.index('--default-device') + 1] if '--default-device' in args[:-1] else None
296
372
  try:
297
- workspace = next((w for w in L.load_registry()['workspaces'] if w['id'] == device), None)
373
+ registry = L.load_registry()
374
+ boards = registry['workspaces'] + registry.get('ssh_workspaces', [])
375
+ workspace = next((w for w in boards if w['id'] == device), None)
376
+ if device == default or (not default and (L.STATE / 'devices' / f'{device}.json').exists()):
377
+ workspace = None
298
378
  except L.LanesError:
299
379
  workspace = None
300
380
  if workspace:
@@ -371,7 +451,7 @@ def cmd_open(args):
371
451
  if not resolve.exists():
372
452
  raise L.LanesError(f'Unknown lane: {lane_id}')
373
453
  holder, a, b, c = resolve.read_text().split('\n')[:4]
374
- if holder not in ('tmux', 'zmx', 'exec'):
454
+ if holder not in ('tmux', 'zmx', 'exec', 'ssh'):
375
455
  raise L.LanesError(f'Unknown holder: {holder}')
376
456
  env = clean_env({'TERM': 'xterm-256color', 'LANES_WS': lane_id.split('--')[0]})
377
457
  os.execve(str(L.REPO / 'holders' / f'{holder}.sh'), [f'{holder}.sh', a, b, c], env)
@@ -441,31 +521,23 @@ def cmd_rebuild(args):
441
521
 
442
522
 
443
523
  def cmd_upgrade_session(args):
524
+ import device_sync
444
525
  registry = L.load_registry()
445
526
  live = live_sessions()
446
527
  devices = [device_name(a) for a in args] or [p.stem for p in sorted((L.STATE / 'devices').glob('*.json'))]
528
+ failures = []
447
529
  for device in devices:
448
530
  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']}'")
531
+ continue # ensure recreates missing sessions with the same catalog
532
+ meta = L.read_json(L.STATE / 'devices' / (device + '.json')) or {}
533
+ mobile, viewports = bool(meta.get('mobile')), int(meta.get('viewports') or 1)
534
+ try:
535
+ for change in device_sync.reconcile(device, registry, tab_specs(device, registry, mobile, viewports), mobile, zellij):
536
+ print(f'{device}: {change}')
537
+ except (L.LanesError, ValueError, KeyError) as error:
538
+ failures.append(str(error))
539
+ if failures:
540
+ raise L.LanesError('; '.join(failures))
469
541
  return 0
470
542
 
471
543
 
@@ -493,19 +565,116 @@ def cmd_ensure(_args):
493
565
  meta = L.read_json(meta_file) or {}
494
566
  create_session(device, bool(meta.get('mobile')), int(meta.get('viewports') or 1))
495
567
  print(f'recreated view session {device}')
496
- return 0
568
+ return cmd_upgrade_session([])
497
569
 
498
570
 
499
- def cmd_prepare(args):
500
- device = device_name(args[0])
571
+ def prepare_view(device, mobile, viewports=1, explicit=False):
572
+ """Create the device's view session unless it runs -> 'created' | 'reused'. A running view is never rebuilt:
573
+ a mobile request refuses a desktop view, and an EXPLICIT desktop request (`--desktop`, `lanes web`) refuses a
574
+ mobile one. Without a flag, `prepare` keeps reusing whatever layout runs."""
501
575
  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'):
576
+ create_session(device, mobile, viewports)
577
+ return 'created'
578
+ running_mobile = bool((L.read_json(L.STATE / 'devices' / f'{device}.json') or {}).get('mobile'))
579
+ if mobile and not running_mobile:
504
580
  raise L.LanesError(f'{device} already has a desktop layout; choose another device name')
581
+ if explicit and not mobile and running_mobile:
582
+ raise L.LanesError(f'{device} already has a mobile layout; choose another device name')
583
+ return 'reused'
584
+
585
+
586
+ def cmd_prepare(args):
587
+ device = device_name(args[0])
588
+ if '--mobile' in args and '--desktop' in args:
589
+ raise L.LanesError('--mobile and --desktop are exclusive')
590
+ prepare_view(device, '--mobile' in args, viewports_arg(args), explicit='--desktop' in args)
505
591
  print(f'ready: {device}')
506
592
  return 0
507
593
 
508
594
 
595
+ WEB_LOOPBACK = 'http://127.0.0.1:8083' # lanes-mobile.service (web/Caddyfile); Tailscale Serve forwards :8443 here
596
+ WEB_USAGE = 'usage: lanes web <device> [--desktop|--mobile] [--viewports N] [--token | --print-only]'
597
+
598
+
599
+ def tailnet_name():
600
+ """This node's MagicDNS name, read the way install/enable-phone.sh reads it; None when tailscale is absent,
601
+ silent or has no name. Read-only: Tailscale Serve itself is configured once by enable-phone.sh."""
602
+ if not shutil.which('tailscale'):
603
+ return None
604
+ rc, out, _err = L.run_bounded(['tailscale', 'status', '--json'], 10)
605
+ if rc != 0:
606
+ return None
607
+ try:
608
+ name = (json.loads(out).get('Self') or {}).get('DNSName') or ''
609
+ except (ValueError, AttributeError):
610
+ return None
611
+ return name.rstrip('.') or None
612
+
613
+
614
+ def cmd_web(args):
615
+ """lanes web <device> [--desktop|--mobile] [--viewports N] [--token | --print-only]
616
+
617
+ One command for a browser view (laptop, tablet, phone): prepare the device's view (idempotent; a running view
618
+ of the other layout is refused, never rebuilt), enable the loopback web services through install/enable-web.sh,
619
+ print the private URL. Without --desktop/--mobile a known device (devices/<device>.json) keeps its recorded
620
+ layout, exactly as a flagless `lanes prepare` reuses whatever runs; only a new device defaults to desktop.
621
+ --token execs the pinned `zellij web --create-token` exactly as enable-phone.sh does, in the caller's terminal
622
+ only; lanes never prints or logs a token. --print-only changes nothing. Output: `key: value` lines
623
+ (DM-LANES-WEB-CLI v1)."""
624
+ words, skip = [], False
625
+ for arg in args:
626
+ if skip:
627
+ skip = False
628
+ elif arg == '--viewports':
629
+ skip = True
630
+ elif arg.startswith('-'):
631
+ if arg not in ('--desktop', '--mobile', '--token', '--print-only'):
632
+ raise L.LanesError(f'unknown option {arg}; {WEB_USAGE}')
633
+ else:
634
+ words.append(arg)
635
+ if len(words) != 1:
636
+ raise L.LanesError(WEB_USAGE)
637
+ device = device_name(words[0])
638
+ if '--mobile' in args and '--desktop' in args:
639
+ raise L.LanesError('--mobile and --desktop are exclusive')
640
+ explicit = '--mobile' in args or '--desktop' in args
641
+ known = None if explicit else L.read_json(L.STATE / 'devices' / f'{device}.json')
642
+ mobile = bool(known.get('mobile')) if isinstance(known, dict) else '--mobile' in args
643
+ viewports = viewports_arg(args)
644
+ token, print_only = '--token' in args, '--print-only' in args
645
+ if token and print_only:
646
+ raise L.LanesError('--token and --print-only are exclusive')
647
+ if token and not (sys.stdin.isatty() and sys.stdout.isatty()):
648
+ print('lanes: run `lanes web --token` in your own terminal; it displays a private login token.', file=sys.stderr)
649
+ return 64
650
+ if token and not Path(ZELLIJ_BASE[0]).exists():
651
+ raise L.LanesError(f'{ZELLIJ_BASE[0]} is not installed: run install/host-install.sh')
652
+ flag = '--mobile' if mobile else '--desktop'
653
+ prepare = ['lanes', 'prepare', device, flag] + (['--viewports', str(viewports)] if viewports != 1 else [])
654
+ state = 'planned'
655
+ if not print_only:
656
+ state = prepare_view(device, mobile, viewports, explicit=explicit)
657
+ rc, out, err = L.run_bounded(['sh', str(L.REPO / 'install/enable-web.sh'), device, flag], 180)
658
+ if rc != 0:
659
+ raise L.LanesError('install/enable-web.sh: ' + ('no answer in 180 s' if rc is None
660
+ else err.strip() or out.strip() or f'exit {rc}'))
661
+ dns = tailnet_name()
662
+ lines = [('device', device), ('layout', 'mobile' if mobile else 'desktop'), ('state', state),
663
+ ('prepare', ' '.join(prepare)), ('services', f'sh install/enable-web.sh {device} {flag}'),
664
+ ('url', f'https://{dns}:8443/{device}' if dns else f'{WEB_LOOPBACK}/{device}')]
665
+ if not dns:
666
+ lines.append(('note', 'no Tailscale name on this host: the loopback URL opens only here; '
667
+ 'there is no public fallback by design (docs/INSTALL.md)'))
668
+ if not token:
669
+ lines.append(('next', f'lanes web {device} {flag} --token (in your own terminal: a login token)'))
670
+ print(''.join(f'{key}: {value}\n' for key, value in lines), end='')
671
+ if token:
672
+ print('\nThe last line is NAME: TOKEN. Paste only TOKEN into the sign-in screen.\n', flush=True)
673
+ # Zellij 0.45.1 requires --create-token by itself; --token-name cannot accompany it (enable-phone.sh).
674
+ os.execv(ZELLIJ_BASE[0], ZELLIJ_BASE + ['web', '--create-token'])
675
+ return 0
676
+
677
+
509
678
  # ------------------------------------------------------------------ safety
510
679
 
511
680
  PANE_FORMAT = '#{session_name}\t#{window_index}.#{pane_index}\t#{pane_pid}\t#{window_width}x#{window_height}'
@@ -911,22 +1080,22 @@ def cmd_doctor(_args):
911
1080
  if registry:
912
1081
  errors = [(w['id'], e) for w in registry['workspaces'] for e in w['errors']]
913
1082
  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?
1083
+ # Stale registry check: any terminals.json newer than the last sync? A lane's `source` is the container's
1084
+ # spelling; doctor runs on the host, so it stats the file under the container's home volume.
1085
+ sys.path.insert(0, str(L.REPO / 'libexec'))
1086
+ import statusd as SD
915
1087
  generated_at = registry.get('generated_at', 0)
1088
+ containers = L.read_json(L.STATE / 'containers.json') or {}
916
1089
  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():
1090
+ for src, path in SD.registry_source_paths(registry, containers):
1091
+ try:
1092
+ if path.stat().st_mtime > generated_at:
1093
+ stale_sources.append(src)
1094
+ except OSError:
1095
+ pass
1096
+ check(not stale_sources, 'registry up to date',
1097
+ 'run lanes sync — ' + '; '.join(stale_sources[:3]) + (' …' if len(stale_sources) > 3 else ''))
1098
+ for name, info in containers.items():
930
1099
  check(bool(info.get('running')), f'container {name} running', f"init pid {info.get('pid')}")
931
1100
  status = L.read_json(L.STATE / 'status.json') or {}
932
1101
  age = time.time() - status.get('updated_at', 0)
@@ -980,7 +1149,8 @@ def cmd_rename(args):
980
1149
 
981
1150
  Rename a lane's display name.
982
1151
  - Unregistered sessions: writes user-sessions metadata + updates resolve.dynamic.
983
- - Registered lanes: writes a displayNames override to the workspace manifest and syncs the registry.
1152
+ - Registered sessions (adopted): writes the record's name in registered-sessions.json and publishes.
1153
+ - Repository lanes: writes a displayNames override to the workspace manifest and syncs the registry.
984
1154
  """
985
1155
  if len(args) < 2:
986
1156
  raise L.LanesError('usage: lanes rename <lane-id> <new-name>')
@@ -1008,21 +1178,28 @@ def cmd_rename(args):
1008
1178
  return 0
1009
1179
  if not lane:
1010
1180
  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')
1181
+ # An adopted session (Add to workspace / `lanes register`) takes its name from registered-sessions.json,
1182
+ # which is the one file renamed here; its id and binding never change (DEF-1412).
1183
+ if lane.get('registered_session'):
1184
+ import registrations
1185
+ registrations.rename(lane_id, new_name)
1186
+ print(f"renamed registered session {lane_id!r} → {new_name!r}")
1187
+ return 0
1188
+ # Registered lane: a displayNames override in the board's manifest (repositories[i]), never terminals.json
1189
+ # (the lane's `source`, which the reader does not consult for display names). The lane id does not change.
1190
+ if not lane.get('repo'):
1191
+ raise L.LanesError('No repository for this lane; set displayNames in the workspace manifest manually')
1015
1192
  registry_name = lane.get('registry_name') or lane.get('name', '')
1016
- SA.rename_registered(source, registry_name, new_name)
1193
+ SA.rename_registered(L.manifest_path(workspace_obj['id']), lane['repo'], registry_name, new_name)
1017
1194
  cmd_sync([]) # rebuild registry so all consumers see the new name
1018
1195
  print(f"renamed registered lane {lane_id!r} ({registry_name!r}) → {new_name!r}")
1019
1196
  return 0
1020
1197
 
1021
1198
 
1022
1199
  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,
1200
+ 'prepare': cmd_prepare, 'web': cmd_web, 'attach': cmd_attach, 'rebuild': cmd_rebuild, 'ensure': cmd_ensure, 'invariant': cmd_invariant,
1024
1201
  'verify-legacy': cmd_verify_legacy, 'doctor': cmd_doctor, 'pressure': cmd_pressure, 'sockets': cmd_sockets, 'tmp': cmd_tmp,
1025
- 'guard': cmd_guard, 'rename': cmd_rename}
1202
+ 'guard': cmd_guard, 'rename': cmd_rename, 'register': cmd_register}
1026
1203
 
1027
1204
  if __name__ == '__main__':
1028
1205
  if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: