@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
@@ -19,6 +19,11 @@ One JSON file per enrolled device under ~/.local/state/lanes/members/ (directory
19
19
  * `invited_as` (optional, additive; an invite record only) is the invite's own {name, email} when a TRUSTED tailnet
20
20
  caller redeemed it (request mode): the owner is then the caller, whoever the invite was minted for, and this keeps
21
21
  who it was minted for beside it. Absent when no trusted caller redeemed (the owner then came from the invite).
22
+ * `client_version` (optional, additive; W9 K1, LNS-86) is the release of the client that enrolled the device, as
23
+ install.sh sent it to enrold (`POST /redeem`, or the request an approval decides): 1-32 characters of
24
+ [A-Za-z0-9._+-] (CLIENT_VERSION, the rule enrold applies to every body). Enrold stores nothing when the body
25
+ named none or a value outside that rule. It is appended after the schema, before `invited_as`; absent on what
26
+ `lanes keys` writes. Shape only: it is no authority, but a reader validates it like every text field.
22
27
  * Every text field can reach a terminal (`lanes device list`, `lanes keys list`) and the file is UTF-8: a character
23
28
  of Unicode category Cc (C0, DEL, C1), Cf (format: the bidi marks, embeddings and isolates, zero-width characters,
24
29
  the BOM, the soft hyphen), Cs (a lone surrogate — what json.loads makes of a body's "\\ud800"), Co (private use),
@@ -49,6 +54,9 @@ MAX_NAME = 128
49
54
  MAX_ID = 64
50
55
  REFUSED_CATEGORIES = frozenset(('Cc', 'Cf', 'Cs', 'Co', 'Cn', 'Zl', 'Zp'))
51
56
  _EMAIL = re.compile(r'[^@\s]+@[^@\s]+')
57
+ # A client's release as an enrolment body states it (`client_version`): enrold's rule for a request file and a member
58
+ # record alike (enrold._CLIENT_VERSION is this object). Its alphabet holds no blank, quote or control character.
59
+ CLIENT_VERSION = re.compile(r'[A-Za-z0-9._+-]{1,32}')
52
60
  _STAMP = re.compile(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ', re.ASCII)
53
61
 
54
62
 
@@ -153,6 +161,9 @@ def validate(document, stem=None):
153
161
  if via != 'invite':
154
162
  raise L.LanesError(f'invited_as on a record enrolled via {via or "nothing"}')
155
163
  validate_invited_as(document['invited_as'])
164
+ client = document.get('client_version')
165
+ if client is not None and not (isinstance(client, str) and CLIENT_VERSION.fullmatch(client)):
166
+ raise L.LanesError('client_version is not a release (1-32 characters of A-Z, a-z, 0-9 and . _ + -)')
156
167
  workspaces = document.get('workspaces')
157
168
  if workspaces is not None:
158
169
  if not isinstance(workspaces, list):
@@ -168,9 +179,10 @@ def validate(document, stem=None):
168
179
 
169
180
 
170
181
  def record(device, pubkey, pubkey_sha256, via='manual', owner=None, approved_by=None, invite_id=None,
171
- request_id=None, workspaces=(), now=None, invited_as=None):
182
+ request_id=None, workspaces=(), now=None, invited_as=None, client_version=None):
172
183
  """A members/<device>.json document, exactly the schema in its order. The caller supplies the key fields (keys.py
173
- computes the fingerprint and checks the key); an invite needs its invite_id, a request its request_id. `invited_as`
184
+ computes the fingerprint and checks the key); an invite needs its invite_id, a request its request_id.
185
+ `client_version` (the enrolling client's release, W9 K1) is appended after the schema when given; `invited_as`
174
186
  (an invite redeemed by a trusted caller only) is appended last when given."""
175
187
  ids = {'invite_id': invite_id, 'request_id': request_id}
176
188
  if via not in VIA:
@@ -190,6 +202,8 @@ def record(device, pubkey, pubkey_sha256, via='manual', owner=None, approved_by=
190
202
  document[field] = ids[field]
191
203
  document['workspaces'] = ([] if workspaces is None else
192
204
  list(workspaces) if isinstance(workspaces, (list, tuple)) else workspaces)
205
+ if client_version is not None:
206
+ document['client_version'] = client_version # validate() below refuses one that is no release
193
207
  if invited_as is not None:
194
208
  document['invited_as'] = validate_invited_as(invited_as)
195
209
  return validate(document)
@@ -19,6 +19,16 @@ 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
34
  when `action_url` (a template over `{lane}`) is configured.
@@ -69,6 +79,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
69
79
  import enrold as E # noqa: E402
70
80
  import lanes_lib as L # noqa: E402
71
81
  import tokens # noqa: E402
82
+ import users as U # noqa: E402
72
83
 
73
84
  TRIGGERS = ('needs_input', 'needs_approval', 'errored') # the sidebar's NEEDS_YOU (sidebar.ATTENTION[0][3])
74
85
  LABELS = tokens.MOBILE_AGENT_LABELS # the phone's words: Needs you / Needs approval / Error
@@ -83,6 +94,7 @@ TIMEOUT = 5
83
94
  PRESENCE = '*.pid' # clients/ files only `lanes attach` writes
84
95
  SEEN = '.seen.json' # a sidebar's opened-lane record; desk devices only
85
96
  # (a .pid of any age, devices/<device>.json not mobile)
97
+ # users/<login>.json written by a desk device counts too
86
98
  ENROL_DIR = 'enrol-requests' # STATE/<this>/<id>.json: lanes-enrold's requests
87
99
  ENROL_SEEN = 'notify.enrol-seen.json' # STATE/<this>: the request ids already announced
88
100
  ENROL_TITLE = 'Enrol request'
@@ -163,37 +175,104 @@ def is_mobile(devices_dir, device):
163
175
  return isinstance(meta, dict) and bool(meta.get('mobile'))
164
176
 
165
177
 
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)
178
+ def _names(directory, suffix):
179
+ try:
180
+ return {name[:-len(suffix)] for name in os.listdir(directory) if name.endswith(suffix)}
181
+ except OSError:
182
+ return set()
178
183
 
179
- def recent(path):
184
+
185
+ def presence_marks(clients_dir, devices_dir=None, users_dir=None, members_dir=None, profiles=None):
186
+ """[(login or None, mtime)]: every mark of someone at a device, whoever's window decides about it.
187
+ * `clients/<device>.<client>.pid` (`lanes attach`, any device, a phone included);
188
+ * `clients/<device>.seen.json` of a DESK device (a lane opened from its sidebar);
189
+ * `users/<login>.json` whose `updated_by_device` is a DESK device (cockpit v3 R7, LNS-80): the sidebar's mark
190
+ of a lane opened there and every other profile write made at that device (`lanes attach`, `lanes next|prev`,
191
+ `lanes here`) — so presence does not hang on the seen file staying in clients/. A change no device made is
192
+ nobody's: a CLI writer (`cli:<command>`, `cli:migration`: users.writer_device is None) or null (W9 review F3).
193
+ A desk device has a `<device>.*.pid` of any age and a `devices/<device>.json` that is not `mobile: true`
194
+ (DEF-1533: a phone's picker never counts, however the phone attached). The login of a device mark is its owner
195
+ (users.login_for over `members/` and `devices/<device>.json`; a pid file belongs to the longest known device name
196
+ it starts with, None when none or nobody owns it); a profile mark's is the profile's. The directories default to
197
+ the siblings of `clients_dir` (all four live in STATE); `profiles` is users.load_all(users_dir)'s list when the
198
+ caller has read it. A directory that cannot be read gives no marks."""
199
+ directory = Path(clients_dir)
200
+ state = directory.parent
201
+ devices_dir = state / 'devices' if devices_dir is None else Path(devices_dir)
202
+ users_dir = state / 'users' if users_dir is None else Path(users_dir)
203
+ members_dir = state / 'members' if members_dir is None else Path(members_dir)
204
+ owners, desks = {}, {}
205
+
206
+ def owner(device):
207
+ if device is None:
208
+ return None
209
+ if device not in owners:
210
+ meta = L.read_json(devices_dir / f'{device}.json')
211
+ owners[device] = U.login_for(device, meta if isinstance(meta, dict) else {}, members_dir)
212
+ return owners[device]
213
+
214
+ def desk(device):
215
+ if device not in desks:
216
+ desks[device] = (bool(device) and any(directory.glob(glob.escape(device) + '.' + PRESENCE))
217
+ and not is_mobile(devices_dir, device))
218
+ return desks[device]
219
+
220
+ def mtime(path):
180
221
  try:
181
- return now - path.stat().st_mtime <= window
222
+ return path.stat().st_mtime
182
223
  except OSError:
183
- return False
224
+ return None
184
225
 
226
+ marks = []
227
+ known = {name for name in _names(devices_dir, '.json') | _names(members_dir, '.json') if L.IDENT.match(name)}
185
228
  try:
186
- directory = Path(clients_dir)
187
- if any(recent(path) for path in directory.glob(PRESENCE)):
188
- return True
229
+ for path in directory.glob(PRESENCE):
230
+ at = mtime(path)
231
+ device = max((name for name in known if path.name.startswith(name + '.')
232
+ and len(path.name) > len(name) + len('..pid')), key=len, default=None)
233
+ if at is not None:
234
+ marks.append((owner(device), at))
189
235
  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
236
+ device, at = path.name[:-len(SEEN)], mtime(path)
237
+ if at is not None and desk(device):
238
+ marks.append((owner(device), at))
194
239
  except OSError:
195
- return False
196
- return False
240
+ pass
241
+ for profile in U.load_all(users_dir)[0] if profiles is None else profiles:
242
+ device = U.writer_device(profile['updated_by_device'])
243
+ if device and desk(device):
244
+ at = mtime(U.path(profile['login'], users_dir))
245
+ if at is not None:
246
+ marks.append((profile['login'], at))
247
+ return marks
248
+
249
+
250
+ def quiet_windows(profiles):
251
+ """{login: notify.quiet_s} of the profiles that set their own quiet window (DM-LANES-USER-PROFILE `notify`)."""
252
+ return {profile['login']: profile['notify']['quiet_s'] for profile in profiles if 'quiet_s' in profile['notify']}
253
+
254
+
255
+ def device_active(clients_dir, window, now, devices_dir=None, users_dir=None, members_dir=None):
256
+ """True when someone was at a device recently enough to mute a push (presence_marks). `window` is the host's
257
+ `quiet_when_device_active_s`. Each mark is recent within its person's own window — their profile's
258
+ `notify.quiet_s` when set, else `window`; a device nobody owns always `window`. When the recent marks all
259
+ belong to one person (or all to devices nobody owns) that is presence; when they belong to several, the host
260
+ `window` decides for all of them (any mark within it). A window of 0 makes nothing recent: a person's
261
+ `quiet_s` 0 never mutes their own pushes, a host `window` of 0 never mutes anyone's but a person present alone
262
+ who set a `quiet_s` of their own. A missing directory is nobody present. Cockpit v3 R7 (LNS-80)."""
263
+ state = Path(clients_dir).parent
264
+ users_dir = state / 'users' if users_dir is None else Path(users_dir)
265
+
266
+ def within(at, span):
267
+ return span > 0 and now - at <= span
268
+
269
+ profiles = U.load_all(users_dir)[0]
270
+ marks = presence_marks(clients_dir, devices_dir, users_dir, members_dir, profiles)
271
+ quiet = quiet_windows(profiles)
272
+ recent = {login for login, at in marks if within(at, quiet.get(login, window) if login else window)}
273
+ if len(recent) <= 1:
274
+ return bool(recent)
275
+ return any(within(at, window) for _login, at in marks)
197
276
 
198
277
 
199
278
  def _note(lane_id, status, agent, names, config):
@@ -427,6 +506,7 @@ def main(argv=None):
427
506
  note = {'lane': 'test', 'status': 'test', 'title': 'lanes', 'message': 'lanes notify: test notification',
428
507
  'priority': 3}
429
508
  return 0 if send(note, config) else 1
509
+ L.record_tree('notify', state) # W9 U0: the unit's tree; `--test` (a one-shot) never records
430
510
  watcher = Watcher(state, conf)
431
511
  if not watcher.config['enabled']:
432
512
  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'])