@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.
- package/package.json +1 -1
- package/payload/lanes/README.md +56 -1
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +281 -104
- package/payload/lanes/client/lanes-launch +44 -2
- package/payload/lanes/container/env.sh +8 -0
- package/payload/lanes/container/lane +19 -2
- package/payload/lanes/docs/ARCHITECTURE.md +86 -1
- package/payload/lanes/docs/DESIGN.md +72 -0
- package/payload/lanes/docs/INSTALL.md +39 -0
- package/payload/lanes/docs/RUNBOOK.md +94 -1
- package/payload/lanes/holders/ssh.sh +18 -0
- package/payload/lanes/install/enable-phone.sh +15 -6
- package/payload/lanes/install/enable-web.sh +19 -6
- package/payload/lanes/libexec/agent_state.py +214 -0
- package/payload/lanes/libexec/connection.py +178 -0
- package/payload/lanes/libexec/cursor_sync.py +139 -6
- package/payload/lanes/libexec/device_sync.py +134 -0
- package/payload/lanes/libexec/lanes_lib.py +158 -5
- package/payload/lanes/libexec/registrations.py +148 -0
- package/payload/lanes/libexec/session_actions.py +47 -15
- package/payload/lanes/libexec/sidebar.py +372 -61
- package/payload/lanes/libexec/statusd.py +126 -33
- package/payload/lanes/libexec/tokens.py +279 -0
- package/payload/lanes/libexec/tokens.sh +11 -0
- package/payload/lanes/libexec/view.sh +9 -8
- package/payload/lanes/web/README.md +15 -0
- package/payload/lanes/web/browser.css +22 -0
- package/payload/lanes/web/browser.js +73 -0
- package/payload/lanes/web/mobile.css +65 -33
- package/payload/lanes/web/mobile.js +23 -5
- package/payload/lanes/workspaces.example/README.md +9 -3
- package/payload/lanes/zellij/config.kdl +61 -3
- package/payload/manifest.json +6 -3
package/package.json
CHANGED
package/payload/lanes/README.md
CHANGED
|
@@ -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
|
-
|
|
|
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.
|
package/payload/lanes/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.1
|
package/payload/lanes/bin/lanes
CHANGED
|
@@ -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
|
|
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]
|
|
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…]
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
568
|
+
return cmd_upgrade_session([])
|
|
497
569
|
|
|
498
570
|
|
|
499
|
-
def
|
|
500
|
-
device
|
|
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,
|
|
503
|
-
|
|
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
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
|
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
|
-
#
|
|
1012
|
-
|
|
1013
|
-
if
|
|
1014
|
-
|
|
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(
|
|
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:
|