@vibelord/lanes 1.0.0 → 1.0.1
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 +56 -1
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +281 -104
- package/payload/lanes/client/lanes-launch +44 -2
- package/payload/lanes/container/env.sh +8 -0
- package/payload/lanes/container/lane +19 -2
- package/payload/lanes/docs/ARCHITECTURE.md +86 -1
- package/payload/lanes/docs/DESIGN.md +72 -0
- package/payload/lanes/docs/INSTALL.md +39 -0
- package/payload/lanes/docs/RUNBOOK.md +94 -1
- package/payload/lanes/holders/ssh.sh +18 -0
- package/payload/lanes/install/enable-phone.sh +15 -6
- package/payload/lanes/install/enable-web.sh +19 -6
- package/payload/lanes/libexec/agent_state.py +214 -0
- package/payload/lanes/libexec/connection.py +178 -0
- package/payload/lanes/libexec/cursor_sync.py +139 -6
- package/payload/lanes/libexec/device_sync.py +134 -0
- package/payload/lanes/libexec/lanes_lib.py +158 -5
- package/payload/lanes/libexec/registrations.py +148 -0
- package/payload/lanes/libexec/session_actions.py +47 -15
- package/payload/lanes/libexec/sidebar.py +372 -61
- package/payload/lanes/libexec/statusd.py +126 -33
- package/payload/lanes/libexec/tokens.py +279 -0
- package/payload/lanes/libexec/tokens.sh +11 -0
- package/payload/lanes/libexec/view.sh +9 -8
- package/payload/lanes/web/README.md +15 -0
- package/payload/lanes/web/browser.css +22 -0
- package/payload/lanes/web/browser.js +73 -0
- package/payload/lanes/web/mobile.css +65 -33
- package/payload/lanes/web/mobile.js +23 -5
- package/payload/lanes/workspaces.example/README.md +9 -3
- package/payload/lanes/zellij/config.kdl +61 -3
- package/payload/manifest.json +6 -3
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Agent-aware lane state from signals the harnesses already emit. Pure functions, no subprocesses.
|
|
2
|
+
|
|
3
|
+
Measured 2026-10-03 on coder-dev-work: Claude Code writes ~/.claude/sessions/<pid>.json with
|
|
4
|
+
status busy|idle|shell|waiting, waitingFor, name, sessionId and tmux "<session>:@<win>.%<pane>" (21/21
|
|
5
|
+
live lanes matched); it never deletes them (669 files, 21 live), so liveness is /proc/<pid>. Codex sets the
|
|
6
|
+
pane title "[ ! ] Action Required | <task> | <dir>" and rings BEL when it needs an approval, prefixes a
|
|
7
|
+
braille spinner while working, and rings BEL on turn completion. Claude's title "✳ <name>" is static.
|
|
8
|
+
|
|
9
|
+
The agent{} status vocabulary is fixed here and reused by statusd, the sidebar, the hook sidecar and the phone:
|
|
10
|
+
working | needs_input | needs_approval | done | errored | shell | idle. It is a NEW key beside status.json's
|
|
11
|
+
six-word `state`, never a replacement for it.
|
|
12
|
+
|
|
13
|
+
Two stale-signal traps, both MEASURED 2026-10-04 on this host: (1) a Claude session file outlives its pid, and pids
|
|
14
|
+
wrap (pid_max 4194304, live pids already ~2.3M), so a recycled pid would resurrect a dead session — every file
|
|
15
|
+
carries `procStart` as a JSON STRING (669/669 files), equal to field 22 (starttime) of /proc/<pid>/stat, and a
|
|
16
|
+
mismatch means the pid is someone else's; (2) tmux keeps a pane's title after Codex exits, so a bare shell can still
|
|
17
|
+
read `<task> | <dir>` — spec §3 calls that state `shell` ("a harness that ended into a shell"), never idle/done.
|
|
18
|
+
|
|
19
|
+
pane_current_command alone cannot tell (2) apart from a live harness: tmux reports the foreground process-group
|
|
20
|
+
LEADER, and lanes launch harnesses non-interactively (`bash -c '<harness> …; exec bash -l'`) or through a wrapper
|
|
21
|
+
script (`bash eval-claude`), where the harness shares its shell's pgid — MEASURED 2026-10-04: 21/21 live Claude
|
|
22
|
+
panes and a live Codex pane report `bash` with a live harness child; Codex panes report `node` only where node IS
|
|
23
|
+
the pane root. The stale panes are the ones whose shell has no child at all, so the liveness signal is
|
|
24
|
+
`pane_has_child` (does pane_pid have any child process in the container's /proc). statusd computes it with
|
|
25
|
+
child_liveness(): a per-pane read of /proc/<pid>/task/*/children (this kernel exposes it, CONFIG_PROC_CHILDREN —
|
|
26
|
+
MEASURED 2026-10-04 on 6.8.0-106-generic), falling back to one child_parents() /proc walk only when that read
|
|
27
|
+
fails; a pid whose liveness could not be determined gets no `pane_has_child` key (unknown never demotes).
|
|
28
|
+
pane_current_command stays a secondary hint (a non-shell pane root such as node is the harness itself, child or not).
|
|
29
|
+
"""
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
SPINNER = set('⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⠁⠂⠄⡀⢀⠠⠐⠈')
|
|
35
|
+
ACTION = '[ ! ] Action Required'
|
|
36
|
+
SHELL_TITLES = {'', 'work', 'bash', 'sh', 'zsh', 'fish'}
|
|
37
|
+
STATUS_FROM_CLAUDE = {'busy': 'working', 'idle': 'needs_input', 'shell': 'shell', 'waiting': 'needs_input'}
|
|
38
|
+
STATUSES = ('working', 'needs_input', 'needs_approval', 'done', 'errored', 'shell', 'idle')
|
|
39
|
+
SHELL_COMMANDS = {'bash', 'sh', 'dash', 'zsh', 'fish', 'ksh', 'tcsh', 'csh'}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def pane_has_child(proc_root, pid):
|
|
43
|
+
"""Does `pid` have any child process? Reads every /proc/<pid>/task/<tid>/children (a child is listed under
|
|
44
|
+
the thread that forked it, so all threads are read): any non-empty -> True, all empty -> False. Any read
|
|
45
|
+
failure — pid gone, no task dir, a thread exiting mid-read, or a kernel without CONFIG_PROC_CHILDREN — is
|
|
46
|
+
None (unknown), never False. Same race caveat as child_parents(): the kernel guarantees the file only for a
|
|
47
|
+
stopped task, so a child forked or reaped during the read may be missed; one tick later it is seen."""
|
|
48
|
+
task = Path(proc_root, str(pid), 'task')
|
|
49
|
+
try:
|
|
50
|
+
tids = os.listdir(task)
|
|
51
|
+
except OSError:
|
|
52
|
+
return None
|
|
53
|
+
if not tids:
|
|
54
|
+
return None
|
|
55
|
+
for tid in tids:
|
|
56
|
+
try:
|
|
57
|
+
if Path(task, tid, 'children').read_text().split():
|
|
58
|
+
return True
|
|
59
|
+
except OSError:
|
|
60
|
+
return None
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def child_parents(proc_root):
|
|
65
|
+
"""The set of pids that have at least one child process in `proc_root` (field 4, ppid, of every
|
|
66
|
+
/proc/<pid>/stat, parsed after the last ')' since comm may contain spaces and ')'), or None when nothing could
|
|
67
|
+
be read — `proc_root` unlistable, or zero stat files parsed — so "could not look" is never "no children".
|
|
68
|
+
O(processes) (MEASURED 2026-10-04: ~140-170 ms over ~3.2k host processes), so it is only child_liveness()'s
|
|
69
|
+
fallback when the per-pane children read fails. A pid that exited mid-walk is skipped."""
|
|
70
|
+
parents = set()
|
|
71
|
+
parsed = 0
|
|
72
|
+
try:
|
|
73
|
+
names = os.listdir(proc_root)
|
|
74
|
+
except OSError:
|
|
75
|
+
return None
|
|
76
|
+
for name in names:
|
|
77
|
+
if not name.isdigit():
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
parents.add(int(Path(proc_root, name, 'stat').read_text().rsplit(')', 1)[1].split()[1]))
|
|
81
|
+
except (OSError, IndexError, ValueError):
|
|
82
|
+
continue
|
|
83
|
+
parsed += 1
|
|
84
|
+
return parents if parsed else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def child_liveness(proc_root, pids):
|
|
88
|
+
"""pid -> True | False | None for each pane pid, as statusd's `pane_has_child`. pane_has_child() per pid
|
|
89
|
+
first (O(threads of that pid)); for a pid it cannot answer, one lazily computed child_parents() walk per call;
|
|
90
|
+
None when both fail. statusd sets the row key only for a non-None value, so unknown never demotes."""
|
|
91
|
+
out = {}
|
|
92
|
+
parents = walked = None
|
|
93
|
+
for pid in pids:
|
|
94
|
+
pid = int(pid)
|
|
95
|
+
has = pane_has_child(proc_root, pid)
|
|
96
|
+
if has is None:
|
|
97
|
+
if not walked:
|
|
98
|
+
parents, walked = child_parents(proc_root), True
|
|
99
|
+
has = None if parents is None else pid in parents
|
|
100
|
+
out[pid] = has
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _harness_ended(pane_row):
|
|
105
|
+
"""True only when the row says the pane's root process has NO child (`pane_has_child is False`) and the
|
|
106
|
+
foreground is a bare shell: the harness exited into that shell, whatever title it left behind. A live
|
|
107
|
+
wrapper-launched harness is a child of the shell (has_child True); a node pane root is the harness itself (not
|
|
108
|
+
a shell). An absent or None `pane_has_child` is unknown and never demotes — the title then decides, since the
|
|
109
|
+
shell-foreground hint alone misreads every `bash -c` / wrapper launch as ended."""
|
|
110
|
+
return pane_row.get('pane_has_child') is False and _shell_foreground(pane_row)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _shell_foreground(pane_row):
|
|
114
|
+
"""True when tmux reports a bare shell as the pane's foreground command (login '-bash' and paths normalised).
|
|
115
|
+
An absent/empty column is unknown, never a shell: the title alone then decides, as before."""
|
|
116
|
+
cmd = str(pane_row.get('pane_current_command') or '').strip().lstrip('-').rsplit('/', 1)[-1]
|
|
117
|
+
return cmd in SHELL_COMMANDS
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _same_process(proc_root, pid, proc_start):
|
|
121
|
+
"""The session file's `procStart` must equal the live pid's starttime (field 22 of /proc/<pid>/stat), else
|
|
122
|
+
the pid was recycled by an unrelated process. Claude writes procStart as a JSON string; str() normalises the
|
|
123
|
+
string and int forms alike. Unreadable stat -> treated as dead."""
|
|
124
|
+
try:
|
|
125
|
+
stat = Path(proc_root, pid, 'stat').read_text()
|
|
126
|
+
return stat.rsplit(')', 1)[1].split()[19] == str(proc_start)
|
|
127
|
+
except (OSError, IndexError):
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse_codex_title(title):
|
|
132
|
+
"""A Codex pane title -> {'harness','status','name'}, or None for a shell / non-agent title."""
|
|
133
|
+
t = (title or '').strip()
|
|
134
|
+
head = t.split('|')[0]
|
|
135
|
+
if t in SHELL_TITLES or (': ' in head and head.startswith(('coder@', 'root@'))):
|
|
136
|
+
return None
|
|
137
|
+
if t.startswith(ACTION):
|
|
138
|
+
rest = [p.strip() for p in t[len(ACTION):].split('|') if p.strip()]
|
|
139
|
+
return {'harness': 'codex', 'status': 'needs_approval', 'name': rest[0] if rest else ''}
|
|
140
|
+
if t and t[0] in SPINNER:
|
|
141
|
+
return {'harness': 'codex', 'status': 'working', 'name': t[1:].split('|')[0].strip()}
|
|
142
|
+
if t.startswith('✳'):
|
|
143
|
+
return None # Claude's static title: presence only; the session file carries the state
|
|
144
|
+
if '|' in t or len(t.split()) >= 2:
|
|
145
|
+
return {'harness': 'codex', 'status': 'idle', 'name': head.strip()}
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def claude_sessions(sessions_dir, proc_root):
|
|
150
|
+
"""pane key -> agent dict, live pids only. Several files can name one pane (a resumed session leaves the
|
|
151
|
+
old pid's file behind): the newest `statusUpdatedAt` among LIVE pids wins. A file whose `procStart` does not
|
|
152
|
+
match the pid's current starttime is a recycled pid and is skipped (no `procStart` -> existence only). `proc_root` is the container's
|
|
153
|
+
/proc as seen from the host (/proc/<container-pid>/root/proc), so liveness is checked in the container's pid
|
|
154
|
+
namespace. A malformed file is skipped, never raised: one bad file costs nobody else their agent."""
|
|
155
|
+
out = {}
|
|
156
|
+
try:
|
|
157
|
+
names = os.listdir(sessions_dir)
|
|
158
|
+
except OSError:
|
|
159
|
+
return out
|
|
160
|
+
for fn in names:
|
|
161
|
+
stem = fn[:-5] if fn.endswith('.json') else ''
|
|
162
|
+
if not stem.isdigit() or not os.path.exists(os.path.join(proc_root, stem)):
|
|
163
|
+
continue
|
|
164
|
+
try:
|
|
165
|
+
d = json.loads(Path(sessions_dir, fn).read_text())
|
|
166
|
+
except (OSError, ValueError):
|
|
167
|
+
continue
|
|
168
|
+
if not isinstance(d, dict):
|
|
169
|
+
continue
|
|
170
|
+
if d.get('procStart') is not None and not _same_process(proc_root, stem, d['procStart']):
|
|
171
|
+
continue
|
|
172
|
+
key = d.get('tmux')
|
|
173
|
+
if not isinstance(key, str) or not key:
|
|
174
|
+
continue
|
|
175
|
+
try:
|
|
176
|
+
since = int((d.get('statusUpdatedAt') or 0) // 1000)
|
|
177
|
+
except (TypeError, ValueError):
|
|
178
|
+
continue
|
|
179
|
+
status = STATUS_FROM_CLAUDE.get(d.get('status'), 'idle')
|
|
180
|
+
if d.get('status') == 'waiting' and 'permission' in str(d.get('waitingFor') or '').lower():
|
|
181
|
+
status = 'needs_approval'
|
|
182
|
+
entry = {'harness': 'claude', 'status': status, 'name': str(d.get('name') or ''),
|
|
183
|
+
'session_id': str(d.get('sessionId') or ''), 'pid': int(stem),
|
|
184
|
+
'since': since, 'source': 'claude-session-file'}
|
|
185
|
+
if key not in out or entry['since'] >= out[key]['since']:
|
|
186
|
+
out[key] = entry
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def merge(pane_row, claude_by_pane, sidecar):
|
|
191
|
+
"""Precedence: hook sidecar (event-driven, both harnesses) > Claude session file > Codex title > bell.
|
|
192
|
+
Any Codex title — idle, spinner or Action Required, bell or not — over a bare-shell pane whose shell has no
|
|
193
|
+
child process (`pane_has_child is False`, statusd's row key) publishes `shell`: the harness ended and tmux kept
|
|
194
|
+
its last title, so no ghost idle/done/working/needs_approval. Unknown liveness leaves the title in charge.
|
|
195
|
+
Returns None when nothing says an agent is here — the sidebar then draws a plain shell."""
|
|
196
|
+
if sidecar and sidecar.get('status') in STATUSES:
|
|
197
|
+
return dict(sidecar, source=sidecar.get('source') or 'hook')
|
|
198
|
+
key = f"{pane_row.get('session_name')}:{pane_row.get('window_id')}.{pane_row.get('pane_id')}"
|
|
199
|
+
if key in claude_by_pane:
|
|
200
|
+
return dict(claude_by_pane[key])
|
|
201
|
+
title = parse_codex_title(pane_row.get('pane_title'))
|
|
202
|
+
bell = pane_row.get('window_bell_flag') == '1'
|
|
203
|
+
if title:
|
|
204
|
+
if _harness_ended(pane_row):
|
|
205
|
+
title['status'] = 'shell' # stale title on a childless shell: the harness ended, no ghost of any kind
|
|
206
|
+
elif title['status'] == 'idle' and bell:
|
|
207
|
+
title['status'] = 'done'
|
|
208
|
+
title['source'] = 'title'
|
|
209
|
+
try:
|
|
210
|
+
title['since'] = int(pane_row.get('window_activity') or 0)
|
|
211
|
+
except ValueError:
|
|
212
|
+
title['since'] = 0
|
|
213
|
+
return title
|
|
214
|
+
return None
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""A quiet connection screen with input custody until the viewer answers."""
|
|
3
|
+
import argparse
|
|
4
|
+
import errno
|
|
5
|
+
import fcntl
|
|
6
|
+
import os
|
|
7
|
+
import pty
|
|
8
|
+
import select
|
|
9
|
+
import signal
|
|
10
|
+
import sys
|
|
11
|
+
import termios
|
|
12
|
+
import time
|
|
13
|
+
import tty
|
|
14
|
+
|
|
15
|
+
RESET = b'\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l\x1b[?2004l\x1b[<u\x1b[0m'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def screen(title, frame):
|
|
19
|
+
title = ''.join(c for c in title if c.isprintable())[:100]
|
|
20
|
+
spinner = '|/-\\'[frame % 4]
|
|
21
|
+
os.write(1, b'\x1b[H\x1b[2J\x1b[?25l' + f'\n {spinner} Syncing {title}…\n\n Connecting to the workspace\n'.encode())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def drain():
|
|
25
|
+
termios.tcflush(0, termios.TCIFLUSH)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def wait_screen(title, seconds, preserve_screen=False):
|
|
29
|
+
saved = termios.tcgetattr(0)
|
|
30
|
+
handlers = {sig: signal.getsignal(sig) for sig in (signal.SIGTERM, signal.SIGHUP)}
|
|
31
|
+
def stop(sig, _frame):
|
|
32
|
+
raise SystemExit(128 + sig)
|
|
33
|
+
for sig in handlers:
|
|
34
|
+
signal.signal(sig, stop)
|
|
35
|
+
try:
|
|
36
|
+
tty.setraw(0)
|
|
37
|
+
os.write(1, RESET)
|
|
38
|
+
drain()
|
|
39
|
+
end, frame = time.monotonic() + seconds, 0
|
|
40
|
+
while time.monotonic() < end:
|
|
41
|
+
if not preserve_screen:
|
|
42
|
+
screen(title, frame)
|
|
43
|
+
frame += 1
|
|
44
|
+
if select.select([0], [], [], min(.15, max(0, end - time.monotonic())))[0]:
|
|
45
|
+
if not os.read(0, 4096):
|
|
46
|
+
break # disconnected terminal
|
|
47
|
+
finally:
|
|
48
|
+
for sig, handler in handlers.items():
|
|
49
|
+
signal.signal(sig, handler)
|
|
50
|
+
drain()
|
|
51
|
+
termios.tcsetattr(0, termios.TCSANOW, saved)
|
|
52
|
+
os.write(1, RESET + b'\x1b[?25h')
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def connect(title, command):
|
|
56
|
+
if not os.isatty(0):
|
|
57
|
+
raise SystemExit('The workspace viewer needs a terminal')
|
|
58
|
+
saved = termios.tcgetattr(0)
|
|
59
|
+
error_fd = os.dup(2)
|
|
60
|
+
child = None
|
|
61
|
+
stopping = False
|
|
62
|
+
master = None
|
|
63
|
+
|
|
64
|
+
def stop(_sig, _frame):
|
|
65
|
+
nonlocal stopping
|
|
66
|
+
stopping = True
|
|
67
|
+
if child:
|
|
68
|
+
try:
|
|
69
|
+
os.killpg(child, signal.SIGTERM)
|
|
70
|
+
except ProcessLookupError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def resize(_sig=None, _frame=None):
|
|
74
|
+
if master is not None:
|
|
75
|
+
size = fcntl.ioctl(0, termios.TIOCGWINSZ, b'\0' * 8)
|
|
76
|
+
fcntl.ioctl(master, termios.TIOCSWINSZ, size)
|
|
77
|
+
|
|
78
|
+
old_handlers = {sig: signal.getsignal(sig) for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGWINCH)}
|
|
79
|
+
try:
|
|
80
|
+
tty.setraw(0)
|
|
81
|
+
drain()
|
|
82
|
+
os.write(1, RESET)
|
|
83
|
+
screen(title, 0)
|
|
84
|
+
child, master = pty.fork()
|
|
85
|
+
if child == 0:
|
|
86
|
+
os.dup2(error_fd, 2) # diagnostics belong in the view's error file
|
|
87
|
+
os.close(error_fd)
|
|
88
|
+
try:
|
|
89
|
+
os.execvp(command[0], command)
|
|
90
|
+
except OSError as error:
|
|
91
|
+
print(error, file=sys.stderr)
|
|
92
|
+
os._exit(127)
|
|
93
|
+
os.close(error_fd)
|
|
94
|
+
error_fd = -1
|
|
95
|
+
resize()
|
|
96
|
+
signal.signal(signal.SIGTERM, stop)
|
|
97
|
+
signal.signal(signal.SIGHUP, stop)
|
|
98
|
+
signal.signal(signal.SIGWINCH, resize)
|
|
99
|
+
connected, frame = False, 0
|
|
100
|
+
while not stopping:
|
|
101
|
+
ready, _, _ = select.select([0, master], [], [], .15)
|
|
102
|
+
# Consume old mouse/keyboard input BEFORE accepting the first output.
|
|
103
|
+
if 0 in ready:
|
|
104
|
+
data = os.read(0, 4096)
|
|
105
|
+
if not data:
|
|
106
|
+
stop(None, None)
|
|
107
|
+
break
|
|
108
|
+
if connected:
|
|
109
|
+
os.write(master, data)
|
|
110
|
+
if master in ready:
|
|
111
|
+
try:
|
|
112
|
+
data = os.read(master, 65536)
|
|
113
|
+
except OSError as error:
|
|
114
|
+
if error.errno != errno.EIO:
|
|
115
|
+
raise
|
|
116
|
+
break
|
|
117
|
+
if not data:
|
|
118
|
+
break
|
|
119
|
+
if not connected:
|
|
120
|
+
drain()
|
|
121
|
+
os.write(1, RESET + b'\x1b[?25h\x1b[H\x1b[2J')
|
|
122
|
+
connected = True
|
|
123
|
+
os.write(1, data)
|
|
124
|
+
if not connected:
|
|
125
|
+
frame += 1
|
|
126
|
+
screen(title, frame)
|
|
127
|
+
if stopping:
|
|
128
|
+
# This kills only the attach client group, never the persistent lane.
|
|
129
|
+
deadline = time.monotonic() + 2
|
|
130
|
+
while time.monotonic() < deadline:
|
|
131
|
+
pid, status = os.waitpid(child, os.WNOHANG)
|
|
132
|
+
if pid:
|
|
133
|
+
return -signal.SIGTERM
|
|
134
|
+
time.sleep(.05)
|
|
135
|
+
try:
|
|
136
|
+
os.killpg(child, signal.SIGKILL)
|
|
137
|
+
except ProcessLookupError:
|
|
138
|
+
pass
|
|
139
|
+
_, status = os.waitpid(child, 0)
|
|
140
|
+
return -signal.SIGTERM if stopping else os.waitstatus_to_exitcode(status)
|
|
141
|
+
finally:
|
|
142
|
+
for sig, handler in old_handlers.items():
|
|
143
|
+
signal.signal(sig, handler)
|
|
144
|
+
if master is not None:
|
|
145
|
+
os.close(master)
|
|
146
|
+
if error_fd >= 0:
|
|
147
|
+
os.close(error_fd)
|
|
148
|
+
drain()
|
|
149
|
+
termios.tcsetattr(0, termios.TCSANOW, saved)
|
|
150
|
+
os.write(1, RESET + b'\x1b[?25h')
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main():
|
|
154
|
+
parser = argparse.ArgumentParser()
|
|
155
|
+
parser.add_argument('--title', default='workspace')
|
|
156
|
+
parser.add_argument('--wait', type=float)
|
|
157
|
+
parser.add_argument('--preserve-screen', action='store_true')
|
|
158
|
+
parser.add_argument('--retry', action='store_true')
|
|
159
|
+
parser.add_argument('command', nargs=argparse.REMAINDER)
|
|
160
|
+
args = parser.parse_args()
|
|
161
|
+
command = args.command[1:] if args.command[:1] == ['--'] else args.command
|
|
162
|
+
if args.wait is not None:
|
|
163
|
+
wait_screen(args.title, args.wait, args.preserve_screen)
|
|
164
|
+
return 0
|
|
165
|
+
if not command:
|
|
166
|
+
parser.error('viewer command required')
|
|
167
|
+
delay = 1
|
|
168
|
+
while True:
|
|
169
|
+
started = time.monotonic()
|
|
170
|
+
code = connect(args.title, command)
|
|
171
|
+
if not args.retry or code < 0:
|
|
172
|
+
return max(0, code) if code < 0 else code
|
|
173
|
+
delay = 1 if time.monotonic() - started >= 30 else min(delay * 2, 15)
|
|
174
|
+
wait_screen(args.title, delay)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == '__main__':
|
|
178
|
+
sys.exit(main())
|
|
@@ -12,12 +12,22 @@ The repository's `.vscode/terminals.json` stays the source of truth for WHICH la
|
|
|
12
12
|
|
|
13
13
|
Text is edited minimally (command strings replaced in place, the `lanes` block inserted before the final brace) so
|
|
14
14
|
git diffs stay small. Dry-run by default: prints a unified diff. `--write` applies (with a timestamped backup).
|
|
15
|
+
|
|
16
|
+
`--canonicalize` additionally replaces every tmux lane's `create` that cannot create the lane — null, attach-only
|
|
17
|
+
(`attach-session` without `new`), or naming a script that is missing in the container — with
|
|
18
|
+
`lanes_lib.canonical_recipe()` (the recorded binding + the repository folder + `bash -l`), which is also what gives a
|
|
19
|
+
NEW lane its identity (LANE_ID, LANE_WS, the lanes/<ws>/<id> trace). `--identity` additionally gives a WORKING
|
|
20
|
+
recipe of the plain shape `[exec] tmux -L <socket> new -ADs <session> [-c <dir>] <command…>` the same identity
|
|
21
|
+
(`plain_tmux_new`: the command argv is kept, only the `-e` flags are added); any other working recipe is left
|
|
22
|
+
alone and its lane carries no identity. The old value is kept as `createPrevious` in both cases. Identity reaches a
|
|
23
|
+
tmux lane ONLY through these `-e` flags: `lane open` never exports it (DEF-1422, lanes/docs/ARCHITECTURE.md).
|
|
15
24
|
Boards whose registry lives outside a repository are additionally PROJECTED into the file Cursor
|
|
16
25
|
reads for that folder, named by `"cursor": "<path>"` on the repository entry of the workspace manifest.
|
|
17
26
|
"""
|
|
18
27
|
import difflib
|
|
19
28
|
import json
|
|
20
29
|
import re
|
|
30
|
+
import shlex
|
|
21
31
|
import time
|
|
22
32
|
from pathlib import Path
|
|
23
33
|
|
|
@@ -31,8 +41,103 @@ def _indent_of(text):
|
|
|
31
41
|
return match.group(1) if match else ' '
|
|
32
42
|
|
|
33
43
|
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
ATTACH_VERBS = {'attach-session'[:n] for n in range(1, 15)} # tmux takes any unique prefix + `attach`
|
|
45
|
+
NEW_VERBS = {'new-session'[:n] for n in range(3, 12)}
|
|
46
|
+
TMUX_OPTION_ARGS = {'-L', '-S', '-f', '-T', '-c'}
|
|
47
|
+
SCRIPT_SUFFIXES = ('.sh', '.bash', '.py', '.js', '.mjs', '.cjs', '.rb', '.pl')
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _words(text, depth=3):
|
|
51
|
+
"""Shell words of a recipe, with quoted command strings split again (`new … 'script.sh arg'`)."""
|
|
52
|
+
try:
|
|
53
|
+
words = shlex.split(text)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return None
|
|
56
|
+
out = []
|
|
57
|
+
for word in words:
|
|
58
|
+
out.append(word)
|
|
59
|
+
if depth and any(c.isspace() for c in word):
|
|
60
|
+
out += _words(word, depth - 1) or []
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _tmux_verbs(words):
|
|
65
|
+
verbs = []
|
|
66
|
+
for i, word in enumerate(words):
|
|
67
|
+
if word == 'tmux' or word.endswith('/tmux'):
|
|
68
|
+
j = i + 1
|
|
69
|
+
while j < len(words) and words[j].startswith('-'):
|
|
70
|
+
j += 2 if words[j] in TMUX_OPTION_ARGS else 1
|
|
71
|
+
if j < len(words):
|
|
72
|
+
verbs.append(words[j])
|
|
73
|
+
return verbs
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def needs_canonical(create, folder, exists):
|
|
77
|
+
"""Why `create` cannot create its lane (None when it can, or when that is not knowable).
|
|
78
|
+
`exists(container_path)` answers True / False / None (unknown — never counted as missing)."""
|
|
79
|
+
if not isinstance(create, str) or not create.strip():
|
|
80
|
+
return 'no create recipe'
|
|
81
|
+
recipe = create.replace('${workspaceFolder}', folder).replace('[workspaceFolder]', folder)
|
|
82
|
+
words = _words(recipe)
|
|
83
|
+
if words is None:
|
|
84
|
+
return None
|
|
85
|
+
verbs = _tmux_verbs(words)
|
|
86
|
+
if any(v in ATTACH_VERBS for v in verbs) and not any(v in NEW_VERBS for v in verbs):
|
|
87
|
+
return 'attach-only'
|
|
88
|
+
for word in words:
|
|
89
|
+
if word.startswith('/') and word.endswith(SCRIPT_SUFFIXES) and exists(word) is False:
|
|
90
|
+
return f'missing script {word}'
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
OPERATORS = set('();<>|&')
|
|
95
|
+
UNSAFE_TO_REQUOTE = set('$`*?[{\\')
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def plain_tmux_new(create, folder, socket, session):
|
|
99
|
+
"""`(folder, command)` of a WORKING recipe of exactly the shape `[exec] tmux -L <socket> new[-session] -A -D -s
|
|
100
|
+
<session> [-c <dir>] <command…>` — this lane's binding, no `-e`, no shell operators — else None. `--identity`
|
|
101
|
+
rewrites such a recipe to `canonical_recipe(…, folder, command)`: the command words are re-quoted one for one, so
|
|
102
|
+
tmux receives the same argv and the rewrite only ADDS the identity `-e` flags. Anything the re-quoting could change
|
|
103
|
+
(an expansion, a glob, a backslash, a leading `~`) or any other shape is left alone."""
|
|
104
|
+
if not isinstance(create, str):
|
|
105
|
+
return None
|
|
106
|
+
recipe = create.replace('${workspaceFolder}', folder).replace('[workspaceFolder]', folder)
|
|
107
|
+
try:
|
|
108
|
+
lexer = shlex.shlex(recipe, posix=True, punctuation_chars=True)
|
|
109
|
+
lexer.whitespace_split = True
|
|
110
|
+
words = list(lexer)
|
|
111
|
+
except ValueError:
|
|
112
|
+
return None
|
|
113
|
+
if any(set(w) <= OPERATORS or set(w) & UNSAFE_TO_REQUOTE or w.startswith('~') for w in words):
|
|
114
|
+
return None
|
|
115
|
+
if words[:1] == ['exec']:
|
|
116
|
+
words = words[1:]
|
|
117
|
+
if words[:3] != ['tmux', '-L', socket] or len(words) < 4 or words[3] not in NEW_VERBS:
|
|
118
|
+
return None
|
|
119
|
+
i, flags, sess, cdir = 4, set(), None, None
|
|
120
|
+
while i < len(words) and words[i].startswith('-'):
|
|
121
|
+
letters = words[i][1:]
|
|
122
|
+
if letters == 'c' and i + 1 < len(words):
|
|
123
|
+
cdir, i = words[i + 1], i + 2
|
|
124
|
+
elif letters and set(letters) <= {'A', 'D'}:
|
|
125
|
+
flags, i = flags | set(letters), i + 1
|
|
126
|
+
elif letters.endswith('s') and set(letters[:-1]) <= {'A', 'D'} and i + 1 < len(words):
|
|
127
|
+
flags, sess, i = flags | set(letters[:-1]), words[i + 1], i + 2
|
|
128
|
+
else:
|
|
129
|
+
return None # -e (already has an identity, or a partial one), -d, -n, -x, …: not the plain shape
|
|
130
|
+
command = words[i:]
|
|
131
|
+
cdir = cdir or folder
|
|
132
|
+
if sess != session or flags != {'A', 'D'} or not command or not cdir.startswith('/'):
|
|
133
|
+
return None
|
|
134
|
+
return cdir, ' '.join(shlex.quote(w) for w in command)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def normalized_text(text, lanes_by_name, canonicalize=None):
|
|
138
|
+
"""Return the file text with uniform commands + the `lanes` block. Minimal textual edits when possible.
|
|
139
|
+
`canonicalize` = {'ws': workspace id, 'exists': container path -> True/False/None, 'repair': bool (default True,
|
|
140
|
+
`--canonicalize`), 'identity': bool (default False, `--identity`)}."""
|
|
36
141
|
data = json.loads(text)
|
|
37
142
|
block = {'version': 1, 'terminals': {}}
|
|
38
143
|
commands = {}
|
|
@@ -47,6 +152,25 @@ def normalized_text(text, lanes_by_name):
|
|
|
47
152
|
entry['tmux'] = {'socketName': lane['tmux']['socket'], 'sessionName': lane['tmux']['session']}
|
|
48
153
|
if create:
|
|
49
154
|
entry['create'] = create
|
|
155
|
+
if 'createPrevious' in previous:
|
|
156
|
+
entry['createPrevious'] = previous['createPrevious']
|
|
157
|
+
if canonicalize and lane.get('holder') == 'tmux' and lane.get('tmux'):
|
|
158
|
+
folder = lane.get('folder') or L.CONTAINER_HOME
|
|
159
|
+
socket, session = lane['tmux']['socket'], lane['tmux']['session']
|
|
160
|
+
kept = plain_tmux_new(create, folder, socket, session) if canonicalize.get('identity') else None
|
|
161
|
+
if canonicalize.get('repair', True) and needs_canonical(create, folder, canonicalize['exists']):
|
|
162
|
+
entry.pop('create', None)
|
|
163
|
+
entry['create'] = L.canonical_recipe(lane['id'], canonicalize['ws'], socket, session, folder)
|
|
164
|
+
entry['createPrevious'] = create
|
|
165
|
+
elif kept:
|
|
166
|
+
try:
|
|
167
|
+
recipe = L.canonical_recipe(lane['id'], canonicalize['ws'], socket, session, kept[0], cmd=kept[1])
|
|
168
|
+
except L.LanesError:
|
|
169
|
+
recipe = None # a trace the proxy would reject: no identity to give, the recipe stays
|
|
170
|
+
if recipe:
|
|
171
|
+
entry.pop('create', None)
|
|
172
|
+
entry['create'] = recipe
|
|
173
|
+
entry['createPrevious'] = create
|
|
50
174
|
block['terminals'][term['name']] = entry
|
|
51
175
|
commands[term['name']] = ENTRY + lane['id']
|
|
52
176
|
# Nothing to do: leave the file byte-identical (never reformat somebody else's JSON).
|
|
@@ -104,7 +228,7 @@ def projected_text(source_text, source_path):
|
|
|
104
228
|
return json.dumps({**note, **data}, ensure_ascii=False, indent=2) + '\n'
|
|
105
229
|
|
|
106
230
|
|
|
107
|
-
def plan(registry, only=()):
|
|
231
|
+
def plan(registry, only=(), canonicalize=False, identity=False):
|
|
108
232
|
"""Yield (workspace id, host path, container path, old text, new text, note)."""
|
|
109
233
|
catalog = L.read_site_json(L.workspaces_dir() / 'catalog.json')['workspaces']
|
|
110
234
|
containers = registry['containers']
|
|
@@ -123,7 +247,14 @@ def plan(registry, only=()):
|
|
|
123
247
|
lanes = {name[len(prefix):] if prefix and name.startswith(prefix) else name: lane for name, lane in lanes.items()}
|
|
124
248
|
host_file = L.host_path(info, repo['registry'])
|
|
125
249
|
old = host_file.read_text()
|
|
126
|
-
|
|
250
|
+
|
|
251
|
+
def exists(container_path, info=info):
|
|
252
|
+
try:
|
|
253
|
+
return L.host_path(info, container_path).exists()
|
|
254
|
+
except (L.LanesError, OSError):
|
|
255
|
+
return None # outside the home volume / container unknown: not knowable, never "missing"
|
|
256
|
+
mode = {'ws': item['id'], 'exists': exists, 'repair': canonicalize, 'identity': identity}
|
|
257
|
+
new = normalized_text(old, lanes, mode if canonicalize or identity else None)
|
|
127
258
|
yield item['id'], host_file, repo['registry'], old, new, 'registry'
|
|
128
259
|
if repo.get('cursor'):
|
|
129
260
|
target = L.host_path(info, repo['cursor'])
|
|
@@ -136,7 +267,8 @@ def run(args):
|
|
|
136
267
|
only = [a for a in args if not a.startswith('--')]
|
|
137
268
|
registry = L.load_registry()
|
|
138
269
|
changed = 0
|
|
139
|
-
|
|
270
|
+
plans = plan(registry, only, '--canonicalize' in args, '--identity' in args)
|
|
271
|
+
for workspace, host_file, container_path, old, new, note in plans:
|
|
140
272
|
if host_file is None:
|
|
141
273
|
print(f'== {workspace}: {note}')
|
|
142
274
|
continue
|
|
@@ -162,5 +294,6 @@ def run(args):
|
|
|
162
294
|
L.atomic_write(host_file, new, mode=mode)
|
|
163
295
|
print(f' written (backup in {backup_dir})')
|
|
164
296
|
if not write and changed:
|
|
165
|
-
|
|
297
|
+
again = ''.join(f' {flag}' for flag in ('--canonicalize', '--identity') if flag in args)
|
|
298
|
+
print(f'\n{changed} file(s) would change. Apply with: lanes cursor-sync <workspace>{again} --write')
|
|
166
299
|
return 0
|