@vibelord/lanes 1.0.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/LICENSE +21 -0
- package/README.md +70 -0
- package/bin/lanes-install.mjs +158 -0
- package/lib/plan.mjs +65 -0
- package/package.json +37 -0
- package/payload/lanes/README.md +152 -0
- package/payload/lanes/VERSION +1 -0
- package/payload/lanes/bin/lanes +1035 -0
- package/payload/lanes/client/ghostty/common.conf +22 -0
- package/payload/lanes/client/ghostty/macos.conf +6 -0
- package/payload/lanes/client/ghostty/omarchy.conf +19 -0
- package/payload/lanes/client/install.sh +16 -0
- package/payload/lanes/client/lanes-launch +315 -0
- package/payload/lanes/client/lanes-upload +147 -0
- package/payload/lanes/client/lanes-window +36 -0
- package/payload/lanes/client/macos-helper.applescript +14 -0
- package/payload/lanes/client/omarchy/discover.sh +56 -0
- package/payload/lanes/client/omarchy/lanes.conf +5 -0
- package/payload/lanes/client/omarchy/lanes.desktop +9 -0
- package/payload/lanes/client/omarchy/lanes.lua +14 -0
- package/payload/lanes/client/ssh.stanza.in +54 -0
- package/payload/lanes/container/env.sh +16 -0
- package/payload/lanes/container/lane +268 -0
- package/payload/lanes/container/lane-run +34 -0
- package/payload/lanes/docs/ARCHITECTURE.md +93 -0
- package/payload/lanes/docs/HOST-GUARD.md +108 -0
- package/payload/lanes/docs/INSTALL.md +192 -0
- package/payload/lanes/docs/RUNBOOK.md +385 -0
- package/payload/lanes/docs/SHARING.md +124 -0
- package/payload/lanes/docs/ZMX-RING0.md +51 -0
- package/payload/lanes/docs/images/desktop.png +0 -0
- package/payload/lanes/docs/images/iphone.png +0 -0
- package/payload/lanes/holders/exec.sh +21 -0
- package/payload/lanes/holders/tmux.sh +111 -0
- package/payload/lanes/holders/zmx.sh +22 -0
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-phone.sh +43 -0
- package/payload/lanes/install/enable-web.sh +16 -0
- package/payload/lanes/install/host-install.sh +72 -0
- package/payload/lanes/install/services.py +61 -0
- package/payload/lanes/install.sh +603 -0
- package/payload/lanes/libexec/cursor_commit.py +159 -0
- package/payload/lanes/libexec/cursor_sync.py +166 -0
- package/payload/lanes/libexec/guard.py +231 -0
- package/payload/lanes/libexec/lanes_lib.py +610 -0
- package/payload/lanes/libexec/legacy_sessions.py +130 -0
- package/payload/lanes/libexec/session_actions.py +276 -0
- package/payload/lanes/libexec/sidebar +8 -0
- package/payload/lanes/libexec/sidebar.py +872 -0
- package/payload/lanes/libexec/statusd.py +764 -0
- package/payload/lanes/libexec/tmpscan.py +921 -0
- package/payload/lanes/libexec/view.sh +179 -0
- package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
- package/payload/lanes/site/README.md +28 -0
- package/payload/lanes/site/beyondwork/known_hosts +1 -0
- package/payload/lanes/site/beyondwork/site.conf +11 -0
- package/payload/lanes/site/site.example.conf +10 -0
- package/payload/lanes/sudo/host-guard.sh +356 -0
- package/payload/lanes/sudo/one-time-root.sh +32 -0
- package/payload/lanes/systemd/lanes-ensure.service +7 -0
- package/payload/lanes/systemd/lanes-ensure.timer +10 -0
- package/payload/lanes/systemd/lanes-gc.service +7 -0
- package/payload/lanes/systemd/lanes-gc.timer +9 -0
- package/payload/lanes/systemd/lanes-guard.service +8 -0
- package/payload/lanes/systemd/lanes-guard.timer +10 -0
- package/payload/lanes/systemd/lanes-mobile.service +23 -0
- package/payload/lanes/systemd/lanes-statusd.service +15 -0
- package/payload/lanes/systemd/lanes-sync.service +10 -0
- package/payload/lanes/systemd/lanes-sync.timer +10 -0
- package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
- package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
- package/payload/lanes/systemd/lanes-web.service +13 -0
- package/payload/lanes/systemd/lanes.slice +11 -0
- package/payload/lanes/web/Caddyfile +37 -0
- package/payload/lanes/web/README.md +35 -0
- package/payload/lanes/web/icon-192.png +0 -0
- package/payload/lanes/web/icon.svg +4 -0
- package/payload/lanes/web/index.html +30 -0
- package/payload/lanes/web/manifest.webmanifest +15 -0
- package/payload/lanes/web/mobile.css +301 -0
- package/payload/lanes/web/mobile.js +367 -0
- package/payload/lanes/workspaces.example/README.md +27 -0
- package/payload/lanes/workspaces.example/catalog.json +7 -0
- package/payload/lanes/workspaces.example/commit-targets.json +8 -0
- package/payload/lanes/workspaces.example/terminals.example.json +19 -0
- package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
- package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
- package/payload/lanes/zellij/config.kdl +360 -0
- package/payload/manifest.json +31 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Track pre-persistence Docker shells without pretending their lost Docker connection is reattachable."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import select
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import lanes_lib as L
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def process(pid):
|
|
14
|
+
root = Path(L.proc_root()) / str(pid)
|
|
15
|
+
stat = (root / 'stat').read_text().rsplit(') ', 1)[1].split()
|
|
16
|
+
return {'pid': int(pid), 'ppid': int(stat[1]), 'sid': int(stat[3]), 'tty': stat[4], 'start': stat[19],
|
|
17
|
+
'uid': root.stat().st_uid, 'cgroup': (root / 'cgroup').read_text(),
|
|
18
|
+
'cwd': os.readlink(root / 'cwd'), 'command': (root / 'comm').read_text().strip()}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def observe():
|
|
22
|
+
lanes, groups = {}, {}
|
|
23
|
+
for path in (L.STATE / 'legacy-sessions').glob('*.json'):
|
|
24
|
+
entry = L.read_json(path) or {}
|
|
25
|
+
identity = entry.get('legacy') or {}
|
|
26
|
+
try:
|
|
27
|
+
actual = process(identity['pid'])
|
|
28
|
+
if actual['start'] != identity['start']:
|
|
29
|
+
continue
|
|
30
|
+
state = {'state': 'quiet', 'pid': actual['pid'], 'cwd': actual['cwd'],
|
|
31
|
+
'command': actual['command'], 'legacy': True}
|
|
32
|
+
except (FileNotFoundError, ProcessLookupError, KeyError):
|
|
33
|
+
continue
|
|
34
|
+
except OSError:
|
|
35
|
+
state = {'state': 'unknown', 'observed': 'error', 'legacy': True}
|
|
36
|
+
lanes[entry['id']] = state
|
|
37
|
+
groups.setdefault(entry['workspace'], []).append(entry)
|
|
38
|
+
L.atomic_write(L.STATE / 'resolve.dynamic' / entry['id'],
|
|
39
|
+
'\n'.join(['exec', entry['container'], 'legacy', entry['id'], entry['name']]) + '\n')
|
|
40
|
+
return lanes, groups
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def close(entry):
|
|
44
|
+
identity = entry.get('legacy') or {}
|
|
45
|
+
recorded = L.read_json(L.STATE / 'legacy-sessions' / (entry['id'] + '.json')) or {}
|
|
46
|
+
if recorded.get('legacy') != identity:
|
|
47
|
+
raise L.LanesError('Legacy shell identity changed; nothing was closed')
|
|
48
|
+
try:
|
|
49
|
+
root = process(identity['pid'])
|
|
50
|
+
except (OSError, KeyError):
|
|
51
|
+
raise L.LanesError('Legacy shell could not be checked; nothing was closed')
|
|
52
|
+
if root['start'] != identity['start'] or root['uid'] != os.getuid():
|
|
53
|
+
raise L.LanesError('Legacy shell changed; nothing was closed')
|
|
54
|
+
members = {}
|
|
55
|
+
for path in Path(L.proc_root()).iterdir():
|
|
56
|
+
if not path.name.isdigit():
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
candidate = process(path.name)
|
|
60
|
+
if candidate['uid'] == root['uid'] and candidate['cgroup'] == root['cgroup']:
|
|
61
|
+
members[candidate['pid']] = candidate
|
|
62
|
+
except OSError:
|
|
63
|
+
continue
|
|
64
|
+
# The root may have exited and its PID been reused while we scanned /proc.
|
|
65
|
+
# Keep the recorded identity authoritative rather than adopting the scan's replacement.
|
|
66
|
+
if members.get(root['pid'], {}).get('start') != root['start']:
|
|
67
|
+
raise L.LanesError('Legacy shell changed or could not be checked; nothing was closed')
|
|
68
|
+
chosen = {root['pid']} | {pid for pid, p in members.items()
|
|
69
|
+
if p['sid'] == root['sid'] and p['tty'] == root['tty'] and root['tty'] != '0'}
|
|
70
|
+
while True:
|
|
71
|
+
children = {pid for pid, p in members.items() if p['ppid'] in chosen}
|
|
72
|
+
if children <= chosen:
|
|
73
|
+
break
|
|
74
|
+
chosen.update(children)
|
|
75
|
+
handles, root_verified = [], False
|
|
76
|
+
try:
|
|
77
|
+
for pid in chosen:
|
|
78
|
+
fd = None
|
|
79
|
+
try:
|
|
80
|
+
fd = os.pidfd_open(pid)
|
|
81
|
+
if process(pid)['start'] != members[pid]['start']:
|
|
82
|
+
os.close(fd)
|
|
83
|
+
continue
|
|
84
|
+
handles.append(fd)
|
|
85
|
+
if pid == root['pid']:
|
|
86
|
+
root_verified = True
|
|
87
|
+
except (OSError, KeyError):
|
|
88
|
+
if fd is not None:
|
|
89
|
+
os.close(fd)
|
|
90
|
+
continue
|
|
91
|
+
if not root_verified:
|
|
92
|
+
raise L.LanesError('Legacy shell could not be checked; nothing was closed')
|
|
93
|
+
# pidfds remain attached to these processes even if the kernel recycles their numeric pids.
|
|
94
|
+
for fd in handles:
|
|
95
|
+
try:
|
|
96
|
+
signal.pidfd_send_signal(fd, signal.SIGHUP)
|
|
97
|
+
except ProcessLookupError:
|
|
98
|
+
pass
|
|
99
|
+
remaining, deadline = set(handles), time.monotonic() + 2
|
|
100
|
+
while remaining and time.monotonic() < deadline:
|
|
101
|
+
ready, _, _ = select.select(list(remaining), [], [], 0.1)
|
|
102
|
+
remaining.difference_update(ready)
|
|
103
|
+
for fd in remaining:
|
|
104
|
+
try:
|
|
105
|
+
signal.pidfd_send_signal(fd, signal.SIGKILL)
|
|
106
|
+
except ProcessLookupError:
|
|
107
|
+
pass
|
|
108
|
+
finally:
|
|
109
|
+
for fd in handles:
|
|
110
|
+
os.close(fd)
|
|
111
|
+
(L.STATE / 'legacy-sessions' / (entry['id'] + '.json')).unlink(missing_ok=True)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def view(container, lane_id):
|
|
115
|
+
L._require(L.IDENT, lane_id, 'legacy shell')
|
|
116
|
+
entry = L.read_json(L.STATE / 'legacy-sessions' / (lane_id + '.json')) or {}
|
|
117
|
+
if entry.get('container') != container:
|
|
118
|
+
raise L.LanesError('Legacy shell no longer exists')
|
|
119
|
+
print('\033[2J\033[H\n ' + entry['name'] + '\n', flush=True)
|
|
120
|
+
print(' This shell predates persistent terminals. Its original Docker connection cannot be reattached.\n'
|
|
121
|
+
' Its shell and running jobs are preserved. Use × in Unregistered to terminate them.\n'
|
|
122
|
+
' New + creates a shell you can leave and reopen.\n', flush=True)
|
|
123
|
+
print(' Container: ' + container + '\n Process: ' + str(entry['legacy']['pid']), flush=True)
|
|
124
|
+
while True:
|
|
125
|
+
time.sleep(30)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == '__main__':
|
|
129
|
+
if len(sys.argv) == 4 and sys.argv[1] == 'view':
|
|
130
|
+
view(sys.argv[2], sys.argv[3])
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Explicit New/Close actions for unregistered terminals; observation never creates or kills."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
import shlex
|
|
7
|
+
import signal
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import lanes_lib as L
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def shell_socket(workspace):
|
|
15
|
+
return 'lanes-shells-' + L._require(L.IDENT, workspace, 'workspace')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def session_key(entry):
|
|
19
|
+
binding = entry.get('tmux') or {}
|
|
20
|
+
return entry.get('container'), binding.get('socket'), binding.get('session')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def session_id(workspace, container, socket, name):
|
|
24
|
+
digest = hashlib.sha256(json.dumps([container, socket, name]).encode()).hexdigest()[:10]
|
|
25
|
+
return f'{workspace}--x-{L.slug(name)[:40]}-{digest}'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def workspace_roots(workspace):
|
|
29
|
+
roots = set(workspace.get('discovery_roots') or [])
|
|
30
|
+
roots.update(lane.get('folder') for lane in workspace.get('lanes', []) if lane.get('folder'))
|
|
31
|
+
if workspace.get('cwd'):
|
|
32
|
+
roots.add(workspace['cwd'])
|
|
33
|
+
for root in list(roots):
|
|
34
|
+
roots.add(root.rstrip('/') + '-worktrees')
|
|
35
|
+
parent = str(Path(root).parent)
|
|
36
|
+
if parent.endswith('-workspace'):
|
|
37
|
+
roots.add(parent)
|
|
38
|
+
return {root.rstrip('/') for root in roots if root and root not in ('/', L.CONTAINER_HOME)}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def workspace_for(registry, container, socket, cwd, owners=(), preferred=None):
|
|
42
|
+
candidates = [w for w in registry['workspaces'] if w.get('container') == container or
|
|
43
|
+
any(lane.get('container') == container for lane in w.get('lanes', []))]
|
|
44
|
+
by_id = {w['id']: w for w in candidates}
|
|
45
|
+
if preferred in by_id:
|
|
46
|
+
return preferred
|
|
47
|
+
explicit = next((w['id'] for w in candidates if shell_socket(w['id']) == socket), None)
|
|
48
|
+
if explicit:
|
|
49
|
+
return explicit
|
|
50
|
+
if len(owners) == 1:
|
|
51
|
+
return next(iter(owners))
|
|
52
|
+
scored = [(len(root), w['id']) for w in candidates for root in workspace_roots(w)
|
|
53
|
+
if cwd == root or cwd.startswith(root + '/')]
|
|
54
|
+
if scored:
|
|
55
|
+
best = max(n for n, _ in scored)
|
|
56
|
+
winners = {w for n, w in scored if n == best}
|
|
57
|
+
if len(winners) == 1:
|
|
58
|
+
return next(iter(winners))
|
|
59
|
+
if len(candidates) == 1:
|
|
60
|
+
return candidates[0]['id']
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def make_entry(workspace, container, socket, name, pane, previous=None):
|
|
65
|
+
previous = previous or {}
|
|
66
|
+
entry = {'id': previous.get('id') or session_id(workspace, container, socket, name),
|
|
67
|
+
'name': previous.get('name') or name, 'holder': 'tmux', 'container': container,
|
|
68
|
+
'workspace': workspace, 'tmux': {'socket': socket, 'session': name},
|
|
69
|
+
'color': None, 'icon': '👻', 'closable': True,
|
|
70
|
+
'identity': {'session_id': pane.get('session_id', ''),
|
|
71
|
+
'created': pane.get('session_created', '')}}
|
|
72
|
+
meta = L.read_json(L.STATE / 'user-sessions' / (entry['id'] + '.json'), {}) or {}
|
|
73
|
+
if session_key(meta) == session_key(entry):
|
|
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')
|
|
77
|
+
return entry
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def new_shell(workspace):
|
|
81
|
+
registry = L.load_registry()
|
|
82
|
+
board = next((w for w in registry['workspaces'] if w['id'] == workspace), None)
|
|
83
|
+
if not board:
|
|
84
|
+
raise L.LanesError('Workspace no longer exists; reload the sidebar')
|
|
85
|
+
container = L._require(L.IDENT, board['container'], 'container')
|
|
86
|
+
cwd = L._require(L.SAFE_PATH, board['cwd'], 'working directory')
|
|
87
|
+
socket = shell_socket(workspace)
|
|
88
|
+
name = 'shell-' + time.strftime('%Y%m%d-%H%M%S') + '-' + secrets.token_hex(3)
|
|
89
|
+
lane_id = session_id(workspace, container, socket, name)
|
|
90
|
+
entry = {'id': lane_id, 'workspace': workspace, 'container': container, 'holder': 'tmux',
|
|
91
|
+
'tmux': {'socket': socket, 'session': name},
|
|
92
|
+
'name': 'Shell ' + time.strftime('%H:%M:%S') + ' · ' + name[-6:]}
|
|
93
|
+
metadata = L.STATE / 'user-sessions' / (lane_id + '.json')
|
|
94
|
+
L.atomic_write(metadata, json.dumps(entry) + '\n')
|
|
95
|
+
# Creation is the only place that may start a tmux server. It runs INSIDE the container.
|
|
96
|
+
rc, out, err = L.run_bounded(
|
|
97
|
+
['docker', 'exec', '-u', 'coder', '-e', 'TERM=xterm-256color', '-e', 'COLORTERM=truecolor',
|
|
98
|
+
'-w', cwd, container, 'tmux', '-L', socket, 'new-session', '-d', '-s', name, '-c', cwd,
|
|
99
|
+
'-x', '100', '-y', '30', '-P', '-F', '#{session_id}\t#{session_created}', '/bin/bash', '-l'], 15)
|
|
100
|
+
if rc != 0:
|
|
101
|
+
# A timeout is not proof that creation failed. Discovery will find a shell that did start.
|
|
102
|
+
raise L.LanesError('New shell: ' + (err.strip()[-180:] or 'no answer yet; discovery will check'))
|
|
103
|
+
parts = out.strip().split('\t')
|
|
104
|
+
if len(parts) != 2:
|
|
105
|
+
raise L.LanesError('Shell started; waiting for discovery to identify it')
|
|
106
|
+
return make_entry(workspace, container, socket, name,
|
|
107
|
+
{'session_id': parts[0], 'session_created': parts[1]}, entry)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def clear_viewers(lane_id):
|
|
111
|
+
for target in (L.STATE / 'views').glob('*/*.target'):
|
|
112
|
+
try:
|
|
113
|
+
if target.read_text().strip() != lane_id:
|
|
114
|
+
continue
|
|
115
|
+
L.atomic_write(target, '!empty\n')
|
|
116
|
+
pid = int(target.with_suffix('.pid').read_text().strip())
|
|
117
|
+
if b'view.sh' in Path(f'{L.proc_root()}/{pid}/cmdline').read_bytes():
|
|
118
|
+
os.kill(pid, signal.SIGUSR1)
|
|
119
|
+
except (OSError, ValueError):
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def rename_session(workspace, entry, new_name):
|
|
124
|
+
"""Rename an unregistered session. Updates user-sessions metadata and resolve.dynamic.
|
|
125
|
+
`new_name` must pass the _title() gate (non-empty, no control chars).
|
|
126
|
+
Returns the updated entry dict."""
|
|
127
|
+
L._title(new_name) # raises LanesError on invalid name
|
|
128
|
+
lane_id = entry['id']
|
|
129
|
+
meta_path = L.STATE / 'user-sessions' / (lane_id + '.json')
|
|
130
|
+
meta = L.read_json(meta_path, {}) or {}
|
|
131
|
+
meta['name'] = new_name
|
|
132
|
+
L.atomic_write(meta_path, json.dumps(meta) + '\n')
|
|
133
|
+
# resolve.dynamic/<id>: lines are holder, container, socket, session, name
|
|
134
|
+
resolve_path = L.STATE / 'resolve.dynamic' / lane_id
|
|
135
|
+
if resolve_path.exists():
|
|
136
|
+
lines = resolve_path.read_text().split('\n')
|
|
137
|
+
if len(lines) >= 5:
|
|
138
|
+
lines[4] = new_name
|
|
139
|
+
L.atomic_write(resolve_path, '\n'.join(lines[:5]) + '\n')
|
|
140
|
+
return dict(entry, name=new_name)
|
|
141
|
+
|
|
142
|
+
|
|
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."""
|
|
147
|
+
L._title(new_name)
|
|
148
|
+
data = json.loads(Path(manifest_path).read_text())
|
|
149
|
+
if 'displayNames' not in data:
|
|
150
|
+
data['displayNames'] = {}
|
|
151
|
+
if new_name == registry_name:
|
|
152
|
+
data['displayNames'].pop(registry_name, None) # removing the override restores the default
|
|
153
|
+
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)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def promote_to_registered(workspace_id, entry, registry):
|
|
159
|
+
"""Promote an unregistered tmux session to a registered lane by writing its binding to terminals.json.
|
|
160
|
+
|
|
161
|
+
The session must have holder=='tmux' and the workspace must have exactly one repository source
|
|
162
|
+
(unambiguous write target). After this call, callers should run `lanes sync` and reload.
|
|
163
|
+
Raises LanesError for unsupported configurations or invalid session state."""
|
|
164
|
+
if entry.get('holder') != 'tmux':
|
|
165
|
+
raise L.LanesError('Only tmux sessions can be promoted (zmx and exec holders are not supported)')
|
|
166
|
+
board = next((w for w in registry.get('workspaces', []) if w['id'] == workspace_id), None)
|
|
167
|
+
if not board:
|
|
168
|
+
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))
|
|
172
|
+
if not unique_sources:
|
|
173
|
+
raise L.LanesError(
|
|
174
|
+
'No registry source found for this workspace; add the lane to terminals.json manually')
|
|
175
|
+
if len(unique_sources) > 1:
|
|
176
|
+
raise L.LanesError(
|
|
177
|
+
'Multiple registry sources in this workspace; add the lane to terminals.json manually')
|
|
178
|
+
terminals_path = Path(unique_sources[0])
|
|
179
|
+
if not terminals_path.exists():
|
|
180
|
+
raise L.LanesError(f'Registry file not found: {terminals_path}')
|
|
181
|
+
tmux = entry.get('tmux') or {}
|
|
182
|
+
socket_name = tmux.get('socket', '')
|
|
183
|
+
session_name = tmux.get('session', '')
|
|
184
|
+
if not socket_name or not session_name:
|
|
185
|
+
raise L.LanesError('Session has no tmux binding; cannot promote')
|
|
186
|
+
data = json.loads(terminals_path.read_text())
|
|
187
|
+
terminals = data.get('terminals') or []
|
|
188
|
+
lane_block = data.setdefault('lanes', {})
|
|
189
|
+
lane_terminals = lane_block.setdefault('terminals', {})
|
|
190
|
+
name = entry.get('name', session_name)
|
|
191
|
+
# Guard: do not overwrite an existing registered terminal with the same name
|
|
192
|
+
if any(t.get('name') == name for t in terminals):
|
|
193
|
+
raise L.LanesError(f'A terminal named {name!r} already exists; rename it first')
|
|
194
|
+
if name in lane_terminals:
|
|
195
|
+
raise L.LanesError(f'A binding for {name!r} already exists; rename it first')
|
|
196
|
+
terminals.append({'name': name, 'command': entry.get('create') or ''})
|
|
197
|
+
lane_terminals[name] = {'holder': 'tmux', 'tmux': {'socketName': socket_name, 'sessionName': session_name}}
|
|
198
|
+
if entry.get('create'):
|
|
199
|
+
lane_terminals[name]['create'] = entry['create']
|
|
200
|
+
data['terminals'] = terminals
|
|
201
|
+
L.atomic_write(terminals_path, json.dumps(data, indent=2, ensure_ascii=False) + '\n', mode=0o644)
|
|
202
|
+
# Remove the user-sessions metadata — the terminal is now governed by the registry
|
|
203
|
+
meta_path = L.STATE / 'user-sessions' / (entry['id'] + '.json')
|
|
204
|
+
meta_path.unlink(missing_ok=True)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def close_session(workspace, entry):
|
|
208
|
+
"""Close the exact observed session, never an identically named replacement or a registered lane."""
|
|
209
|
+
registry = L.load_registry()
|
|
210
|
+
current = (L.read_json(L.STATE / 'status.json', {}) or {}).get('unregistered', {}).get(workspace, [])
|
|
211
|
+
published = next((x for x in current if x['id'] == entry['id']), None)
|
|
212
|
+
if not published or session_key(published) != session_key(entry) or not entry.get('closable'):
|
|
213
|
+
raise L.LanesError('Session changed; reload before closing')
|
|
214
|
+
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
|
|
220
|
+
registered = {session_key(lane) for w in registry['workspaces'] for lane in w.get('lanes', [])
|
|
221
|
+
if lane.get('holder') == 'tmux'}
|
|
222
|
+
if session_key(entry) in registered:
|
|
223
|
+
raise L.LanesError('Registered lanes cannot be closed here')
|
|
224
|
+
container, socket, name = session_key(entry)
|
|
225
|
+
L._require(L.IDENT, container, 'container')
|
|
226
|
+
L._require(L.IDENT, socket, 'socket')
|
|
227
|
+
# The session gate admits what TMUX itself can hold, not what a lane may be DECLARED with — the same rule
|
|
228
|
+
# `holders/tmux.sh` uses (DEF-891). statusd publishes every live session as a 👻 row with `closable: True`,
|
|
229
|
+
# its name inside the declaration grammar or not (the `<name> (drain)` twins the Studio mints among them),
|
|
230
|
+
# and the sidebar draws the red × on each; a gate copied from `lanes_lib.TMUX_SESSION` refused exactly
|
|
231
|
+
# those rows with "Invalid session" before any tmux call, so the operator was offered a close that could
|
|
232
|
+
# never succeed (DEF-916). The raw name is never a kill target: the identity probe below targets
|
|
233
|
+
# `=<name>:` — the trailing `:` also keeps a trailing `;` from ending the tmux command — and the kill
|
|
234
|
+
# itself runs inside tmux against the pinned `$sid`. Control characters stay refused: no listing
|
|
235
|
+
# round-trips them.
|
|
236
|
+
if not name or any(ord(c) < 32 or ord(c) == 127 for c in name):
|
|
237
|
+
raise L.LanesError(f'Invalid session: {name!r}')
|
|
238
|
+
identity = entry.get('identity') or {}
|
|
239
|
+
sid, created = identity.get('session_id', ''), identity.get('created', '')
|
|
240
|
+
if not sid.startswith('$') or not sid[1:].isdigit() or not created.isdigit():
|
|
241
|
+
raise L.LanesError('Session identity is not ready; wait for the next status update')
|
|
242
|
+
info = (L.read_json(L.STATE / 'containers.json', {}) or {}).get(container) or {}
|
|
243
|
+
outcome, actual = L.tmux_probe(info, socket,
|
|
244
|
+
['display-message', '-p', '-t', '=' + name + ':',
|
|
245
|
+
'#{session_id}\t#{session_created}'], 5)
|
|
246
|
+
if outcome != 'ok' or actual.strip() != sid + '\t' + created:
|
|
247
|
+
raise L.LanesError('Session changed or could not be checked; nothing was closed')
|
|
248
|
+
# A grouped member SHARES this session's windows, so `kill-session` on the leader only unlinks the
|
|
249
|
+
# leader's winlinks: every window and every process keeps running under the member, the row leaves
|
|
250
|
+
# status.json, and the survivor comes back next cycle as a NEW 👻 — while the footer said "Closed"
|
|
251
|
+
# (MEASURED on tmux 3.4, review round 8; DEF-981). `studio_host::lane::stop` already kills the Host's
|
|
252
|
+
# own `view-<studio>-<ch>` members first and refuses `not_stoppable` for a stranger; this is the same
|
|
253
|
+
# rule on the same shape, so the two surfaces answer alike. A listing that does not answer closes
|
|
254
|
+
# nothing: no verdict is not "no members".
|
|
255
|
+
outcome, listing = L.tmux_probe(info, socket,
|
|
256
|
+
['list-sessions', '-F', '#{session_name}\t#{session_group}'], 5)
|
|
257
|
+
if outcome != 'ok':
|
|
258
|
+
raise L.LanesError('Could not read the session groups; nothing was closed')
|
|
259
|
+
views, strangers = L.grouped_members(listing, name)
|
|
260
|
+
if strangers:
|
|
261
|
+
raise L.LanesError(
|
|
262
|
+
f'{len(strangers)} session(s) are grouped onto it and would keep its panes alive: '
|
|
263
|
+
+ ', '.join(sorted(strangers)[:3]) + ' — end them first; nothing was closed')
|
|
264
|
+
for member in views:
|
|
265
|
+
# Named by the listing, targeted exactly, and only ever a name the Studio Host mints.
|
|
266
|
+
L.tmux_probe(info, socket, ['kill-session', '-t', '=' + member + ':'], 5)
|
|
267
|
+
# The conditional executes inside tmux: even a replacement between the check and kill is protected.
|
|
268
|
+
outcome, error = L.tmux_probe(info, socket,
|
|
269
|
+
['if-shell', '-F', '-t', sid, '#{==:#{session_created},' + created + '}',
|
|
270
|
+
'kill-session -t ' + shlex.quote(sid), 'display-message -p stale-session'], 5)
|
|
271
|
+
if outcome != 'ok' or error.strip():
|
|
272
|
+
raise L.LanesError('Could not confirm close: ' + (error or outcome))
|
|
273
|
+
clear_viewers(entry['id'])
|
|
274
|
+
for directory in ('resolve.dynamic', 'user-sessions'):
|
|
275
|
+
path = L.STATE / directory / (entry['id'] + ('.json' if directory == 'user-sessions' else ''))
|
|
276
|
+
path.unlink(missing_ok=True)
|