@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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +56 -1
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +281 -104
  5. package/payload/lanes/client/lanes-launch +44 -2
  6. package/payload/lanes/container/env.sh +8 -0
  7. package/payload/lanes/container/lane +19 -2
  8. package/payload/lanes/docs/ARCHITECTURE.md +86 -1
  9. package/payload/lanes/docs/DESIGN.md +72 -0
  10. package/payload/lanes/docs/INSTALL.md +39 -0
  11. package/payload/lanes/docs/RUNBOOK.md +94 -1
  12. package/payload/lanes/holders/ssh.sh +18 -0
  13. package/payload/lanes/install/enable-phone.sh +15 -6
  14. package/payload/lanes/install/enable-web.sh +19 -6
  15. package/payload/lanes/libexec/agent_state.py +214 -0
  16. package/payload/lanes/libexec/connection.py +178 -0
  17. package/payload/lanes/libexec/cursor_sync.py +139 -6
  18. package/payload/lanes/libexec/device_sync.py +134 -0
  19. package/payload/lanes/libexec/lanes_lib.py +158 -5
  20. package/payload/lanes/libexec/registrations.py +148 -0
  21. package/payload/lanes/libexec/session_actions.py +47 -15
  22. package/payload/lanes/libexec/sidebar.py +372 -61
  23. package/payload/lanes/libexec/statusd.py +126 -33
  24. package/payload/lanes/libexec/tokens.py +279 -0
  25. package/payload/lanes/libexec/tokens.sh +11 -0
  26. package/payload/lanes/libexec/view.sh +9 -8
  27. package/payload/lanes/web/README.md +15 -0
  28. package/payload/lanes/web/browser.css +22 -0
  29. package/payload/lanes/web/browser.js +73 -0
  30. package/payload/lanes/web/mobile.css +65 -33
  31. package/payload/lanes/web/mobile.js +23 -5
  32. package/payload/lanes/workspaces.example/README.md +9 -3
  33. package/payload/lanes/zellij/config.kdl +61 -3
  34. 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
- def normalized_text(text, lanes_by_name):
35
- """Return the file text with uniform commands + the `lanes` block. Minimal textual edits when possible."""
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
- new = normalized_text(old, lanes)
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
- for workspace, host_file, container_path, old, new, note in plan(registry, only):
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
- print(f'\n{changed} file(s) would change. Apply with: lanes cursor-sync <workspace> --write')
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