@vibelord/lanes 1.1.1 → 1.2.0

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 (59) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +45 -22
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2459 -164
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +106 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1091 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1140 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +618 -13
  20. package/payload/lanes/docs/DESIGN.md +122 -44
  21. package/payload/lanes/docs/INSTALL.md +157 -17
  22. package/payload/lanes/docs/RUNBOOK.md +603 -72
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +777 -136
  25. package/payload/lanes/install/enable-web.sh +2 -0
  26. package/payload/lanes/install/host-install.sh +163 -7
  27. package/payload/lanes/install/services.py +148 -18
  28. package/payload/lanes/install.sh +68 -7
  29. package/payload/lanes/libexec/agent_state.py +74 -1
  30. package/payload/lanes/libexec/cursor_sync.py +6 -0
  31. package/payload/lanes/libexec/device_sync.py +47 -10
  32. package/payload/lanes/libexec/economy_poll.py +1 -0
  33. package/payload/lanes/libexec/enrold.py +41 -22
  34. package/payload/lanes/libexec/keys.py +54 -23
  35. package/payload/lanes/libexec/lanes_lib.py +620 -22
  36. package/payload/lanes/libexec/members.py +16 -2
  37. package/payload/lanes/libexec/notify.py +104 -24
  38. package/payload/lanes/libexec/registrations.py +216 -18
  39. package/payload/lanes/libexec/session_actions.py +182 -14
  40. package/payload/lanes/libexec/sidebar +11 -3
  41. package/payload/lanes/libexec/sidebar.py +2135 -361
  42. package/payload/lanes/libexec/statusd.py +137 -10
  43. package/payload/lanes/libexec/studio_host.py +8 -1
  44. package/payload/lanes/libexec/tokens.py +121 -30
  45. package/payload/lanes/libexec/tokens.sh +86 -2
  46. package/payload/lanes/libexec/update.py +972 -0
  47. package/payload/lanes/libexec/users.py +614 -0
  48. package/payload/lanes/libexec/view.sh +34 -6
  49. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  50. package/payload/lanes/systemd/lanes-gc.service +1 -1
  51. package/payload/lanes/systemd/lanes-update.service +17 -0
  52. package/payload/lanes/systemd/lanes-update.timer +17 -0
  53. package/payload/lanes/web/README.md +83 -24
  54. package/payload/lanes/web/browser.js +3 -5
  55. package/payload/lanes/web/mobile.css +17 -17
  56. package/payload/lanes/web/mobile.js +44 -32
  57. package/payload/lanes/workspaces.example/README.md +3 -1
  58. package/payload/lanes/zellij/config.kdl +106 -56
  59. package/payload/manifest.json +5 -3
@@ -20,8 +20,14 @@ def session_key(entry):
20
20
  return entry.get('container'), binding.get('socket'), binding.get('session')
21
21
 
22
22
 
23
+ def binding_digest(container, socket, name):
24
+ """sha256 hex of one tmux binding: the tail of `session_id` (its first 10 characters) and the name of the binding's
25
+ `handback/` file."""
26
+ return hashlib.sha256(json.dumps([container, socket, name]).encode()).hexdigest()
27
+
28
+
23
29
  def session_id(workspace, container, socket, name):
24
- digest = hashlib.sha256(json.dumps([container, socket, name]).encode()).hexdigest()[:10]
30
+ digest = binding_digest(container, socket, name)[:10]
25
31
  return f'{workspace}--x-{L.slug(name)[:40]}-{digest}'
26
32
 
27
33
 
@@ -78,6 +84,48 @@ def make_entry(workspace, container, socket, name, pane, previous=None):
78
84
  return entry
79
85
 
80
86
 
87
+ def handback_path(container, socket, name):
88
+ return L.STATE / 'handback' / (binding_digest(container, socket, name) + '.json')
89
+
90
+
91
+ def hand_back(record, identity):
92
+ """What a session `registrations.unregister` releases keeps as an UNREGISTERED one (cockpit v3 S7): its name in
93
+ `user-sessions/<id>.json` (make_entry's name authority), the board and id it was registered under in
94
+ `handback/<binding digest>.json` (`handed_back`: statusd's first listing of it), and its views in
95
+ `resolve.dynamic/<id>`. `identity` is the {session_id, created} tmux named for the binding: the hand-back is that
96
+ session's alone. Raises OSError (the caller has already saved the record)."""
97
+ container, socket, name = session_key(record)
98
+ binding = {'container': container, 'holder': 'tmux', 'tmux': {'socket': socket, 'session': name}}
99
+ L.atomic_write(L.STATE / 'user-sessions' / (record['id'] + '.json'),
100
+ json.dumps(dict({'id': record['id'], 'workspace': record['workspace']}, **binding,
101
+ name=record['name'])) + '\n')
102
+ L.atomic_write(handback_path(container, socket, name),
103
+ json.dumps(dict({'id': record['id'], 'workspace': record['workspace']}, **binding,
104
+ identity=dict(identity))) + '\n')
105
+ make_entry(record['workspace'], container, socket, name,
106
+ {'session_id': identity['session_id'], 'session_created': identity['created']}, record)
107
+
108
+
109
+ def handed_back(container, socket, name, pane):
110
+ """statusd's seed for a session it lists with no listing of its own from the previous cycle -> (board, previous)
111
+ for `make_entry`, or (None, None). A session that was REGISTERED has none, and statusd would mint its id from
112
+ whichever board its directory names now — another board's id, under the raw tmux name, whenever the agent moved
113
+ (fix cycle 1 of S7, DEF-1826). `hand_back` left the board and id it was registered under; they apply only to the
114
+ session tmux named then (same binding, same session id and creation time), so a session re-created under that name
115
+ later is attributed as any other. One read of a small file, for a session's first listing only. Never raises on a
116
+ damaged file (statusd would skip a live session for the cycle): anything unexpected is no hand-back."""
117
+ meta = L.read_json(handback_path(container, socket, name), {})
118
+ if not isinstance(meta, dict) or not isinstance(meta.get('tmux'), dict) or \
119
+ session_key(meta) != (container, socket, name):
120
+ return None, None
121
+ identity = meta.get('identity') if isinstance(meta.get('identity'), dict) else {}
122
+ if (not identity.get('session_id') or identity.get('session_id') != pane.get('session_id')
123
+ or identity.get('created') != pane.get('session_created')
124
+ or not all(isinstance(meta.get(field), str) and L.IDENT.match(meta[field]) for field in ('id', 'workspace'))):
125
+ return None, None
126
+ return meta['workspace'], {'id': meta['id']}
127
+
128
+
81
129
  def new_shell(workspace):
82
130
  registry = L.load_registry()
83
131
  board = next((w for w in registry['workspaces'] if w['id'] == workspace), None)
@@ -143,12 +191,17 @@ def rename_session(workspace, entry, new_name):
143
191
  return dict(entry, name=new_name)
144
192
 
145
193
 
146
- def rename_registered(manifest_path, repo_id, registry_name, new_name):
194
+ def rename_registered(manifest_path, repo_id, registry_name, new_name, declared_display=None):
147
195
  """Display override for one declared terminal. The reader is `lanes_lib._workspace_lanes`, which looks at
148
196
  `repositories[i].displayNames` keyed by the terminal's declared name — the manifest's top level (and the
149
197
  lane's terminals.json `source`) has no such key. `registry_name` is the lane's title (repository prefix +
150
- declared name); renaming back to it clears the override. The lane id never changes (lanes_lib.lane_id_for).
151
- The first write keeps a `<manifest>.bak-<UTC timestamp>` copy beside it. Callers run `lanes sync`."""
198
+ declared name). This is the HOST layer and wins over the container's own `lanes.terminals[<name>].displayName`
199
+ (LNS-33), which the lane carries as `declared_display` and which this never writes. Renaming back to the lane's
200
+ default — `declared_display` when the container declares one, else the title — clears the override; any other
201
+ name (the title included, when the container declares a different name) is kept as one, or the reader would
202
+ fall back to the container's name and the rename would silently do nothing. The lane id never changes
203
+ (lanes_lib.lane_id_for). The first write keeps a `<manifest>.bak-<UTC timestamp>` copy beside it. Callers run
204
+ `lanes sync`."""
152
205
  L._title(new_name)
153
206
  path = Path(manifest_path)
154
207
  original = path.read_text()
@@ -159,8 +212,8 @@ def rename_registered(manifest_path, repo_id, registry_name, new_name):
159
212
  prefix = repo.get('prefix') or ''
160
213
  declared = registry_name[len(prefix):] if prefix and registry_name.startswith(prefix) else registry_name
161
214
  names = repo.setdefault('displayNames', {})
162
- if new_name == registry_name:
163
- names.pop(declared, None) # removing the override restores the default title
215
+ if new_name == (declared_display or registry_name):
216
+ names.pop(declared, None) # removing the override restores the lane's default name
164
217
  else:
165
218
  names[declared] = new_name
166
219
  if not any(path.parent.glob(path.name + '.bak-*')):
@@ -169,12 +222,49 @@ def rename_registered(manifest_path, repo_id, registry_name, new_name):
169
222
  L.atomic_write(path, json.dumps(data, indent=2, ensure_ascii=False) + '\n', mode=0o644)
170
223
 
171
224
 
172
- def promote_to_registered(workspace_id, entry, registry):
225
+ def declared_in(lane):
226
+ """Where a DECLARED lane is declared, as a person edits it: a repository lane's `source` (its terminals.json, the
227
+ container's spelling) or `catalog.json` for a lane of an SSH workspace (`sshWorkspaces`); None for anything else —
228
+ an added session (registered-sessions.json is the registrations module's), an unregistered one, a zmx row."""
229
+ if lane.get('registered_session') or lane.get('closable'):
230
+ return None
231
+ return lane.get('source') or ('catalog.json' if lane.get('holder') == 'ssh' else None)
232
+
233
+
234
+ def declared_text(lane, dash='—'):
235
+ """What `u` (sidebar) and `lanes unregister` say about a declared lane no editor of ours writes — a lane of an SSH
236
+ workspace, declared in `catalog.json` (`declared_in`): `declared in <where> — edit the file`. A repository lane
237
+ (its terminals.json `source`) is taken out by `unregister_lane` instead (cockpit v3 S7 + M4, wired at the fan-in).
238
+ `dash` is the caller's `dash` mark (the sidebar's ASCII footer is seven-bit)."""
239
+ return f'declared in {declared_in(lane)} {dash} edit the file'
240
+
241
+
242
+ def promoted_id(workspace_id, repo_id, name):
243
+ """The id `lanes sync` gives the terminal `promote_to_registered` declares as `name`: lanes_lib.lane_id_for — the
244
+ one slug helper sync uses — over the title `_workspace_lanes` builds, the repository's `prefix` + the declared name.
245
+ The prefix is read from the board's manifest (`repo_id` is the source lane's `repo`); a manifest that cannot be
246
+ read predicts the unprefixed id. A PREDICTION: sync suffixes an id another lane of the board already holds
247
+ (`-2`, `build_registry`), so a reader never trusts it without the binding (the sidebar's `settle_pending`)."""
248
+ prefix = ''
249
+ if repo_id:
250
+ try:
251
+ manifest = L.read_site_json(L.manifest_path(workspace_id))
252
+ repo = next((r for r in manifest.get('repositories') or []
253
+ if isinstance(r, dict) and r.get('id') == repo_id), {})
254
+ prefix = repo.get('prefix') or ''
255
+ except (L.LanesError, OSError, ValueError, KeyError, TypeError):
256
+ prefix = ''
257
+ return L.lane_id_for(workspace_id, {'name': prefix + name})
258
+
259
+
260
+ def promote_to_registered(workspace_id, entry, registry, name=None):
173
261
  """Promote an unregistered tmux session to a registered lane by writing its binding to terminals.json.
174
262
 
175
- The session must have holder=='tmux' and the workspace must have exactly one repository source
176
- (unambiguous write target). After this call, callers should run `lanes sync` and reload.
177
- Raises LanesError for unsupported configurations or invalid session state."""
263
+ `name` is the terminal's declared name — the sidebar's `P` asks for it (cockpit v3 S8); without it, the session's
264
+ own name. The session must have holder=='tmux' and the workspace must have exactly one repository source
265
+ (unambiguous write target), and neither a published lane nor that file may bind the session already. Returns the id
266
+ `lanes sync` is expected to give the lane (`promoted_id`). After this call, callers should run `lanes sync`. Raises
267
+ LanesError for unsupported configurations or invalid session state."""
178
268
  if entry.get('holder') != 'tmux':
179
269
  raise L.LanesError('Only tmux sessions can be promoted (zmx and exec holders are not supported)')
180
270
  # A binding any board already holds — a repository declaration or an adopted registered session — is
@@ -189,9 +279,9 @@ def promote_to_registered(workspace_id, entry, registry):
189
279
  raise L.LanesError('Workspace not found')
190
280
  # Find the terminals.json source — must be a single unambiguous file. A source is (container, spelling): the
191
281
  # same spelling in two containers (one repository cloned in two boxes) is two files, so it is still ambiguous.
192
- sources = [(lane.get('container') or board.get('container') or '', lane['source'])
193
- for lane in board.get('lanes', []) if lane.get('source')]
194
- unique_sources = list(dict.fromkeys(sources))
282
+ sources = {(lane.get('container') or board.get('container') or '', lane['source']): lane.get('repo')
283
+ for lane in board.get('lanes', []) if lane.get('source')}
284
+ unique_sources = list(sources)
195
285
  if not unique_sources:
196
286
  raise L.LanesError(
197
287
  'No registry source found for this workspace; add the lane to terminals.json manually')
@@ -210,11 +300,19 @@ def promote_to_registered(workspace_id, entry, registry):
210
300
  session_name = tmux.get('session', '')
211
301
  if not socket_name or not session_name:
212
302
  raise L.LanesError('Session has no tmux binding; cannot promote')
303
+ name = L._title(entry.get('name', session_name) if name is None else name)
213
304
  data = json.loads(terminals_path.read_text())
214
305
  terminals = data.get('terminals') or []
215
306
  lane_block = data.setdefault('lanes', {})
216
307
  lane_terminals = lane_block.setdefault('terminals', {})
217
- name = entry.get('name', session_name)
308
+ # A session this file already binds — under any name — is promoted already: the next sync lists its lane. A
309
+ # second terminal bound to it would give one tmux session two lanes (DEF-1555's shape), and the registry cannot
310
+ # say so between the write and the sync that lists it: a `P` on another device, or on this one after the sidebar
311
+ # let the row go (S8 fix cycle 1, DEF-1762). So the file is asked, as build_registry reads it. Nothing is written.
312
+ for terminal in terminals:
313
+ bound = L.declared_tmux(data, terminal.get('name')) or {}
314
+ if (bound.get('socketName'), bound.get('sessionName')) == (socket_name, session_name):
315
+ raise L.LanesError(f"Already declared as {terminal.get('name')!r}; lanes sync lists it")
218
316
  # Guard: do not overwrite an existing registered terminal with the same name
219
317
  if any(t.get('name') == name for t in terminals):
220
318
  raise L.LanesError(f'A terminal named {name!r} already exists; rename it first')
@@ -229,6 +327,76 @@ def promote_to_registered(workspace_id, entry, registry):
229
327
  # Remove the user-sessions metadata — the terminal is now governed by the registry
230
328
  meta_path = L.STATE / 'user-sessions' / (entry['id'] + '.json')
231
329
  meta_path.unlink(missing_ok=True)
330
+ return promoted_id(workspace_id, sources[unique_sources[0]], name)
331
+
332
+
333
+ class DeclaredRefused(L.LanesError):
334
+ """unregister_declared / unregister_lane refused: `code` is the machine code lane-mcp's `lanes_unregister`
335
+ answers for the same case (last_terminal, adopted_lane, not_declared_here, source_link, …)."""
336
+
337
+ def __init__(self, message, code):
338
+ super().__init__(message)
339
+ self.code = code
340
+
341
+
342
+ def unregister_declared(source_path, name, root=None):
343
+ """Remove the declared terminal `name` from the terminals.json at `source_path` (the HOST path: L.host_path of the
344
+ lane's `source`) — its `terminals[]` item and its `lanes.terminals` entry — with the editor lane-mcp's
345
+ `lanes_unregister` runs in the container (lane_core.terminals_remove through edit_file): one span, re-read before
346
+ the atomic write, the file's mode kept, never the root `throttle` object, never a rename or reorder, never the
347
+ last terminal. `root` is the container's home volume: a symbolic link at or above the file below it refuses
348
+ (`source_link`; from the host it would resolve outside the container, the DEF-1796 class). The session keeps
349
+ running and shows as an unregistered session once `lanes sync` has run (callers run it; lanes-statusd's
350
+ hot-sync also starts one within ~15 s of the edit). -> {'path', 'name', 'changed': bool}. DeclaredRefused
351
+ otherwise. The sidebar's `u` and `lanes unregister` on a declared lane call unregister_lane, which finds both
352
+ arguments (cockpit v3 S7's hook, wired to this editor at the fan-in)."""
353
+ core = L.lane_core()
354
+
355
+ def transform(text):
356
+ if text is None:
357
+ raise core.LaneError(f'{source_path} does not exist', 'source_missing')
358
+ return core.terminals_remove(text, name)
359
+ try:
360
+ before, after, _backup = core.edit_file(Path(source_path), transform, root=root)
361
+ except core.LaneError as error:
362
+ raise DeclaredRefused(str(error), error.code or 'refused') from error
363
+ return {'path': str(source_path), 'name': name, 'changed': before != after}
364
+
365
+
366
+ def unregister_lane(lane_id, registry=None):
367
+ """unregister_declared for a lane of the published registry: a DECLARED tmux lane (a terminals.json `source`),
368
+ never an adopted session (`registered_session`: registered-sessions.json is its authority) or an unregistered
369
+ one. The file is reached through the container's home volume (the registry's `containers`, else
370
+ containers.json) and the terminal found the way lane-mcp finds it (lane_core.declared_name: its tmux binding in
371
+ `lanes.terminals` or the `throttle` block, else the one name that ends its `registry_name`).
372
+ -> {'lane', 'source', 'path', 'name', 'changed'}."""
373
+ core = L.lane_core()
374
+ registry = L.load_registry() if registry is None else registry
375
+ lane = next((item for w in registry['workspaces'] for item in w['lanes'] if item['id'] == lane_id), None)
376
+ if lane is None:
377
+ raise DeclaredRefused(f'{lane_id} is not a lane of the registry; reload', 'not_in_container')
378
+ if lane.get('registered_session'):
379
+ raise DeclaredRefused(f'{lane_id} was adopted (registered-sessions.json), not declared in a terminals.json',
380
+ 'adopted_lane')
381
+ if not lane.get('source'):
382
+ raise DeclaredRefused(f'{lane_id} names no terminals.json', 'not_declared_here')
383
+ info = (registry.get('containers') or {}).get(lane.get('container')) \
384
+ or (L.read_json(L.STATE / 'containers.json', {}) or {}).get(lane.get('container')) or {}
385
+ path = L.host_path(info, lane['source'])
386
+ try:
387
+ _ident, text = core._snapshot(path, info['home'])
388
+ except core.LaneError as error:
389
+ raise DeclaredRefused(str(error), error.code or 'refused') from error
390
+ if text is None:
391
+ raise DeclaredRefused(f'{lane["source"]} does not exist in {lane.get("container")}', 'source_missing')
392
+ try:
393
+ doc = core.strict_json(text, lane['source'])
394
+ except core.LaneError as error:
395
+ raise DeclaredRefused(str(error), error.code or 'refused') from error
396
+ name = core.declared_name(doc, lane)
397
+ if name is None:
398
+ raise DeclaredRefused(f'{lane["source"]} declares no terminal that matches {lane_id}', 'not_declared_here')
399
+ return dict(unregister_declared(path, name, root=info['home']), lane=lane_id, source=lane['source'])
232
400
 
233
401
 
234
402
  def close_session(workspace, entry):
@@ -1,8 +1,16 @@
1
1
  #!/bin/sh
2
- # Never leave the sidebar pane empty: restart the TUI if it ever exits.
2
+ # Never leave the sidebar pane empty: restart the TUI if it ever exits (after a 1 s pause: no restart storm).
3
+ # Exit 75 is the sidebar's self-swap (cockpit v3 U5): an update flipped ~/.local/share/lanes/current to a new tree, and
4
+ # the idle sidebar saved its cursor, scroll, seen marks and filter (views/<device>/<ws>.<slot>.sidebar.state) for its
5
+ # successor. Re-execute this wrapper at once — no pause — from $here, the LOGICAL directory it was started from: `cd`
6
+ # and `pwd -L` keep a `current` symlink in it, so a layout's `…/current/libexec/sidebar` now runs the new tree's
7
+ # wrapper and sidebar.py. (A sidebar started from a `pkg/<version>` path never exits 75: its re-exec would run the
8
+ # same tree, so it shows the footer's version note instead.)
3
9
  trap '' INT QUIT
4
- here=$(cd "$(dirname "$0")" && pwd)
10
+ here=$(cd "$(dirname "$0")" && pwd -L)
5
11
  while :; do
6
- python3 -S -E "$here/sidebar.py" "$@" || :
12
+ rc=0
13
+ python3 -S -E "$here/sidebar.py" "$@" || rc=$?
14
+ if [ "$rc" = 75 ]; then exec "$here/sidebar" "$@"; fi
7
15
  sleep 1
8
16
  done