@vibelord/lanes 1.1.1 → 1.3.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 (71) 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 +47 -23
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2753 -202
  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 +147 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1150 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1318 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +793 -16
  20. package/payload/lanes/docs/DESIGN.md +127 -46
  21. package/payload/lanes/docs/INSTALL.md +175 -18
  22. package/payload/lanes/docs/RUNBOOK.md +968 -76
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +967 -150
  25. package/payload/lanes/holders.lock +5 -0
  26. package/payload/lanes/install/enable-docs.sh +78 -0
  27. package/payload/lanes/install/enable-web.sh +2 -0
  28. package/payload/lanes/install/host-install.sh +197 -7
  29. package/payload/lanes/install/services.py +148 -18
  30. package/payload/lanes/install.sh +68 -7
  31. package/payload/lanes/libexec/agent_state.py +74 -1
  32. package/payload/lanes/libexec/cursor_sync.py +6 -0
  33. package/payload/lanes/libexec/device_sync.py +47 -10
  34. package/payload/lanes/libexec/docs_store.py +995 -0
  35. package/payload/lanes/libexec/docsd.py +2010 -0
  36. package/payload/lanes/libexec/economy_poll.py +1 -0
  37. package/payload/lanes/libexec/enrold.py +41 -22
  38. package/payload/lanes/libexec/keys.py +54 -23
  39. package/payload/lanes/libexec/lanes_lib.py +620 -22
  40. package/payload/lanes/libexec/mdlite.py +575 -0
  41. package/payload/lanes/libexec/members.py +21 -2
  42. package/payload/lanes/libexec/notify.py +169 -29
  43. package/payload/lanes/libexec/registrations.py +216 -18
  44. package/payload/lanes/libexec/session_actions.py +182 -14
  45. package/payload/lanes/libexec/sidebar +11 -3
  46. package/payload/lanes/libexec/sidebar.py +2232 -365
  47. package/payload/lanes/libexec/statusd.py +231 -12
  48. package/payload/lanes/libexec/studio_host.py +8 -1
  49. package/payload/lanes/libexec/tokens.py +148 -35
  50. package/payload/lanes/libexec/tokens.sh +86 -2
  51. package/payload/lanes/libexec/update.py +1044 -0
  52. package/payload/lanes/libexec/uploads.py +538 -0
  53. package/payload/lanes/libexec/users.py +614 -0
  54. package/payload/lanes/libexec/view.sh +34 -6
  55. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  56. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  57. package/payload/lanes/systemd/lanes-gc.service +1 -1
  58. package/payload/lanes/systemd/lanes-update.service +17 -0
  59. package/payload/lanes/systemd/lanes-update.timer +17 -0
  60. package/payload/lanes/web/Caddyfile +24 -1
  61. package/payload/lanes/web/README.md +165 -28
  62. package/payload/lanes/web/browser.js +4 -8
  63. package/payload/lanes/web/docs.css +52 -0
  64. package/payload/lanes/web/docs.js +36 -0
  65. package/payload/lanes/web/mobile.css +17 -17
  66. package/payload/lanes/web/mobile.js +45 -32
  67. package/payload/lanes/web/upload.css +17 -0
  68. package/payload/lanes/web/upload.js +84 -0
  69. package/payload/lanes/workspaces.example/README.md +3 -1
  70. package/payload/lanes/zellij/config.kdl +106 -56
  71. package/payload/manifest.json +6 -3
@@ -19,12 +19,29 @@ NEEDS_YOU set. The six-word `state` is never read: the agent status is the only
19
19
  from the phone's picker never mutes pushes (DEF-1533). A phone's own attach within the window still counts.
20
20
  Typing in a lane touches neither file, so this is "attached or opened a lane at a desk recently", not "is
21
21
  looking now" (DEF-1532). A suppressed transition is consumed, never sent later.
22
+ * Since cockpit v3 R7 (LNS-80) a person's profile `STATE/users/<login>.json` counts as well when its
23
+ `updated_by_device` is a desk device (same rule) and its mtime is within the window: the sidebar writes the mark
24
+ of an opened lane there too, so presence never hangs on `clients/<device>.seen.json` staying the place it is
25
+ kept; a phone's write, and a change no device made, never count: the CLI's writes are stamped `cli:<command>`
26
+ (`lanes workspaces|lane …`) or `cli:migration` (`lanes device claim`, `lanes keys adopt --login`, `lanes keys
27
+ owner`), so an admin over SSH naming a desk device mutes nobody (W9 review F3). Each mark belongs to a person (the
28
+ device's owner, users.login_for over STATE/members; the profile's login) and is recent within that person's own
29
+ window: their profile's `notify.quiet_s` when set, else the host's `quiet_when_device_active_s`. Recent marks of
30
+ one person alone mute; recent marks of several people fall back to the host window for all (presence_marks,
31
+ device_active).
22
32
  * Payload: `<lane name>: <agent task> — <status>` and, on a second line, the client command that opens the lane
23
33
  (`lanes-launch lane <id>`). No client registers a URL scheme for it, so the push carries a tappable action only
24
- when `action_url` (a template over `{lane}`) is configured.
34
+ when `action_url` (a template over `{lane}`, and `{doc}` for a `published` event — `action_url()`) is configured.
25
35
  * A failed send is one journal line (lane id and the failure class — never the topic or the bearer) and is not
26
36
  retried; it never stops the service.
27
37
 
38
+ Published documents (plan W10 W4): the docs service's publish pick-up (`lanes-docsd`, docsd.Pickup), not this
39
+ service, calls `notify_published` once a document an agent published from a lane is stored — the same config, the
40
+ same desk-presence rule (device_active) and the same `send`, at most one push per lane per minute (docsd's
41
+ PUBLISHED_PUSH_S), best effort. Its payload is `{title: 'Published', message: '<lane name> published a document (<kind>,
42
+ <size>)\n/lanes-docs/d/<id>', priority: 2}` and, when `action_url` is set, the tap target with `{doc}` resolved —
43
+ never the document's title, name or content: the topic is the host's, and the document may be for one login only.
44
+
28
45
  Enrolment requests (W8 A5, LNS-44): the same service also watches `STATE/enrol-requests/` (lanes-enrold's store;
29
46
  read through `enrold.load_requests`, so a request announced is one `lanes enrol list` shows). A request that is
30
47
  pending — not approved, denied or past `expires_at` — and has not been announced yet yields ONE push:
@@ -45,7 +62,9 @@ Config `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` overrides the path), `
45
62
  `url` (ntfy server, http/https, required), `topic` (`[A-Za-z0-9_-]{1,64}`, required), `token_file` (a file holding
46
63
  an ntfy access token, optional — sent as a bearer, never logged; an unreadable one disables sending rather than
47
64
  sending unauthenticated), `quiet_when_device_active_s` (default 120; 0 never suppresses), `action_url` (optional
48
- template, e.g. `https://lanes.example/?lane={lane}`). Without a valid `url` and `topic` the service only tracks
65
+ template, e.g. `https://lanes.example/?lane={lane}`; plan W10 W7 adds `{doc}`, the page `/lanes-docs/d/<id>` of the
66
+ document a `published` push names — empty for a needs-you push; the docs service sends those, W4). Without a valid
67
+ `url` and `topic` the service only tracks
49
68
  transitions and sends nothing. The config is re-read when notify.conf OR its `token_file` changes (inode, size,
50
69
  mtime or ctime, so a token written, rotated, removed or chmod-ed after notify.conf counts), and on every tick while
51
70
  sending is disabled, so a token that becomes readable switches the pushes on within one interval.
@@ -66,9 +85,11 @@ import urllib.request
66
85
  from pathlib import Path
67
86
 
68
87
  sys.path.insert(0, str(Path(__file__).resolve().parent))
88
+ import docs_store # noqa: E402
69
89
  import enrold as E # noqa: E402
70
90
  import lanes_lib as L # noqa: E402
71
91
  import tokens # noqa: E402
92
+ import users as U # noqa: E402
72
93
 
73
94
  TRIGGERS = ('needs_input', 'needs_approval', 'errored') # the sidebar's NEEDS_YOU (sidebar.ATTENTION[0][3])
74
95
  LABELS = tokens.MOBILE_AGENT_LABELS # the phone's words: Needs you / Needs approval / Error
@@ -83,6 +104,9 @@ TIMEOUT = 5
83
104
  PRESENCE = '*.pid' # clients/ files only `lanes attach` writes
84
105
  SEEN = '.seen.json' # a sidebar's opened-lane record; desk devices only
85
106
  # (a .pid of any age, devices/<device>.json not mobile)
107
+ # users/<login>.json written by a desk device counts too
108
+ PUBLISHED_TITLE = 'Published' # a document published from a lane (notify_published)
109
+ PUBLISHED_PRIORITY = 2
86
110
  ENROL_DIR = 'enrol-requests' # STATE/<this>/<id>.json: lanes-enrold's requests
87
111
  ENROL_SEEN = 'notify.enrol-seen.json' # STATE/<this>: the request ids already announced
88
112
  ENROL_TITLE = 'Enrol request'
@@ -163,37 +187,116 @@ def is_mobile(devices_dir, device):
163
187
  return isinstance(meta, dict) and bool(meta.get('mobile'))
164
188
 
165
189
 
166
- def device_active(clients_dir, window, now, devices_dir=None):
167
- """True when a device attached (`<device>.<client>.pid`) or a desk device opened a lane (`<device>.seen.json`)
168
- within `window` seconds of `now`. `seen.json` counts only for a device that has a `<device>.*.pid` of any age
169
- (the mark of `lanes attach`) AND whose `devices_dir/<device>.json` is not `mobile: true`. The mobile flag is
170
- the authoritative phone mark: a Safari phone never runs `lanes attach`, but an SSH-only phone may
171
- (`lanes attach phone --mobile`, RUNBOOK) and its pid is never removed — so a lane opened from the phone's picker
172
- never mutes the phone's own pushes, however it attached (DEF-1533). `devices_dir` defaults to the `devices`
173
- directory beside `clients_dir` (both live in STATE). A missing directory is nobody present; a window of 0 is
174
- never present."""
175
- if window <= 0:
176
- return False
177
- devices_dir = Path(clients_dir).parent / 'devices' if devices_dir is None else Path(devices_dir)
190
+ def _names(directory, suffix):
191
+ try:
192
+ return {name[:-len(suffix)] for name in os.listdir(directory) if name.endswith(suffix)}
193
+ except OSError:
194
+ return set()
195
+
178
196
 
179
- def recent(path):
197
+ def presence_marks(clients_dir, devices_dir=None, users_dir=None, members_dir=None, profiles=None):
198
+ """[(login or None, mtime)]: every mark of someone at a device, whoever's window decides about it.
199
+ * `clients/<device>.<client>.pid` (`lanes attach`, any device, a phone included);
200
+ * `clients/<device>.seen.json` of a DESK device (a lane opened from its sidebar);
201
+ * `users/<login>.json` whose `updated_by_device` is a DESK device (cockpit v3 R7, LNS-80): the sidebar's mark
202
+ of a lane opened there and every other profile write made at that device (`lanes attach`, `lanes next|prev`,
203
+ `lanes here`) — so presence does not hang on the seen file staying in clients/. A change no device made is
204
+ nobody's: a CLI writer (`cli:<command>`, `cli:migration`: users.writer_device is None) or null (W9 review F3).
205
+ A desk device has a `<device>.*.pid` of any age and a `devices/<device>.json` that is not `mobile: true`
206
+ (DEF-1533: a phone's picker never counts, however the phone attached). The login of a device mark is its owner
207
+ (users.login_for over `members/` and `devices/<device>.json`; a pid file belongs to the longest known device name
208
+ it starts with, None when none or nobody owns it); a profile mark's is the profile's. The directories default to
209
+ the siblings of `clients_dir` (all four live in STATE); `profiles` is users.load_all(users_dir)'s list when the
210
+ caller has read it. A directory that cannot be read gives no marks."""
211
+ directory = Path(clients_dir)
212
+ state = directory.parent
213
+ devices_dir = state / 'devices' if devices_dir is None else Path(devices_dir)
214
+ users_dir = state / 'users' if users_dir is None else Path(users_dir)
215
+ members_dir = state / 'members' if members_dir is None else Path(members_dir)
216
+ owners, desks = {}, {}
217
+
218
+ def owner(device):
219
+ if device is None:
220
+ return None
221
+ if device not in owners:
222
+ meta = L.read_json(devices_dir / f'{device}.json')
223
+ owners[device] = U.login_for(device, meta if isinstance(meta, dict) else {}, members_dir)
224
+ return owners[device]
225
+
226
+ def desk(device):
227
+ if device not in desks:
228
+ desks[device] = (bool(device) and any(directory.glob(glob.escape(device) + '.' + PRESENCE))
229
+ and not is_mobile(devices_dir, device))
230
+ return desks[device]
231
+
232
+ def mtime(path):
180
233
  try:
181
- return now - path.stat().st_mtime <= window
234
+ return path.stat().st_mtime
182
235
  except OSError:
183
- return False
236
+ return None
184
237
 
238
+ marks = []
239
+ known = {name for name in _names(devices_dir, '.json') | _names(members_dir, '.json') if L.IDENT.match(name)}
185
240
  try:
186
- directory = Path(clients_dir)
187
- if any(recent(path) for path in directory.glob(PRESENCE)):
188
- return True
241
+ for path in directory.glob(PRESENCE):
242
+ at = mtime(path)
243
+ device = max((name for name in known if path.name.startswith(name + '.')
244
+ and len(path.name) > len(name) + len('..pid')), key=len, default=None)
245
+ if at is not None:
246
+ marks.append((owner(device), at))
189
247
  for path in directory.glob('*' + SEEN):
190
- device = path.name[:-len(SEEN)]
191
- if (device and recent(path) and any(directory.glob(glob.escape(device) + '.' + PRESENCE))
192
- and not is_mobile(devices_dir, device)):
193
- return True
248
+ device, at = path.name[:-len(SEEN)], mtime(path)
249
+ if at is not None and desk(device):
250
+ marks.append((owner(device), at))
194
251
  except OSError:
195
- return False
196
- return False
252
+ pass
253
+ for profile in U.load_all(users_dir)[0] if profiles is None else profiles:
254
+ device = U.writer_device(profile['updated_by_device'])
255
+ if device and desk(device):
256
+ at = mtime(U.path(profile['login'], users_dir))
257
+ if at is not None:
258
+ marks.append((profile['login'], at))
259
+ return marks
260
+
261
+
262
+ def quiet_windows(profiles):
263
+ """{login: notify.quiet_s} of the profiles that set their own quiet window (DM-LANES-USER-PROFILE `notify`)."""
264
+ return {profile['login']: profile['notify']['quiet_s'] for profile in profiles if 'quiet_s' in profile['notify']}
265
+
266
+
267
+ def device_active(clients_dir, window, now, devices_dir=None, users_dir=None, members_dir=None):
268
+ """True when someone was at a device recently enough to mute a push (presence_marks). `window` is the host's
269
+ `quiet_when_device_active_s`. Each mark is recent within its person's own window — their profile's
270
+ `notify.quiet_s` when set, else `window`; a device nobody owns always `window`. When the recent marks all
271
+ belong to one person (or all to devices nobody owns) that is presence; when they belong to several, the host
272
+ `window` decides for all of them (any mark within it). A window of 0 makes nothing recent: a person's
273
+ `quiet_s` 0 never mutes their own pushes, a host `window` of 0 never mutes anyone's but a person present alone
274
+ who set a `quiet_s` of their own. A missing directory is nobody present. Cockpit v3 R7 (LNS-80)."""
275
+ state = Path(clients_dir).parent
276
+ users_dir = state / 'users' if users_dir is None else Path(users_dir)
277
+
278
+ def within(at, span):
279
+ return span > 0 and now - at <= span
280
+
281
+ profiles = U.load_all(users_dir)[0]
282
+ marks = presence_marks(clients_dir, devices_dir, users_dir, members_dir, profiles)
283
+ quiet = quiet_windows(profiles)
284
+ recent = {login for login, at in marks if within(at, quiet.get(login, window) if login else window)}
285
+ if len(recent) <= 1:
286
+ return bool(recent)
287
+ return any(within(at, window) for _login, at in marks)
288
+
289
+
290
+ def action_url(template, lane_id, doc_id=None):
291
+ """notify.conf's `action_url` template made a push's tap target -> the URL, or '' without a template. `{lane}` is
292
+ the lane id; `{doc}` (plan W10 W7) the page of the document a `published` event is about, `/lanes-docs/d/<id>`
293
+ (docs_store.URL; a docs_store id, else nothing), and nothing for an event without one (a needs-you push) — so
294
+ `https://<host>:8443{doc}` opens the document. Both are percent-quoted."""
295
+ if not template:
296
+ return ''
297
+ doc = docs_store.URL.format(doc_id) if isinstance(doc_id, str) and docs_store.ID_RE.match(doc_id) else ''
298
+ return (template.replace('{lane}', urllib.parse.quote(lane_id, safe=''))
299
+ .replace('{doc}', urllib.parse.quote(doc, safe='/')))
197
300
 
198
301
 
199
302
  def _note(lane_id, status, agent, names, config):
@@ -203,14 +306,50 @@ def _note(lane_id, status, agent, names, config):
203
306
  first = _line(f'{name}: {task} — {label}' if task else f'{name} — {label}')
204
307
  note = {'lane': lane_id, 'status': status, 'title': label,
205
308
  'message': f'{first}\nlanes-launch lane {lane_id}', 'priority': PRIORITY.get(status, 3)}
206
- template = config.get('action_url') or ''
207
- if template:
208
- url = template.replace('{lane}', urllib.parse.quote(lane_id, safe=''))
309
+ url = action_url(config.get('action_url') or '', lane_id)
310
+ if url:
209
311
  note['click'] = url
210
312
  note['actions'] = [{'action': 'view', 'label': 'Open lane', 'url': url}]
211
313
  return note
212
314
 
213
315
 
316
+ def _size(size):
317
+ if size < 1024:
318
+ return f'{size} B'
319
+ return f'{size / 1024:.1f} KiB' if size < 1024 * 1024 else f'{size / (1024 * 1024):.1f} MiB'
320
+
321
+
322
+ def published_note(lane_id, meta, names, config):
323
+ """The push for a document stored from lane `lane_id` (`meta` its docs_store meta): the lane's name, the kind and
324
+ the size, and the document's page (module docstring) — never its title, name or content."""
325
+ name = _text(names.get(lane_id) or lane_id)
326
+ first = _line(f"{name} published a document ({_text(meta.get('kind'))}, {_size(int(meta.get('bytes') or 0))})")
327
+ note = {'lane': lane_id, 'status': 'published', 'doc': meta['id'], 'title': PUBLISHED_TITLE,
328
+ 'message': f"{first}\n{docs_store.URL.format(meta['id'])}", 'priority': PUBLISHED_PRIORITY}
329
+ url = action_url(config.get('action_url') or '', lane_id, meta['id'])
330
+ if url:
331
+ note['click'] = url
332
+ note['actions'] = [{'action': 'view', 'label': 'Open document', 'url': url}]
333
+ return note
334
+
335
+
336
+ def notify_published(lane_id, meta, names=None, conf_path=None, state=None, now=None, opener=None):
337
+ """Send the `published` push for a stored document (the docs service's pick-up calls it, W4) -> the note sent, or
338
+ None: sending disabled (no valid notify.conf at `conf_path`, else $LANES_NOTIFY_CONF, else the default), somebody
339
+ at a desk (device_active over `state`, default L.STATE), or the send failed (one journal line). Never raises for a
340
+ failed send; the caller rate-limits."""
341
+ config = read_config(conf_path or os.environ.get('LANES_NOTIFY_CONF') or DEFAULT_CONF)
342
+ if not config['enabled']:
343
+ return None
344
+ state = Path(state) if state is not None else L.STATE
345
+ now = time.time() if now is None else now
346
+ window = int(config.get('quiet_when_device_active_s', DEFAULT_QUIET) or 0)
347
+ if device_active(state / 'clients', window, now, state / 'devices'):
348
+ return None
349
+ note = published_note(lane_id, meta, names or {}, config)
350
+ return note if send(note, config, opener) else None
351
+
352
+
214
353
  def notify_changes(prev_status, new_status, config, names=None, now=None, clients_dir=None, devices_dir=None):
215
354
  """The notifications for lanes whose agent status ENTERED needs-you between two status.json documents.
216
355
  `prev_status` None = the first read: seed only, nothing is news. Empty when a desk device is present."""
@@ -427,6 +566,7 @@ def main(argv=None):
427
566
  note = {'lane': 'test', 'status': 'test', 'title': 'lanes', 'message': 'lanes notify: test notification',
428
567
  'priority': 3}
429
568
  return 0 if send(note, config) else 1
569
+ L.record_tree('notify', state) # W9 U0: the unit's tree; `--test` (a one-shot) never records
430
570
  watcher = Watcher(state, conf)
431
571
  if not watcher.config['enabled']:
432
572
  print(f'notify: {conf} has no valid url and topic; tracking only until it does', file=sys.stderr, flush=True)
@@ -3,9 +3,25 @@ import json
3
3
  import time
4
4
 
5
5
  import lanes_lib as L
6
+ import session_actions as A
6
7
 
7
8
  FILENAME = 'registered-sessions.json'
8
9
  VERSION = 1
10
+ # What tmux names a session by — the identity statusd publishes on an unregistered row and `register` checks.
11
+ IDENTITY_FORMAT = '#{session_id}\t#{session_created}'
12
+ # tmux saying in words that no session has the name (`_answer`): a target that may not fail ("can't find session"), or
13
+ # a targeted verb on a server holding no session at all ("no current target", `L.tmux_probe`'s DEF-967 note).
14
+ ABSENT_WORDS = ("can't find session", 'no current target')
15
+
16
+
17
+ class Unpublished(L.LanesError):
18
+ """The file was written; handing the session back or publishing the registry then failed (`lanes sync` publishes).
19
+ `record` is what the call returns when nothing fails — for `unregister`, the record with its `identity`, so the
20
+ undo still stands although the call raised (fix cycle 1 of S7, DEF-1826)."""
21
+
22
+ def __init__(self, message, record):
23
+ super().__init__(message)
24
+ self.record = record
9
25
 
10
26
 
11
27
  def key(entry):
@@ -13,6 +29,50 @@ def key(entry):
13
29
  return entry.get('container'), binding.get('socket'), binding.get('session')
14
30
 
15
31
 
32
+ def _identity_query(session):
33
+ return ['display-message', '-p', '-t', '=' + session + ':', IDENTITY_FORMAT]
34
+
35
+
36
+ def _ready(identity):
37
+ """`register`'s identity gate: a tmux session id `$<n>` and a creation time in digits -> (sid, created)."""
38
+ identity = identity if isinstance(identity, dict) else {}
39
+ sid, created = identity.get('session_id', ''), identity.get('created', '')
40
+ if not isinstance(sid, str) or not isinstance(created, str) or not sid.startswith('$') or not sid[1:].isdigit() or not created.isdigit():
41
+ raise L.LanesError('Session identity is not ready; wait for the next status update')
42
+ return sid, created
43
+
44
+
45
+ def _probe(container, socket, session):
46
+ """tmux's answer for one binding's identity: (outcome, text) of `L.tmux_probe` (never starts a server)."""
47
+ info = (L.read_json(L.STATE / 'containers.json', {}) or {}).get(container) or {}
48
+ return L.tmux_probe(info, socket, _identity_query(session), 5)
49
+
50
+
51
+ def _confirm(container, socket, session, identity):
52
+ """`register`'s live check: tmux must name this binding's session with exactly this identity, now."""
53
+ sid, created = _ready(identity)
54
+ outcome, observed = _probe(container, socket, session)
55
+ if outcome != 'ok' or observed.strip() != sid + '\t' + created:
56
+ raise L.LanesError('Session changed or could not be checked; nothing was registered')
57
+
58
+
59
+ def _answer(outcome, text):
60
+ """What tmux's answer to `_identity_query` says about the binding -> ('live', {session_id, created}),
61
+ ('absent', None) or ('unknown', None). Absent: no server (`noserver`), or tmux 3.4's answer for a name no session
62
+ has — exit 0 with every field empty, because display-message's target may fail (MEASURED 2026-10-07, tmux 3.4,
63
+ with other sessions on the server and with none) — or `ABSENT_WORDS`. Unknown: a timeout or any other error (they
64
+ say NOTHING about the server, `L.tmux_probe`), or an answer that is no identity."""
65
+ if outcome == 'noserver' or (outcome == 'ok' and not text.strip()):
66
+ return 'absent', None
67
+ if outcome == 'ok':
68
+ parts = text.strip().split('\t')
69
+ if len(parts) == 2 and parts[0][:1] == '$' and parts[0][1:].isdigit() and parts[1].isdigit():
70
+ return 'live', {'session_id': parts[0], 'created': parts[1]}
71
+ elif outcome == 'error' and any(words in text.lower() for words in ABSENT_WORDS):
72
+ return 'absent', None
73
+ return 'unknown', None
74
+
75
+
16
76
  def eligible(entry):
17
77
  return entry.get('holder') == 'tmux' and bool(entry.get('tmux')) and not entry.get('legacy')
18
78
 
@@ -77,6 +137,17 @@ def apply(registry, data=None):
77
137
  return registry
78
138
 
79
139
 
140
+ def _publish(registry, what):
141
+ """`publish_registry` for an action a person just took -> the containers whose own `~/.lz/registry.json` it
142
+ refused (DEF-1796), which the caller raises as `L.ProjectionRefused` once it holds its result. A publish that
143
+ raises (registry.json itself not written) is the same `<what> saved; run lanes sync …` refusal, without a result:
144
+ the change is saved but nothing shows it yet."""
145
+ try:
146
+ return L.publish_registry(registry) or []
147
+ except OSError as error:
148
+ raise L.LanesError(f'{what} saved; run lanes sync to finish publishing: {error}') from error
149
+
150
+
80
151
  def rename(lane_id, new_name):
81
152
  """Display name of one registered session — this file is its only name authority (`apply` takes the lane's
82
153
  name from the record). Only `name` changes: the id, binding, workspace and folder stay, so view targets,
@@ -92,12 +163,12 @@ def rename(lane_id, new_name):
92
163
  registry = apply(L.load_registry(), data)
93
164
  registry['generated_at'] = time.time()
94
165
  L.atomic_write(L.workspaces_dir() / FILENAME, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
95
- try:
96
- L.publish_registry(registry)
97
- except OSError as error:
98
- raise L.LanesError('Rename saved; run lanes sync to finish publishing: ' + str(error)) from error
99
- return next((lane for w in registry['workspaces'] for lane in w['lanes'] if lane['id'] == lane_id),
166
+ refused = _publish(registry, 'Rename')
167
+ lane = next((lane for w in registry['workspaces'] for lane in w['lanes'] if lane['id'] == lane_id),
100
168
  dict(record))
169
+ if refused:
170
+ raise L.ProjectionRefused('Rename', refused, lane)
171
+ return lane
101
172
 
102
173
 
103
174
  def register(workspace, entry, destination=None):
@@ -123,26 +194,153 @@ def register(workspace, entry, destination=None):
123
194
  L._require(L.IDENT, container, 'container')
124
195
  L._require(L.IDENT, socket, 'tmux socket')
125
196
  L._require(L.TMUX_SESSION, name, 'tmux session')
126
- identity = actual.get('identity') or {}
127
- sid, created = identity.get('session_id', ''), identity.get('created', '')
128
- if not isinstance(sid, str) or not isinstance(created, str) or not sid.startswith('$') or not sid[1:].isdigit() or not created.isdigit():
129
- raise L.LanesError('Session identity is not ready; wait for the next status update')
130
- info = (L.read_json(L.STATE / 'containers.json', {}) or {}).get(container) or {}
131
- outcome, observed = L.tmux_probe(info, socket, ['display-message', '-p', '-t', '=' + name + ':',
132
- '#{session_id}\t#{session_created}'], 5)
133
- if outcome != 'ok' or observed.strip() != sid + '\t' + created:
134
- raise L.LanesError('Session changed or could not be checked; nothing was registered')
197
+ _confirm(container, socket, name, actual.get('identity') or {})
135
198
  record = {'id': actual['id'], 'workspace': destination, 'name': L._title(actual['name']),
136
199
  'container': container, 'tmux': dict(actual['tmux']),
137
200
  'folder': L._require(L.SAFE_PATH, board.get('cwd') or L.CONTAINER_HOME, 'working directory')}
138
201
  if any(item['id'] == record['id'] or key(item) == key(record) for item in data['sessions']):
139
202
  raise L.LanesError(f'Registration already saved; check {FILENAME} and run lanes sync')
140
203
  data['sessions'].append(record)
141
- registry = apply(registry, data)
204
+ return _save(registry, data, board, record['id'])
205
+
206
+
207
+ def _save(registry, data, board, lane_id):
208
+ """Write the file, then publish the registry it gives -> the lane as `board` lists it. Holding registry_lock().
209
+ A publication that fails raises `Unpublished` carrying that lane: the record is saved, `lanes sync` publishes it.
210
+ A publication that a container refused its own `~/.lz/registry.json` (DEF-1796, `publish_registry`'s answer)
211
+ raises `L.ProjectionRefused` carrying that lane too: saved and published everywhere else, and said at once."""
212
+ registry = apply(registry, data)
213
+ registry['generated_at'] = time.time()
214
+ L.atomic_write(L.workspaces_dir() / FILENAME, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
215
+ lane = next(lane for lane in board['lanes'] if lane['id'] == lane_id)
216
+ try:
217
+ refused = L.publish_registry(registry) or []
218
+ except OSError as error:
219
+ raise Unpublished('Registration saved; run lanes sync to finish publishing: ' + str(error), lane) from error
220
+ if refused:
221
+ raise L.ProjectionRefused('Registration', refused, lane)
222
+ return lane
223
+
224
+
225
+ def ghost(record):
226
+ """The row statusd lists for this session once it is unregistered again — `session_actions.make_entry`'s shape:
227
+ the record's id and board (statusd's first listing of it takes both from `handback/`, whatever board its directory
228
+ names now — `session_actions.handed_back`), its name (handed back through `user-sessions/`) and the identity
229
+ `unregister` observed. The sidebar shows it under Unregistered at once, so the cursor that was on the lane stays
230
+ on it. None when `unregister` observed no live session (statusd will list nothing). Pure: no file is read or
231
+ written."""
232
+ identity = record.get('identity')
233
+ if not identity:
234
+ return None
235
+ return {'id': record['id'], 'name': record['name'], 'holder': 'tmux', 'container': record['container'],
236
+ 'workspace': record['workspace'], 'tmux': dict(record['tmux']), 'color': None, 'icon': '👻',
237
+ 'closable': True, 'identity': dict(identity)}
238
+
239
+
240
+ def _listed(data, lane_id):
241
+ """Whether `apply` lists this record as a lane. It does not when the record's workspace left the catalog, uses
242
+ another container, or a repository declares the binding: such a record is in the file and nowhere else."""
243
+ try:
244
+ registry = apply(L.load_registry(), data)
245
+ except L.LanesError:
246
+ return False
247
+ return any(lane['id'] == lane_id and lane.get('registered_session')
248
+ for board in registry['workspaces'] for lane in board['lanes'])
249
+
250
+
251
+ def unregister(lane_id):
252
+ """Take one added session out of registered-sessions.json — sidebar `u`, `lanes unregister <id>` (cockpit v3 S7,
253
+ LNS-65) — and publish the registry without it. Nothing is started, stopped, renamed or reattached: a session that
254
+ runs goes on as an UNREGISTERED one, under the same id, the same name and the same views. Declared lanes are not
255
+ here (their file is a repository's terminals.json: `session_actions.unregister_declared`).
256
+
257
+ tmux is asked first which session holds the binding (`_answer`). A live one is handed back
258
+ (`session_actions.hand_back`: its name, its board and id for statusd, its views — all before `publish_registry`
259
+ removes `resolve/<id>`, so a view that re-attaches in between still resolves). No session there (ended, container
260
+ down): nothing is handed back, statusd will list nothing. No usable answer (a timeout, an error that is not tmux
261
+ saying the session is absent): refused, nothing written — a session that runs would otherwise go back with no
262
+ identity, unnamed, and the undo would refuse it (fix cycle 1, DEF-1826). A record `apply` does not list (`_listed`)
263
+ is removed without asking tmux: nothing listed it, nothing moves.
264
+
265
+ -> the record exactly as it was stored, plus `identity` ({session_id, created}) when a session answered: what
266
+ `reregister` checks before it saves the record again. Once the record is out of the file, a failure to hand back or
267
+ publish raises `Unpublished` carrying that same result, so the undo still stands."""
268
+ with L.registry_lock():
269
+ data = load()
270
+ record = next((item for item in data['sessions'] if item['id'] == lane_id), None)
271
+ if record is None:
272
+ raise L.LanesError(f'{lane_id} is not a registered session in {FILENAME}; reload')
273
+ result, identity = dict(record), None
274
+ if _listed(data, lane_id):
275
+ state, identity = _answer(*_probe(*key(record)))
276
+ if state == 'unknown':
277
+ raise L.LanesError('Session could not be checked; nothing was unregistered')
278
+ if identity:
279
+ result['identity'] = identity
280
+ data['sessions'] = [item for item in data['sessions'] if item['id'] != lane_id]
281
+ registry = apply(L.load_registry(), data)
142
282
  registry['generated_at'] = time.time()
143
283
  L.atomic_write(L.workspaces_dir() / FILENAME, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
144
284
  try:
145
- L.publish_registry(registry)
285
+ if identity:
286
+ A.hand_back(record, identity)
287
+ refused = L.publish_registry(registry) or []
146
288
  except OSError as error:
147
- raise L.LanesError('Registration saved; run lanes sync to finish publishing: ' + str(error)) from error
148
- return next(lane for lane in board['lanes'] if lane['id'] == record['id'])
289
+ raise Unpublished('Unregistered; run lanes sync to finish publishing: ' + str(error), result) from error
290
+ if refused: # out of the file and registry.json; a container's own projection still lists it (DEF-1796)
291
+ raise L.ProjectionRefused('Unregistered', refused, result)
292
+ return result
293
+
294
+
295
+ def reregister(record):
296
+ """The undo of `unregister` — sidebar `z` within 10 s (cockpit v3 S7): save the record it returned again, the same
297
+ id, workspace, name, binding and folder, byte for byte, after the checks `register` makes. Refused, writing
298
+ nothing, when the binding is registered again meanwhile (a repository declaration or an added session), when its
299
+ workspace no longer uses that container, or when the session is not the one that was unregistered:
300
+
301
+ - `identity` given (a session ran when it was unregistered): `register`'s identity gate, then statusd's listing —
302
+ a session it lists under that binding must carry that identity — then tmux, now, must name exactly that session
303
+ (a session closed and re-created under the same name in the window is a different one);
304
+ - no `identity` (nothing ran there): it is restored only while nothing runs there now, which is the state it was
305
+ unregistered in — `_answer` says absent; a live session, or no usable answer, refuses.
306
+
307
+ A publication that fails after the record is saved raises `Unpublished` (the undo is done; `lanes sync` publishes)."""
308
+ saved = {'id': record.get('id'), 'workspace': record.get('workspace'), 'name': record.get('name'),
309
+ 'container': record.get('container'), 'tmux': dict(record.get('tmux') or {}),
310
+ 'folder': record.get('folder')}
311
+ with L.registry_lock():
312
+ data = load()
313
+ registry = apply(L.load_registry(), data)
314
+ for field in ('id', 'workspace', 'container'):
315
+ L._require(L.IDENT, saved[field], field)
316
+ L._title(saved['name'])
317
+ container, socket, session = key(saved)
318
+ L._require(L.IDENT, socket, 'tmux socket')
319
+ L._require(L.TMUX_SESSION, session, 'tmux session')
320
+ L._require(L.SAFE_PATH, saved['folder'], 'working directory')
321
+ entry = dict(saved, holder='tmux')
322
+ for board in registry['workspaces']:
323
+ if any(key(lane) == key(entry) for lane in board['lanes'] if lane.get('holder') == 'tmux'):
324
+ raise L.LanesError('Already registered in ' + (board.get('title') or board['id']))
325
+ board = next((w for w in destinations(registry, entry) if w['id'] == saved['workspace']), None)
326
+ if not board:
327
+ raise L.LanesError('Choose a workspace that uses this session’s container')
328
+ if any(item['id'] == saved['id'] or key(item) == key(saved) for item in data['sessions']):
329
+ raise L.LanesError(f'Registration already saved; check {FILENAME} and run lanes sync')
330
+ identity = record.get('identity')
331
+ if identity:
332
+ _ready(identity)
333
+ published = (L.read_json(L.STATE / 'status.json', {}) or {}).get('unregistered') or {}
334
+ if any(isinstance(item, dict) and key(item) == key(saved) and item.get('identity') != identity
335
+ for entries in published.values() for item in entries or ()):
336
+ raise L.LanesError('Session changed; nothing was registered')
337
+ _confirm(container, socket, session, identity)
338
+ else:
339
+ state, _identity = _answer(*_probe(container, socket, session))
340
+ if state == 'live':
341
+ raise L.LanesError('A session runs under that name now; add it with a (lanes register); '
342
+ 'nothing was registered')
343
+ if state == 'unknown':
344
+ raise L.LanesError('Session could not be checked; nothing was registered')
345
+ data['sessions'].append(saved)
346
+ return _save(registry, data, board, saved['id'])