@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.
- package/README.md +36 -5
- package/bin/lanes-install.mjs +166 -13
- package/lib/plan.mjs +113 -3
- package/package.json +1 -1
- package/payload/lanes/README.md +45 -22
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +2459 -164
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
- package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
- package/payload/lanes/client/lanes-launch +289 -40
- package/payload/lanes/client/lanes-window +15 -3
- package/payload/lanes/container/hooks/claude-settings.json +1 -0
- package/payload/lanes/container/lane +106 -92
- package/payload/lanes/container/lane-edit +100 -0
- package/payload/lanes/container/lane-mcp +1091 -0
- package/payload/lanes/container/lane-signal +109 -29
- package/payload/lanes/container/lane_core.py +1140 -0
- package/payload/lanes/container/vimrc +31 -0
- package/payload/lanes/docs/ARCHITECTURE.md +618 -13
- package/payload/lanes/docs/DESIGN.md +122 -44
- package/payload/lanes/docs/INSTALL.md +157 -17
- package/payload/lanes/docs/RUNBOOK.md +603 -72
- package/payload/lanes/docs/SHARING.md +3 -2
- package/payload/lanes/docs/USERGUIDE.md +777 -136
- package/payload/lanes/install/enable-web.sh +2 -0
- package/payload/lanes/install/host-install.sh +163 -7
- package/payload/lanes/install/services.py +148 -18
- package/payload/lanes/install.sh +68 -7
- package/payload/lanes/libexec/agent_state.py +74 -1
- package/payload/lanes/libexec/cursor_sync.py +6 -0
- package/payload/lanes/libexec/device_sync.py +47 -10
- package/payload/lanes/libexec/economy_poll.py +1 -0
- package/payload/lanes/libexec/enrold.py +41 -22
- package/payload/lanes/libexec/keys.py +54 -23
- package/payload/lanes/libexec/lanes_lib.py +620 -22
- package/payload/lanes/libexec/members.py +16 -2
- package/payload/lanes/libexec/notify.py +104 -24
- package/payload/lanes/libexec/registrations.py +216 -18
- package/payload/lanes/libexec/session_actions.py +182 -14
- package/payload/lanes/libexec/sidebar +11 -3
- package/payload/lanes/libexec/sidebar.py +2135 -361
- package/payload/lanes/libexec/statusd.py +137 -10
- package/payload/lanes/libexec/studio_host.py +8 -1
- package/payload/lanes/libexec/tokens.py +121 -30
- package/payload/lanes/libexec/tokens.sh +86 -2
- package/payload/lanes/libexec/update.py +972 -0
- package/payload/lanes/libexec/users.py +614 -0
- package/payload/lanes/libexec/view.sh +34 -6
- package/payload/lanes/systemd/lanes-ensure.service +3 -0
- package/payload/lanes/systemd/lanes-gc.service +1 -1
- package/payload/lanes/systemd/lanes-update.service +17 -0
- package/payload/lanes/systemd/lanes-update.timer +17 -0
- package/payload/lanes/web/README.md +83 -24
- package/payload/lanes/web/browser.js +3 -5
- package/payload/lanes/web/mobile.css +17 -17
- package/payload/lanes/web/mobile.js +44 -32
- package/payload/lanes/workspaces.example/README.md +3 -1
- package/payload/lanes/zellij/config.kdl +106 -56
- 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.
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
222
|
+
return path.stat().st_mtime
|
|
182
223
|
except OSError:
|
|
183
|
-
return
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
+
if identity:
|
|
286
|
+
A.hand_back(record, identity)
|
|
287
|
+
refused = L.publish_registry(registry) or []
|
|
146
288
|
except OSError as error:
|
|
147
|
-
raise
|
|
148
|
-
|
|
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'])
|