@vibelord/lanes 1.5.0 → 1.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibelord/lanes",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Installer for Lanes: shared agent terminals over SSH with Zellij views, registry sync, uploads and a phone interface.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1 +1 @@
1
- 1.5.0
1
+ 1.5.1
@@ -1127,8 +1127,8 @@ def cmd_attach(args):
1127
1127
  # Record the base and bring the owner (W8 A2) in line, also for sessions that existed before either was known.
1128
1128
  record_base(device, args)
1129
1129
  record_look(device, args)
1130
- # Views idle until a real client (with a real size) has attached once. We exec into the zellij
1131
- # client below, so a detached helper raises the flag shortly after the client has sized the session.
1130
+ # Views idle until a real client (with a real size) has attached once. We exec into the display
1131
+ # supervisor below, so a detached helper raises the flag shortly after its client has sized the session.
1132
1132
  ready = L.STATE / 'clients' / f'{device}.ready'
1133
1133
  if not ready.exists() and os.fork() == 0:
1134
1134
  os.setsid()
@@ -1163,7 +1163,11 @@ def cmd_attach(args):
1163
1163
  'COLORTERM': os.environ.get('COLORTERM', 'truecolor')})
1164
1164
  if L.run_bounded(['infocmp', env['TERM']], 5)[0] != 0: # unknown here — or no answer: the safe default
1165
1165
  env['TERM'] = 'xterm-256color'
1166
- os.execve(str(ZELLIJ), ZELLIJ_BASE + ['attach', device], env)
1166
+ # Own the terminal backlog as well as SSH's transport. A slow Zellij 0.45.1
1167
+ # client can stay alive after its server has dropped it (Exit is behind the
1168
+ # queued frames); keepalives alone cannot make lanes-launch reconnect it.
1169
+ os.execve(sys.executable, [sys.executable, str(L.REPO / 'libexec/attach.py'),
1170
+ *ZELLIJ_BASE, 'attach', device], env)
1167
1171
 
1168
1172
 
1169
1173
  def cmd_open(args):
@@ -711,6 +711,23 @@ identity so folder permissions work. On Linux it is a systemd user service that
711
711
  user manager holds, looked up before every login, so an agent a desktop session hands over after boot is used
712
712
  without a restart. The installer's remembered `--no-uploads` / `--no-desktop` remove what an earlier install added.
713
713
 
714
+ Desktop `lanes attach` execs `libexec/attach.py`, a PTY supervisor for exactly one Zellij client. It relays raw
715
+ input and output with nonblocking writes, forwards the terminal size, and restores terminal modes on exit.
716
+ `OUTPUT_LIMIT` (1 MiB) and `OUTPUT_AGE` (5 s) bound output waiting for the SSH terminal; no output means no timeout.
717
+ Crossing either bound terminates only its child, with `STOP_GRACE` (1 s) before KILL, and exits 1 for the existing
718
+ `lanes-launch` reconnect loop. The view server, its panes and their tmux/zmx holders remain alive. The existing
719
+ `clients/<device>.<client>.pid` now names the supervisor; its argv still carries the exact Zellij attach command,
720
+ so stale-predecessor cleanup reaches the supervisor and its owned child. No new durable state is introduced.
721
+
722
+ This closes the desktop failure observed on Anarchy on 2026-10-16 (DEF-1965). In the pinned Zellij 0.45.1,
723
+ [`ClientSender`](https://github.com/zellij-org/zellij/blob/v0.45.1/zellij-server/src/os_input_output.rs) removes a
724
+ client when its 5,000-message queue fills, but sends Exit only after draining queued messages. The
725
+ [`input handler`](https://github.com/zellij-org/zellij/blob/v0.45.1/zellij-client/src/input_handler.rs) can then wait
726
+ indefinitely for an action acknowledgement. The transport remains alive while the display cannot interact.
727
+ The supervisor bounds terminal backpressure before that can strand an attachment; it does not patch upstream
728
+ Zellij or cover its separate browser transport. `tests/attach_backpressure.py` proves the unread-terminal control,
729
+ bounded disconnect, holder survival and keyboard switching after reattachment with the installed pin.
730
+
714
731
  The sidebar sends an upload request naming paths, workspace and device. The client checks its folder allowlist,
715
732
  streams an archive over SSH, and reports original errors and progress. The host extracts data into that
716
733
  workspace's `uploads/` folder without overwriting files; an optional paste places the resulting paths in the
@@ -378,7 +378,16 @@ lanes studio-host re-key --yes after the owner revoked the Host: a new mac
378
378
  stderr. Writes are atomic and only on a change; the file stays 0600 and `~/.ssh` 0700. A non-default sshd
379
379
  `AuthorizedKeysFile` does not read this block.
380
380
 
381
- - **A view looks wrong / stuck:** `lanes rebuild mac`. Views are disposable; agents are untouched; the client reconnects.
381
+ - **A desktop stops accepting keys while SSH stays connected:** reconnect that window's display first (close and
382
+ reopen the Lanes window, or terminate only its verified SSH display child so `lanes-launch` retries). Do not
383
+ restart holders or containers. `lanes attach` now supervises its Zellij client with `libexec/attach.py`: output
384
+ waiting to reach the terminal is limited to 1 MiB and an oldest pending chunk age of 5 s. Either bound ends only
385
+ that client (TERM, then KILL after 1 s if needed), exits 1, and lets `lanes-launch` reconnect to the existing view.
386
+ Idle terminals have no deadline; input, resizing and normal exit codes pass through. A client that cannot drain
387
+ its terminal cannot be rescued by SSH keepalives alone (DEF-1965). This guard covers desktop SSH attachments;
388
+ Zellij's browser transport does not use it. Already attached desktops pick it up on their next attachment.
389
+ - **A view's layout looks wrong:** `lanes rebuild mac`. Views are disposable; agents are untouched; the client
390
+ reconnects. Rebuilding also ends that view's Host tab shell, so prefer a display reconnect for a frozen client.
382
391
  - **A lane was added/renamed/removed in a repo's `.vscode/terminals.json`:** nothing to do. `lanes-sync.timer`
383
392
  re-reads registries every 2 minutes and the sidebar follows. The zellij layout is never edited live. A sync that
384
393
  finds nothing new leaves `registry.json` (and its `generated_at`) untouched and only records the time in
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env python3
2
+ """Bound the desktop client's terminal backlog; reconnect a slow display, never its session.
3
+
4
+ Zellij 0.45.1 removes a slow client when its 5,000-message queue fills, but
5
+ sends Exit only after draining that queue. Input can wait for an unblock that
6
+ the removed client will never receive. SSH keepalives cannot detect this.
7
+ This relay bounds bytes and age before the terminal can block Zellij's writer.
8
+ """
9
+ import collections
10
+ import errno
11
+ import fcntl
12
+ import os
13
+ import pty
14
+ import select
15
+ import signal
16
+ import sys
17
+ import termios
18
+ import time
19
+ import tty
20
+
21
+ OUTPUT_LIMIT = 1024 * 1024
22
+ OUTPUT_AGE = 5.0
23
+ CHUNK = 65536
24
+ STOP_GRACE = 1.0
25
+ # CAN cancels a partial escape sequence. Leave the alternate screen and restore
26
+ # terminal modes even when the child cannot run its own teardown.
27
+ RESET = (b'\x18\x1b[?2026l\x1b[?1049l\x1b[?1000l\x1b[?1002l\x1b[?1003l'
28
+ b'\x1b[?1006l\x1b[?2004l\x1b[<u\x1b[0m\x1b[?25h')
29
+
30
+
31
+ def relay(command, output_limit=OUTPUT_LIMIT, output_age=OUTPUT_AGE):
32
+ if not command:
33
+ raise ValueError('No display command supplied')
34
+ if not os.isatty(0) or not os.isatty(1):
35
+ raise ValueError('The Lanes display needs a terminal')
36
+ saved = termios.tcgetattr(0)
37
+ size = fcntl.ioctl(0, termios.TIOCGWINSZ, b'\0' * 8)
38
+ flags = fcntl.fcntl(1, fcntl.F_GETFL)
39
+ pending = collections.deque() # (time read, remaining bytes); includes partial writes
40
+ pending_size = 0
41
+ input_pending = b''
42
+ child = master = None
43
+ status = None
44
+ stopped = False
45
+ reason = None
46
+
47
+ def stop(_sig, _frame):
48
+ nonlocal stopped
49
+ stopped = True
50
+
51
+ def resize(_sig=None, _frame=None):
52
+ # forkpty returns -1 in the child. The parent's first resize can send
53
+ # SIGWINCH before that child has restored its inherited handlers.
54
+ if master is not None and master >= 0:
55
+ try:
56
+ fcntl.ioctl(master, termios.TIOCSWINSZ,
57
+ fcntl.ioctl(0, termios.TIOCGWINSZ, b'\0' * 8))
58
+ except OSError:
59
+ pass # the terminal may have closed while SIGWINCH was pending
60
+
61
+ signals = (signal.SIGHUP, signal.SIGTERM, signal.SIGINT, signal.SIGWINCH)
62
+ handlers = {sig: signal.getsignal(sig) for sig in signals}
63
+ try:
64
+ # Install before fork: a close during startup must still reap our child.
65
+ for sig in signals:
66
+ signal.signal(sig, resize if sig == signal.SIGWINCH else stop)
67
+ tty.setraw(0)
68
+ child, master = pty.fork()
69
+ if child == 0:
70
+ for sig in signals:
71
+ signal.signal(sig, signal.SIG_DFL)
72
+ try:
73
+ # Give the first render the real dimensions, before exec can
74
+ # race the parent's initial SIGWINCH forwarding.
75
+ fcntl.ioctl(0, termios.TIOCSWINSZ, size)
76
+ os.execvpe(command[0], command, os.environ)
77
+ except OSError as error:
78
+ os.write(2, f'lanes: cannot start display: {error}\r\n'.encode())
79
+ os._exit(127)
80
+ os.set_blocking(master, False)
81
+ fcntl.fcntl(1, fcntl.F_SETFL, flags | os.O_NONBLOCK)
82
+ resize()
83
+ eof = False
84
+ while not stopped:
85
+ if status is None:
86
+ pid, value = os.waitpid(child, os.WNOHANG)
87
+ if pid:
88
+ status = value
89
+ if pending and time.monotonic() - pending[0][0] >= output_age:
90
+ reason = 'display output stalled'
91
+ break
92
+ if eof and not pending:
93
+ break
94
+ readers = ([] if eof else [master]) + ([] if input_pending or status is not None else [0])
95
+ writers = ([1] if pending else []) + ([master] if input_pending and not eof else [])
96
+ readable, writable, _ = select.select(readers, writers, [], 0.05)
97
+ # Drain first: a writable terminal should not trip the byte limit.
98
+ if 1 in writable:
99
+ at, data = pending[0]
100
+ try:
101
+ written = os.write(1, data)
102
+ except BlockingIOError:
103
+ written = 0
104
+ pending_size -= written
105
+ if written == len(data):
106
+ pending.popleft()
107
+ elif written:
108
+ pending[0] = (at, data[written:])
109
+ if master in writable:
110
+ try:
111
+ written = os.write(master, input_pending)
112
+ input_pending = input_pending[written:]
113
+ except BlockingIOError:
114
+ pass
115
+ if 0 in readable:
116
+ input_pending = os.read(0, CHUNK)
117
+ if not input_pending:
118
+ break
119
+ if master in readable:
120
+ try:
121
+ data = os.read(master, CHUNK)
122
+ except BlockingIOError:
123
+ continue
124
+ except OSError as error:
125
+ if error.errno != errno.EIO:
126
+ raise
127
+ data = b'' # Linux PTYs report EIO when the slave closes
128
+ if not data:
129
+ eof = True
130
+ elif pending_size + len(data) > output_limit:
131
+ reason = 'display output exceeded its backlog limit'
132
+ break
133
+ else:
134
+ pending.append((time.monotonic(), data))
135
+ pending_size += len(data)
136
+ finally:
137
+ if child and status is None:
138
+ # Signal only our unreaped child, never a session server or holder.
139
+ try:
140
+ os.kill(child, signal.SIGTERM)
141
+ except ProcessLookupError:
142
+ pass
143
+ deadline = time.monotonic() + STOP_GRACE
144
+ while time.monotonic() < deadline:
145
+ pid, value = os.waitpid(child, os.WNOHANG)
146
+ if pid:
147
+ status = value
148
+ break
149
+ time.sleep(0.02)
150
+ if status is None:
151
+ try:
152
+ os.kill(child, signal.SIGKILL)
153
+ except ProcessLookupError:
154
+ pass
155
+ _, status = os.waitpid(child, 0)
156
+ if master is not None:
157
+ os.close(master)
158
+ # Never block cleanup on the display that caused the timeout. The next
159
+ # attach repaints from current session state; queued stale output dies here.
160
+ ending = RESET
161
+ if reason:
162
+ ending += f'\r\nlanes: {reason}; reconnecting the display.\r\n'.encode()
163
+ try:
164
+ os.write(1, ending)
165
+ except OSError:
166
+ pass
167
+ try:
168
+ termios.tcsetattr(0, termios.TCSANOW, saved)
169
+ finally:
170
+ fcntl.fcntl(1, fcntl.F_SETFL, flags)
171
+ for sig, handler in handlers.items():
172
+ signal.signal(sig, handler)
173
+ # 1 is a reconnectable failure in lanes-launch; 64–78 impose a 60 s hold.
174
+ return 1 if reason or stopped else (os.waitstatus_to_exitcode(status) if status is not None else 0)
175
+
176
+
177
+ if __name__ == '__main__':
178
+ try:
179
+ sys.exit(relay(sys.argv[1:]))
180
+ except (OSError, ValueError) as error:
181
+ sys.exit(f'lanes: display attach failed: {error}')
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@vibelord/lanes",
3
- "version": "1.5.0",
4
- "commit": "ac8e653198f940c895645325759f19fb1dabe18e",
5
- "digest": "sha256:acf5755212a15acabefd00c799c206bf05dd7003696fccdbdf21058785c54374",
3
+ "version": "1.5.1",
4
+ "commit": "3b7ac2e84a23ed47f2ab3f404232286c47d3f2e8",
5
+ "digest": "sha256:1647256dba4c8075b45ba74c973e4ccaf00ae74e2f572f5678c16e7ad1f682bc",
6
6
  "executables": [
7
7
  "bin/lanes",
8
8
  "client/install.sh",