@vibelord/lanes 1.0.0 → 1.0.2

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 (56) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +92 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +928 -150
  5. package/payload/lanes/client/ghostty/common.conf +3 -0
  6. package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
  7. package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
  8. package/payload/lanes/client/ghostty/omarchy.conf +3 -2
  9. package/payload/lanes/client/lanes-launch +62 -5
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +8 -0
  12. package/payload/lanes/container/hooks/claude-settings.json +10 -0
  13. package/payload/lanes/container/hooks/codex-hooks.json +22 -0
  14. package/payload/lanes/container/lane +326 -25
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +158 -6
  17. package/payload/lanes/docs/DESIGN.md +250 -0
  18. package/payload/lanes/docs/INSTALL.md +39 -0
  19. package/payload/lanes/docs/RUNBOOK.md +507 -24
  20. package/payload/lanes/docs/SHARING.md +1 -1
  21. package/payload/lanes/holders/exec.sh +2 -5
  22. package/payload/lanes/holders/ssh.sh +18 -0
  23. package/payload/lanes/holders.lock +3 -0
  24. package/payload/lanes/install/enable-phone.sh +15 -6
  25. package/payload/lanes/install/enable-web.sh +19 -6
  26. package/payload/lanes/install/host-install.sh +10 -1
  27. package/payload/lanes/install/services.py +30 -3
  28. package/payload/lanes/install.sh +108 -3
  29. package/payload/lanes/libexec/agent_state.py +246 -0
  30. package/payload/lanes/libexec/connection.py +178 -0
  31. package/payload/lanes/libexec/cursor_sync.py +147 -6
  32. package/payload/lanes/libexec/device_sync.py +134 -0
  33. package/payload/lanes/libexec/economy_poll.py +685 -0
  34. package/payload/lanes/libexec/guard.py +27 -1
  35. package/payload/lanes/libexec/lanes_lib.py +436 -36
  36. package/payload/lanes/libexec/notify.py +317 -0
  37. package/payload/lanes/libexec/registrations.py +148 -0
  38. package/payload/lanes/libexec/session_actions.py +53 -22
  39. package/payload/lanes/libexec/sidebar.py +1190 -186
  40. package/payload/lanes/libexec/statusd.py +352 -90
  41. package/payload/lanes/libexec/tokens.py +310 -0
  42. package/payload/lanes/libexec/tokens.sh +11 -0
  43. package/payload/lanes/libexec/view.sh +22 -11
  44. package/payload/lanes/systemd/lanes-economy.service +20 -0
  45. package/payload/lanes/systemd/lanes-notify.service +19 -0
  46. package/payload/lanes/systemd/lanes-sync.service +3 -0
  47. package/payload/lanes/web/README.md +96 -2
  48. package/payload/lanes/web/browser.css +22 -0
  49. package/payload/lanes/web/browser.js +73 -0
  50. package/payload/lanes/web/index.html +2 -1
  51. package/payload/lanes/web/mobile.css +108 -221
  52. package/payload/lanes/web/mobile.js +105 -55
  53. package/payload/lanes/workspaces.example/README.md +9 -3
  54. package/payload/lanes/zellij/config.kdl +128 -19
  55. package/payload/manifest.json +7 -3
  56. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env python3
2
+ """Opt-in push notifications: one ntfy message per lane per transition into needs-you (plan Task 14).
3
+
4
+ The desktop sidebar already raises one OSC 9 per transition while a terminal shows it (spec §4.9); this is the same
5
+ event for the operator who is away from every terminal. `lanes-notify.service` (opt-in — no installer enables it)
6
+ re-reads `STATE/status.json` when its mtime changes (checked every `--interval` seconds, 3 in the unit) and POSTs
7
+ to an ntfy server when a lane's `agent.status` ENTERS `needs_input`, `needs_approval` or `errored` — the sidebar's
8
+ NEEDS_YOU set. The six-word `state` is never read: the agent status is the only input.
9
+
10
+ * A status that stays the same is not news; a change between two needs-you statuses is (input → approval).
11
+ * The first document the service reads only seeds it: lanes that were already waiting when it (re)started are
12
+ not announced, so a restart never sends a burst.
13
+ * Presence suppression: nothing is sent while any device was at a desk within `quiet_when_device_active_s`
14
+ (default 120 s) — measured as the mtime of `STATE/clients/<device>.<client>.pid` (written only by
15
+ `lanes attach`), or of `STATE/clients/<device>.seen.json` (written by a sidebar when the operator opens a
16
+ lane) for a DESK device only: one with a `<device>.*.pid` of any age whose `STATE/devices/<device>.json` is not
17
+ `mobile: true`. Phone presence is decided by the device's mobile layout, not by how it attached: a Safari phone
18
+ has no `.pid`, and an SSH-only phone that ran `lanes attach phone --mobile` is still mobile, so opening a lane
19
+ from the phone's picker never mutes pushes (DEF-1452). A phone's own attach within the window still counts.
20
+ Typing in a lane touches neither file, so this is "attached or opened a lane at a desk recently", not "is
21
+ looking now" (DEF-1451). A suppressed transition is consumed, never sent later.
22
+ * Payload: `<lane name>: <agent task> — <status>` and, on a second line, the client command that opens the lane
23
+ (`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.
25
+ * A failed send is one journal line (lane id and the failure class — never the topic or the bearer) and is not
26
+ retried; it never stops the service.
27
+
28
+ Config `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` overrides the path), `key=value` lines, `#` comments,
29
+ re-read when its mtime changes: `url` (ntfy server, http/https, required), `topic` (`[A-Za-z0-9_-]{1,64}`,
30
+ required), `token_file` (a file holding an ntfy access token, optional — sent as a bearer, never logged; an
31
+ unreadable one disables sending rather than sending unauthenticated), `quiet_when_device_active_s` (default 120;
32
+ 0 never suppresses), `action_url` (optional template, e.g. `https://lanes.example/?lane={lane}`). Without a valid
33
+ `url` and `topic` the service only tracks transitions and sends nothing.
34
+
35
+ Answering a `PermissionRequest`-held approval FROM the phone needs a request/response channel back into the hook;
36
+ that is a follow-up design, not part of this module.
37
+ """
38
+ import argparse
39
+ import glob
40
+ import json
41
+ import os
42
+ import re
43
+ import sys
44
+ import time
45
+ import urllib.error
46
+ import urllib.parse
47
+ import urllib.request
48
+ from pathlib import Path
49
+
50
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
51
+ import lanes_lib as L # noqa: E402
52
+ import tokens # noqa: E402
53
+
54
+ TRIGGERS = ('needs_input', 'needs_approval', 'errored') # the sidebar's NEEDS_YOU (sidebar.ATTENTION[0][3])
55
+ LABELS = tokens.MOBILE_AGENT_LABELS # the phone's words: Needs you / Needs approval / Error
56
+ PRIORITY = {'needs_approval': 4, 'errored': 4, 'needs_input': 3}
57
+ DEFAULT_QUIET = 120
58
+ DEFAULT_CONF = '~/.config/lanes/notify.conf'
59
+ KEYS = ('url', 'topic', 'token_file', 'quiet_when_device_active_s', 'action_url')
60
+ TOPIC = re.compile(r'^[A-Za-z0-9_-]{1,64}$')
61
+ UNSAFE = re.compile(r'[\x00-\x1f\x7f-\x9f]') # C0, DEL, C1: never inside a push line
62
+ TEXT_MAX = 200 # characters of the first payload line
63
+ TIMEOUT = 5
64
+ PRESENCE = '*.pid' # clients/ files only `lanes attach` writes
65
+ SEEN = '.seen.json' # a sidebar's opened-lane record; desk devices only
66
+ # (a .pid of any age, devices/<device>.json not mobile)
67
+
68
+
69
+ def _text(value):
70
+ return UNSAFE.sub('', str(value or ''))
71
+
72
+
73
+ def read_config(path):
74
+ """notify.conf -> {url, topic, token_file, token, quiet_when_device_active_s, action_url, enabled}. Never raises."""
75
+ raw = {}
76
+ try:
77
+ lines = Path(os.path.expanduser(str(path))).read_text().splitlines()
78
+ except (OSError, UnicodeDecodeError):
79
+ lines = []
80
+ for line in lines:
81
+ line = line.strip()
82
+ if not line or line.startswith('#') or '=' not in line:
83
+ continue
84
+ key, value = (part.strip() for part in line.split('=', 1))
85
+ if key in KEYS:
86
+ raw[key] = value
87
+ try:
88
+ quiet = max(0, int(raw.get('quiet_when_device_active_s', DEFAULT_QUIET)))
89
+ except ValueError:
90
+ quiet = DEFAULT_QUIET
91
+ url = raw.get('url', '').rstrip('/')
92
+ topic = raw.get('topic', '')
93
+ config = {'url': url, 'topic': topic, 'token_file': raw.get('token_file', ''), 'token': None,
94
+ 'quiet_when_device_active_s': quiet, 'action_url': raw.get('action_url', '')}
95
+ enabled = url.startswith(('http://', 'https://')) and bool(TOPIC.match(topic))
96
+ if enabled and config['token_file']:
97
+ try:
98
+ config['token'] = Path(os.path.expanduser(config['token_file'])).read_text().strip() or None
99
+ except (OSError, UnicodeDecodeError):
100
+ config['token'] = None
101
+ enabled = config['token'] is not None
102
+ config['enabled'] = enabled
103
+ return config
104
+
105
+
106
+ def agent_statuses(document):
107
+ """status.json -> {lane id: agent status} for lanes with an agent{} (the six-word `state` is never read)."""
108
+ lanes = document.get('lanes') if isinstance(document, dict) else None
109
+ out = {}
110
+ for lane_id, entry in (lanes.items() if isinstance(lanes, dict) else ()):
111
+ agent = entry.get('agent') if isinstance(entry, dict) else None
112
+ if isinstance(agent, dict) and isinstance(agent.get('status'), str):
113
+ out[lane_id] = agent['status']
114
+ return out
115
+
116
+
117
+ def lane_names(registry):
118
+ """registry.json -> {lane id: display name} over every workspace's lanes."""
119
+ out = {}
120
+ workspaces = registry.get('workspaces') if isinstance(registry, dict) else None
121
+ for ws in workspaces if isinstance(workspaces, list) else ():
122
+ if not isinstance(ws, dict):
123
+ continue
124
+ for lane in ws.get('lanes') or ():
125
+ if isinstance(lane, dict) and isinstance(lane.get('id'), str) and lane.get('name'):
126
+ out[lane['id']] = str(lane['name'])
127
+ return out
128
+
129
+
130
+ def is_mobile(devices_dir, device):
131
+ """True when `STATE/devices/<device>.json` says `mobile: true` — the phone layout `lanes prepare|attach
132
+ --mobile` created, the same mark the device's sidebar reads. Missing or unreadable meta is not mobile."""
133
+ meta = L.read_json(Path(devices_dir) / f'{device}.json')
134
+ return isinstance(meta, dict) and bool(meta.get('mobile'))
135
+
136
+
137
+ def device_active(clients_dir, window, now, devices_dir=None):
138
+ """True when a device attached (`<device>.<client>.pid`) or a desk device opened a lane (`<device>.seen.json`)
139
+ within `window` seconds of `now`. `seen.json` counts only for a device that has a `<device>.*.pid` of any age
140
+ (the mark of `lanes attach`) AND whose `devices_dir/<device>.json` is not `mobile: true`. The mobile flag is
141
+ the authoritative phone mark: a Safari phone never runs `lanes attach`, but an SSH-only phone may
142
+ (`lanes attach phone --mobile`, RUNBOOK) and its pid is never removed — so a lane opened from the phone's picker
143
+ never mutes the phone's own pushes, however it attached (DEF-1452). `devices_dir` defaults to the `devices`
144
+ directory beside `clients_dir` (both live in STATE). A missing directory is nobody present; a window of 0 is
145
+ never present."""
146
+ if window <= 0:
147
+ return False
148
+ devices_dir = Path(clients_dir).parent / 'devices' if devices_dir is None else Path(devices_dir)
149
+
150
+ def recent(path):
151
+ try:
152
+ return now - path.stat().st_mtime <= window
153
+ except OSError:
154
+ return False
155
+
156
+ try:
157
+ directory = Path(clients_dir)
158
+ if any(recent(path) for path in directory.glob(PRESENCE)):
159
+ return True
160
+ for path in directory.glob('*' + SEEN):
161
+ device = path.name[:-len(SEEN)]
162
+ if (device and recent(path) and any(directory.glob(glob.escape(device) + '.' + PRESENCE))
163
+ and not is_mobile(devices_dir, device)):
164
+ return True
165
+ except OSError:
166
+ return False
167
+ return False
168
+
169
+
170
+ def _note(lane_id, status, agent, names, config):
171
+ label = LABELS.get(status, status)
172
+ name = _text(names.get(lane_id) or lane_id)
173
+ task = _text(agent.get('name'))
174
+ first = f'{name}: {task} — {label}' if task else f'{name} — {label}'
175
+ if len(first) > TEXT_MAX:
176
+ first = first[:TEXT_MAX - 1] + '…'
177
+ note = {'lane': lane_id, 'status': status, 'title': label,
178
+ 'message': f'{first}\nlanes-launch lane {lane_id}', 'priority': PRIORITY.get(status, 3)}
179
+ template = config.get('action_url') or ''
180
+ if template:
181
+ url = template.replace('{lane}', urllib.parse.quote(lane_id, safe=''))
182
+ note['click'] = url
183
+ note['actions'] = [{'action': 'view', 'label': 'Open lane', 'url': url}]
184
+ return note
185
+
186
+
187
+ def notify_changes(prev_status, new_status, config, names=None, now=None, clients_dir=None, devices_dir=None):
188
+ """The notifications for lanes whose agent status ENTERED needs-you between two status.json documents.
189
+ `prev_status` None = the first read: seed only, nothing is news. Empty when a desk device is present."""
190
+ if prev_status is None:
191
+ return []
192
+ before = agent_statuses(prev_status)
193
+ lanes = new_status.get('lanes') if isinstance(new_status, dict) else None
194
+ changed = [(lane_id, status) for lane_id, status in agent_statuses(new_status).items()
195
+ if status in TRIGGERS and before.get(lane_id) != status]
196
+ if not changed:
197
+ return []
198
+ now = time.time() if now is None else now
199
+ window = int(config.get('quiet_when_device_active_s', DEFAULT_QUIET) or 0)
200
+ if device_active(clients_dir if clients_dir is not None else L.STATE / 'clients', window, now, devices_dir):
201
+ return []
202
+ names = names or {}
203
+ return [_note(lane_id, status, lanes[lane_id]['agent'], names, config) for lane_id, status in sorted(changed)]
204
+
205
+
206
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
207
+ def redirect_request(self, *_args, **_kwargs): # a redirect would carry the bearer elsewhere: refuse it
208
+ return None
209
+
210
+
211
+ def default_opener():
212
+ return urllib.request.build_opener(_NoRedirect()).open
213
+
214
+
215
+ def send(note, config, opener=None):
216
+ """POST one notification as ntfy JSON to `config['url']`. True when the server accepted it. Any failure is one
217
+ stderr (journal) line naming the lane and the failure class — never the topic or the bearer — and False."""
218
+ body = {'topic': config['topic'], 'title': note['title'], 'message': note['message'],
219
+ 'priority': note.get('priority', 3)}
220
+ for key in ('click', 'actions'):
221
+ if key in note:
222
+ body[key] = note[key]
223
+ headers = {'Content-Type': 'application/json'}
224
+ if config.get('token'):
225
+ headers['Authorization'] = f"Bearer {config['token']}"
226
+ request = urllib.request.Request(config['url'], data=json.dumps(body).encode(), headers=headers, method='POST')
227
+ try:
228
+ with (opener or default_opener())(request, timeout=TIMEOUT) as response:
229
+ status = getattr(response, 'status', 200)
230
+ if not 200 <= status < 300:
231
+ raise urllib.error.HTTPError(config['url'], status, 'not accepted', {}, None)
232
+ return True
233
+ except urllib.error.HTTPError as error:
234
+ why = f'HTTP {error.code}'
235
+ except urllib.error.URLError as error:
236
+ why = f'unreachable ({type(error.reason).__name__})' if not isinstance(error.reason, str) else 'unreachable'
237
+ except TimeoutError:
238
+ why = 'timed out'
239
+ except OSError as error:
240
+ why = type(error).__name__
241
+ except Exception as error: # a push must never stop the service
242
+ why = type(error).__name__
243
+ print(f'notify: send failed for {note.get("lane")}: {why}', file=sys.stderr, flush=True)
244
+ return False
245
+
246
+
247
+ class Watcher:
248
+ """Tails status.json by mtime; sends what notify_changes returns. The config and registry are re-read when
249
+ their mtime changes. The first readable document seeds the previous one."""
250
+
251
+ def __init__(self, state, conf_path, opener=None, clock=time.time):
252
+ self.state = Path(state)
253
+ self.conf_path = Path(os.path.expanduser(str(conf_path)))
254
+ self.opener, self.clock = opener, clock
255
+ self.prev = None
256
+ self.stamps = {}
257
+ self.config = read_config(self.conf_path)
258
+ self.names = {}
259
+
260
+ def _changed(self, key, path):
261
+ try:
262
+ stamp = path.stat().st_mtime_ns
263
+ except OSError:
264
+ stamp = None
265
+ if self.stamps.get(key, 'unset') == stamp:
266
+ return False
267
+ self.stamps[key] = stamp
268
+ return True
269
+
270
+ def tick(self):
271
+ if self._changed('conf', self.conf_path):
272
+ self.config = read_config(self.conf_path)
273
+ if self._changed('registry', self.state / 'registry.json'):
274
+ self.names = lane_names(L.read_json(self.state / 'registry.json'))
275
+ if not self._changed('status', self.state / 'status.json'):
276
+ return []
277
+ document = L.read_json(self.state / 'status.json')
278
+ if not isinstance(document, dict):
279
+ return [] # a torn or missing file: keep the previous document
280
+ notes = notify_changes(self.prev, document, self.config, names=self.names, now=self.clock(),
281
+ clients_dir=self.state / 'clients', devices_dir=self.state / 'devices')
282
+ self.prev = document
283
+ if not self.config['enabled']:
284
+ return []
285
+ for note in notes:
286
+ send(note, self.config, self.opener)
287
+ return notes
288
+
289
+
290
+ def main(argv=None):
291
+ parser = argparse.ArgumentParser(prog='notify', description='push needs-you transitions to an ntfy topic')
292
+ parser.add_argument('--interval', type=float, default=3.0, help='seconds between status.json checks')
293
+ parser.add_argument('--test', action='store_true', help='send one test notification and exit (1 = not sent)')
294
+ args = parser.parse_args(argv)
295
+ conf = os.environ.get('LANES_NOTIFY_CONF') or DEFAULT_CONF
296
+ state = Path(os.environ.get('LANES_STATE') or L.STATE)
297
+ if args.test:
298
+ config = read_config(conf)
299
+ if not config['enabled']:
300
+ print(f'notify: {conf} has no valid url and topic (or its token_file is unreadable)', file=sys.stderr)
301
+ return 1
302
+ note = {'lane': 'test', 'status': 'test', 'title': 'lanes', 'message': 'lanes notify: test notification',
303
+ 'priority': 3}
304
+ return 0 if send(note, config) else 1
305
+ watcher = Watcher(state, conf)
306
+ if not watcher.config['enabled']:
307
+ print(f'notify: {conf} has no valid url and topic; tracking only until it does', file=sys.stderr, flush=True)
308
+ while True:
309
+ try:
310
+ watcher.tick()
311
+ except Exception as error: # one bad tick is a journal line; the next tick tries again
312
+ print(f'notify: {type(error).__name__}: {error}', file=sys.stderr, flush=True)
313
+ time.sleep(max(1.0, args.interval))
314
+
315
+
316
+ if __name__ == '__main__':
317
+ sys.exit(main())
@@ -0,0 +1,148 @@
1
+ """Durable, attach-only registrations for sessions adopted through the sidebar or CLI."""
2
+ import json
3
+ import time
4
+
5
+ import lanes_lib as L
6
+
7
+ FILENAME = 'registered-sessions.json'
8
+ VERSION = 1
9
+
10
+
11
+ def key(entry):
12
+ binding = entry.get('tmux') or {}
13
+ return entry.get('container'), binding.get('socket'), binding.get('session')
14
+
15
+
16
+ def eligible(entry):
17
+ return entry.get('holder') == 'tmux' and bool(entry.get('tmux')) and not entry.get('legacy')
18
+
19
+
20
+ def destinations(registry, entry):
21
+ if not eligible(entry):
22
+ return []
23
+ return [w for w in registry['workspaces'] if w.get('container') == entry.get('container') or
24
+ any(lane.get('container') == entry.get('container') for lane in w.get('lanes', []))]
25
+
26
+
27
+ def load():
28
+ path = L.workspaces_dir() / FILENAME
29
+ try:
30
+ data = json.loads(path.read_text())
31
+ except FileNotFoundError:
32
+ return {'version': VERSION, 'sessions': []}
33
+ except (OSError, ValueError) as error:
34
+ raise L.LanesError(f'Cannot read {path}: {error}') from error
35
+ if not isinstance(data, dict) or type(data.get('version')) is not int or data['version'] != VERSION:
36
+ raise L.LanesError(f'Unsupported {FILENAME} version; this server reads version {VERSION}')
37
+ if not isinstance(data.get('sessions'), list):
38
+ raise L.LanesError(f'Invalid sessions in {FILENAME}')
39
+ ids, bindings = set(), set()
40
+ for record in data['sessions']:
41
+ if not isinstance(record, dict) or not isinstance(record.get('tmux'), dict):
42
+ raise L.LanesError(f'Invalid registration in {FILENAME}')
43
+ for field in ('id', 'workspace', 'container'):
44
+ L._require(L.IDENT, record.get(field), field)
45
+ L._title(record.get('name'))
46
+ L._require(L.IDENT, record['tmux'].get('socket'), 'tmux socket')
47
+ L._require(L.TMUX_SESSION, record['tmux'].get('session'), 'tmux session')
48
+ L._require(L.SAFE_PATH, record.get('folder'), 'working directory')
49
+ if record['id'] in ids or key(record) in bindings:
50
+ raise L.LanesError(f'Duplicate registration in {FILENAME}')
51
+ ids.add(record['id']); bindings.add(key(record))
52
+ return data
53
+
54
+
55
+ def apply(registry, data=None):
56
+ """Overlay local registrations. Repository declarations win if they adopt the same binding."""
57
+ data = load() if data is None else data
58
+ by_id = {w['id']: w for w in registry['workspaces']}
59
+ for workspace in by_id.values():
60
+ # A stale repository can retain a previous board. Rebuild its local registrations from authority too.
61
+ workspace['lanes'] = [lane for lane in workspace['lanes'] if not lane.get('registered_session')]
62
+ lanes = [lane for w in by_id.values() for lane in w['lanes']]
63
+ bindings = {key(lane) for lane in lanes if lane.get('holder') == 'tmux'}
64
+ ids = {lane['id'] for lane in lanes}
65
+ for record in data['sessions']:
66
+ workspace = by_id.get(record['workspace'])
67
+ if not workspace or key(record) in bindings:
68
+ continue
69
+ lane = dict(record, holder='tmux', registered_session=True, icon='💻', color=None, create=None)
70
+ if workspace not in destinations(registry, lane):
71
+ workspace.setdefault('errors', []).append('Registered session belongs to a different container: ' + record['name'])
72
+ continue
73
+ if lane['id'] in ids:
74
+ raise L.LanesError('Registered session ID collides with another lane: ' + lane['id'])
75
+ workspace['lanes'].append(lane)
76
+ bindings.add(key(lane)); ids.add(lane['id'])
77
+ return registry
78
+
79
+
80
+ def rename(lane_id, new_name):
81
+ """Display name of one registered session — this file is its only name authority (`apply` takes the lane's
82
+ name from the record). Only `name` changes: the id, binding, workspace and folder stay, so view targets,
83
+ resolve files and `lane open <id>` keep working. The `user-sessions/` and `resolve.dynamic/` files an
84
+ UNREGISTERED session is renamed through are never read for a registered one (DEF-1475)."""
85
+ name = L._title(new_name)
86
+ with L.registry_lock():
87
+ data = load()
88
+ record = next((item for item in data['sessions'] if item['id'] == lane_id), None)
89
+ if record is None:
90
+ raise L.LanesError(f'{lane_id} is not a registered session in {FILENAME}; reload')
91
+ record['name'] = name
92
+ registry = apply(L.load_registry(), data)
93
+ registry['generated_at'] = time.time()
94
+ 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),
100
+ dict(record))
101
+
102
+
103
+ def register(workspace, entry, destination=None):
104
+ """Keep the same binding and ID; registration never starts, stops, renames or reattaches a process."""
105
+ destination = destination or workspace
106
+ with L.registry_lock():
107
+ data = load()
108
+ registry = apply(L.load_registry(), data)
109
+ if not eligible(entry):
110
+ raise L.LanesError('Only reconnectable tmux sessions can be registered')
111
+ for board in registry['workspaces']:
112
+ if any(key(lane) == key(entry) for lane in board['lanes'] if lane.get('holder') == 'tmux'):
113
+ raise L.LanesError('Already registered in ' + (board.get('title') or board['id']))
114
+ board = next((w for w in destinations(registry, entry) if w['id'] == destination), None)
115
+ if not board:
116
+ raise L.LanesError('Choose a workspace that uses this session’s container')
117
+ published = (L.read_json(L.STATE / 'status.json', {}) or {}).get('unregistered', {}).get(workspace, [])
118
+ actual = next((item for item in published if item.get('id') == entry.get('id')), None)
119
+ if not actual or key(actual) != key(entry) or actual.get('identity') != entry.get('identity'):
120
+ raise L.LanesError('Session changed; reload before registering')
121
+ container, socket, name = key(actual)
122
+ L._require(L.IDENT, actual['id'], 'lane id')
123
+ L._require(L.IDENT, container, 'container')
124
+ L._require(L.IDENT, socket, 'tmux socket')
125
+ 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')
135
+ record = {'id': actual['id'], 'workspace': destination, 'name': L._title(actual['name']),
136
+ 'container': container, 'tmux': dict(actual['tmux']),
137
+ 'folder': L._require(L.SAFE_PATH, board.get('cwd') or L.CONTAINER_HOME, 'working directory')}
138
+ if any(item['id'] == record['id'] or key(item) == key(record) for item in data['sessions']):
139
+ raise L.LanesError(f'Registration already saved; check {FILENAME} and run lanes sync')
140
+ data['sessions'].append(record)
141
+ registry = apply(registry, data)
142
+ registry['generated_at'] = time.time()
143
+ L.atomic_write(L.workspaces_dir() / FILENAME, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
144
+ try:
145
+ L.publish_registry(registry)
146
+ 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'])
@@ -72,8 +72,9 @@ def make_entry(workspace, container, socket, name, pane, previous=None):
72
72
  meta = L.read_json(L.STATE / 'user-sessions' / (entry['id'] + '.json'), {}) or {}
73
73
  if session_key(meta) == session_key(entry):
74
74
  entry['name'] = meta.get('name') or entry['name']
75
- L.atomic_write(L.STATE / 'resolve.dynamic' / entry['id'],
76
- '\n'.join(['tmux', container, socket, name, entry['name']]) + '\n')
75
+ # statusd calls this for every unregistered session on every cycle: rewrite only a changed target or name.
76
+ L.atomic_write_if_changed(L.STATE / 'resolve.dynamic' / entry['id'],
77
+ '\n'.join(['tmux', container, socket, name, entry['name']]) + '\n')
77
78
  return entry
78
79
 
79
80
 
@@ -82,6 +83,8 @@ def new_shell(workspace):
82
83
  board = next((w for w in registry['workspaces'] if w['id'] == workspace), None)
83
84
  if not board:
84
85
  raise L.LanesError('Workspace no longer exists; reload the sidebar')
86
+ if board.get('attach_only'):
87
+ raise L.LanesError('Select an existing remote terminal in this workspace')
85
88
  container = L._require(L.IDENT, board['container'], 'container')
86
89
  cwd = L._require(L.SAFE_PATH, board['cwd'], 'working directory')
87
90
  socket = shell_socket(workspace)
@@ -140,19 +143,30 @@ def rename_session(workspace, entry, new_name):
140
143
  return dict(entry, name=new_name)
141
144
 
142
145
 
143
- def rename_registered(manifest_path, registry_name, new_name):
144
- """Rename a registered lane by writing a displayNames override to the workspace manifest JSON.
145
- `manifest_path` is the path to workspaces.<board>.json; `registry_name` is the terminal's declared
146
- name; `new_name` is the display override. Callers must run `lanes sync` after this returns."""
146
+ def rename_registered(manifest_path, repo_id, registry_name, new_name):
147
+ """Display override for one declared terminal. The reader is `lanes_lib._workspace_lanes`, which looks at
148
+ `repositories[i].displayNames` keyed by the terminal's declared name — the manifest's top level (and the
149
+ 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`."""
147
152
  L._title(new_name)
148
- data = json.loads(Path(manifest_path).read_text())
149
- if 'displayNames' not in data:
150
- data['displayNames'] = {}
153
+ path = Path(manifest_path)
154
+ original = path.read_text()
155
+ data = json.loads(original)
156
+ repo = next((r for r in data.get('repositories') or [] if r.get('id') == repo_id), None)
157
+ if repo is None:
158
+ raise L.LanesError(f'Repository {repo_id!r} not in {path.name}')
159
+ prefix = repo.get('prefix') or ''
160
+ declared = registry_name[len(prefix):] if prefix and registry_name.startswith(prefix) else registry_name
161
+ names = repo.setdefault('displayNames', {})
151
162
  if new_name == registry_name:
152
- data['displayNames'].pop(registry_name, None) # removing the override restores the default
163
+ names.pop(declared, None) # removing the override restores the default title
153
164
  else:
154
- data['displayNames'][registry_name] = new_name
155
- L.atomic_write(manifest_path, json.dumps(data, indent=2, ensure_ascii=False) + '\n', mode=0o644)
165
+ names[declared] = new_name
166
+ if not any(path.parent.glob(path.name + '.bak-*')):
167
+ L.atomic_write(path.parent / (path.name + '.bak-' + time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())),
168
+ original, mode=0o644)
169
+ L.atomic_write(path, json.dumps(data, indent=2, ensure_ascii=False) + '\n', mode=0o644)
156
170
 
157
171
 
158
172
  def promote_to_registered(workspace_id, entry, registry):
@@ -163,19 +177,32 @@ def promote_to_registered(workspace_id, entry, registry):
163
177
  Raises LanesError for unsupported configurations or invalid session state."""
164
178
  if entry.get('holder') != 'tmux':
165
179
  raise L.LanesError('Only tmux sessions can be promoted (zmx and exec holders are not supported)')
180
+ # A binding any board already holds — a repository declaration or an adopted registered session — is
181
+ # registered. Appending it again would make the repository declaration win the binding on the next sync,
182
+ # re-keying an adopted lane's id and orphaning its view targets (DEF-1474). Nothing is written.
183
+ if entry.get('registered_session') or session_key(entry) in {
184
+ session_key(lane) for w in registry.get('workspaces', []) for lane in w.get('lanes', [])
185
+ if lane.get('holder') == 'tmux'}:
186
+ raise L.LanesError('Already registered; nothing was promoted')
166
187
  board = next((w for w in registry.get('workspaces', []) if w['id'] == workspace_id), None)
167
188
  if not board:
168
189
  raise L.LanesError('Workspace not found')
169
- # Find the terminals.json source — must be a single unambiguous path
170
- sources = [lane.get('source') for lane in board.get('lanes', []) if lane.get('source')]
171
- unique_sources = list(dict.fromkeys(s for s in sources if s))
190
+ # Find the terminals.json source — must be a single unambiguous file. A source is (container, spelling): the
191
+ # 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))
172
195
  if not unique_sources:
173
196
  raise L.LanesError(
174
197
  'No registry source found for this workspace; add the lane to terminals.json manually')
175
198
  if len(unique_sources) > 1:
176
199
  raise L.LanesError(
177
200
  'Multiple registry sources in this workspace; add the lane to terminals.json manually')
178
- terminals_path = Path(unique_sources[0])
201
+ # The source is the container's spelling of terminals.json; this process runs on the host, where the file only
202
+ # exists under the container's home volume. Writing Path(source) would miss it (or write a stray host file).
203
+ container, source = unique_sources[0]
204
+ info = (L.read_json(L.STATE / 'containers.json') or {}).get(container) or {}
205
+ terminals_path = L.host_path(info, source)
179
206
  if not terminals_path.exists():
180
207
  raise L.LanesError(f'Registry file not found: {terminals_path}')
181
208
  tmux = entry.get('tmux') or {}
@@ -206,17 +233,21 @@ def promote_to_registered(workspace_id, entry, registry):
206
233
 
207
234
  def close_session(workspace, entry):
208
235
  """Close the exact observed session, never an identically named replacement or a registered lane."""
209
- registry = L.load_registry()
236
+ with L.registry_lock():
237
+ return _close_session(workspace, entry)
238
+
239
+
240
+ def _close_session(workspace, entry):
241
+ import registrations
242
+ registry = registrations.apply(L.load_registry())
210
243
  current = (L.read_json(L.STATE / 'status.json', {}) or {}).get('unregistered', {}).get(workspace, [])
211
244
  published = next((x for x in current if x['id'] == entry['id']), None)
212
245
  if not published or session_key(published) != session_key(entry) or not entry.get('closable'):
213
246
  raise L.LanesError('Session changed; reload before closing')
214
247
  if entry.get('legacy'):
215
- import legacy_sessions
216
- legacy_sessions.close(entry)
217
- clear_viewers(entry['id'])
218
- (L.STATE / 'resolve.dynamic' / entry['id']).unlink(missing_ok=True)
219
- return
248
+ # Plan Task 19 retired the pre-persistence Docker-shell tracker (legacy_sessions.py): only a status.json an
249
+ # older statusd wrote can still carry such a row. Refused before any process, tmux or file is touched.
250
+ raise L.LanesError('Legacy shells are retired; nothing was closed')
220
251
  registered = {session_key(lane) for w in registry['workspaces'] for lane in w.get('lanes', [])
221
252
  if lane.get('holder') == 'tmux'}
222
253
  if session_key(entry) in registered: