@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.
- package/package.json +1 -1
- package/payload/lanes/README.md +92 -7
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +928 -150
- package/payload/lanes/client/ghostty/common.conf +3 -0
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
- package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
- package/payload/lanes/client/ghostty/omarchy.conf +3 -2
- package/payload/lanes/client/lanes-launch +62 -5
- package/payload/lanes/client/lanes-window +17 -1
- package/payload/lanes/container/env.sh +8 -0
- package/payload/lanes/container/hooks/claude-settings.json +10 -0
- package/payload/lanes/container/hooks/codex-hooks.json +22 -0
- package/payload/lanes/container/lane +326 -25
- package/payload/lanes/container/lane-signal +173 -0
- package/payload/lanes/docs/ARCHITECTURE.md +158 -6
- package/payload/lanes/docs/DESIGN.md +250 -0
- package/payload/lanes/docs/INSTALL.md +39 -0
- package/payload/lanes/docs/RUNBOOK.md +507 -24
- package/payload/lanes/docs/SHARING.md +1 -1
- package/payload/lanes/holders/exec.sh +2 -5
- package/payload/lanes/holders/ssh.sh +18 -0
- package/payload/lanes/holders.lock +3 -0
- package/payload/lanes/install/enable-phone.sh +15 -6
- package/payload/lanes/install/enable-web.sh +19 -6
- package/payload/lanes/install/host-install.sh +10 -1
- package/payload/lanes/install/services.py +30 -3
- package/payload/lanes/install.sh +108 -3
- package/payload/lanes/libexec/agent_state.py +246 -0
- package/payload/lanes/libexec/connection.py +178 -0
- package/payload/lanes/libexec/cursor_sync.py +147 -6
- package/payload/lanes/libexec/device_sync.py +134 -0
- package/payload/lanes/libexec/economy_poll.py +685 -0
- package/payload/lanes/libexec/guard.py +27 -1
- package/payload/lanes/libexec/lanes_lib.py +436 -36
- package/payload/lanes/libexec/notify.py +317 -0
- package/payload/lanes/libexec/registrations.py +148 -0
- package/payload/lanes/libexec/session_actions.py +53 -22
- package/payload/lanes/libexec/sidebar.py +1190 -186
- package/payload/lanes/libexec/statusd.py +352 -90
- package/payload/lanes/libexec/tokens.py +310 -0
- package/payload/lanes/libexec/tokens.sh +11 -0
- package/payload/lanes/libexec/view.sh +22 -11
- package/payload/lanes/systemd/lanes-economy.service +20 -0
- package/payload/lanes/systemd/lanes-notify.service +19 -0
- package/payload/lanes/systemd/lanes-sync.service +3 -0
- package/payload/lanes/web/README.md +96 -2
- package/payload/lanes/web/browser.css +22 -0
- package/payload/lanes/web/browser.js +73 -0
- package/payload/lanes/web/index.html +2 -1
- package/payload/lanes/web/mobile.css +108 -221
- package/payload/lanes/web/mobile.js +105 -55
- package/payload/lanes/workspaces.example/README.md +9 -3
- package/payload/lanes/zellij/config.kdl +128 -19
- package/payload/manifest.json +7 -3
- 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
|
-
|
|
76
|
-
|
|
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
|
-
"""
|
|
145
|
-
`
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
163
|
+
names.pop(declared, None) # removing the override restores the default title
|
|
153
164
|
else:
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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:
|