@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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/bin/lanes-install.mjs +158 -0
  4. package/lib/plan.mjs +65 -0
  5. package/package.json +37 -0
  6. package/payload/lanes/README.md +152 -0
  7. package/payload/lanes/VERSION +1 -0
  8. package/payload/lanes/bin/lanes +1035 -0
  9. package/payload/lanes/client/ghostty/common.conf +22 -0
  10. package/payload/lanes/client/ghostty/macos.conf +6 -0
  11. package/payload/lanes/client/ghostty/omarchy.conf +19 -0
  12. package/payload/lanes/client/install.sh +16 -0
  13. package/payload/lanes/client/lanes-launch +315 -0
  14. package/payload/lanes/client/lanes-upload +147 -0
  15. package/payload/lanes/client/lanes-window +36 -0
  16. package/payload/lanes/client/macos-helper.applescript +14 -0
  17. package/payload/lanes/client/omarchy/discover.sh +56 -0
  18. package/payload/lanes/client/omarchy/lanes.conf +5 -0
  19. package/payload/lanes/client/omarchy/lanes.desktop +9 -0
  20. package/payload/lanes/client/omarchy/lanes.lua +14 -0
  21. package/payload/lanes/client/ssh.stanza.in +54 -0
  22. package/payload/lanes/container/env.sh +16 -0
  23. package/payload/lanes/container/lane +268 -0
  24. package/payload/lanes/container/lane-run +34 -0
  25. package/payload/lanes/docs/ARCHITECTURE.md +93 -0
  26. package/payload/lanes/docs/HOST-GUARD.md +108 -0
  27. package/payload/lanes/docs/INSTALL.md +192 -0
  28. package/payload/lanes/docs/RUNBOOK.md +385 -0
  29. package/payload/lanes/docs/SHARING.md +124 -0
  30. package/payload/lanes/docs/ZMX-RING0.md +51 -0
  31. package/payload/lanes/docs/images/desktop.png +0 -0
  32. package/payload/lanes/docs/images/iphone.png +0 -0
  33. package/payload/lanes/holders/exec.sh +21 -0
  34. package/payload/lanes/holders/tmux.sh +111 -0
  35. package/payload/lanes/holders/zmx.sh +22 -0
  36. package/payload/lanes/holders.lock +5 -0
  37. package/payload/lanes/install/enable-phone.sh +43 -0
  38. package/payload/lanes/install/enable-web.sh +16 -0
  39. package/payload/lanes/install/host-install.sh +72 -0
  40. package/payload/lanes/install/services.py +61 -0
  41. package/payload/lanes/install.sh +603 -0
  42. package/payload/lanes/libexec/cursor_commit.py +159 -0
  43. package/payload/lanes/libexec/cursor_sync.py +166 -0
  44. package/payload/lanes/libexec/guard.py +231 -0
  45. package/payload/lanes/libexec/lanes_lib.py +610 -0
  46. package/payload/lanes/libexec/legacy_sessions.py +130 -0
  47. package/payload/lanes/libexec/session_actions.py +276 -0
  48. package/payload/lanes/libexec/sidebar +8 -0
  49. package/payload/lanes/libexec/sidebar.py +872 -0
  50. package/payload/lanes/libexec/statusd.py +764 -0
  51. package/payload/lanes/libexec/tmpscan.py +921 -0
  52. package/payload/lanes/libexec/view.sh +179 -0
  53. package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
  54. package/payload/lanes/site/README.md +28 -0
  55. package/payload/lanes/site/beyondwork/known_hosts +1 -0
  56. package/payload/lanes/site/beyondwork/site.conf +11 -0
  57. package/payload/lanes/site/site.example.conf +10 -0
  58. package/payload/lanes/sudo/host-guard.sh +356 -0
  59. package/payload/lanes/sudo/one-time-root.sh +32 -0
  60. package/payload/lanes/systemd/lanes-ensure.service +7 -0
  61. package/payload/lanes/systemd/lanes-ensure.timer +10 -0
  62. package/payload/lanes/systemd/lanes-gc.service +7 -0
  63. package/payload/lanes/systemd/lanes-gc.timer +9 -0
  64. package/payload/lanes/systemd/lanes-guard.service +8 -0
  65. package/payload/lanes/systemd/lanes-guard.timer +10 -0
  66. package/payload/lanes/systemd/lanes-mobile.service +23 -0
  67. package/payload/lanes/systemd/lanes-statusd.service +15 -0
  68. package/payload/lanes/systemd/lanes-sync.service +10 -0
  69. package/payload/lanes/systemd/lanes-sync.timer +10 -0
  70. package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
  71. package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
  72. package/payload/lanes/systemd/lanes-web.service +13 -0
  73. package/payload/lanes/systemd/lanes.slice +11 -0
  74. package/payload/lanes/web/Caddyfile +37 -0
  75. package/payload/lanes/web/README.md +35 -0
  76. package/payload/lanes/web/icon-192.png +0 -0
  77. package/payload/lanes/web/icon.svg +4 -0
  78. package/payload/lanes/web/index.html +30 -0
  79. package/payload/lanes/web/manifest.webmanifest +15 -0
  80. package/payload/lanes/web/mobile.css +301 -0
  81. package/payload/lanes/web/mobile.js +367 -0
  82. package/payload/lanes/workspaces.example/README.md +27 -0
  83. package/payload/lanes/workspaces.example/catalog.json +7 -0
  84. package/payload/lanes/workspaces.example/commit-targets.json +8 -0
  85. package/payload/lanes/workspaces.example/terminals.example.json +19 -0
  86. package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
  87. package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
  88. package/payload/lanes/zellij/config.kdl +360 -0
  89. package/payload/manifest.json +31 -0
@@ -0,0 +1,56 @@
1
+ #!/bin/sh
2
+ # READ-ONLY discovery for the lanes client on an Omarchy machine. Changes nothing. Run it on the machine
3
+ # (or: ssh anarchy 'sh -s' < client/omarchy/discover.sh ). Output is the input for install.sh decisions.
4
+ section() { printf '\n== %s\n' "$1"; }
5
+ have() { command -v "$1" >/dev/null 2>&1; }
6
+ show() { [ -r "$1" ] && { printf -- '--- %s\n' "$1"; sed -n "1,${2:-40}p" "$1"; } || printf -- '--- %s (absent)\n' "$1"; }
7
+
8
+ section system
9
+ uname -srm; . /etc/os-release 2>/dev/null && echo "$PRETTY_NAME"
10
+ for v in "$HOME/.local/share/omarchy/version" /usr/share/omarchy/version; do [ -r "$v" ] && echo "omarchy version file: $(cat "$v")"; done
11
+ have omarchy-version && echo "omarchy-version: $(omarchy-version 2>/dev/null)"
12
+ [ -d "$HOME/.local/share/omarchy/.git" ] && echo "omarchy git: $(git -C "$HOME/.local/share/omarchy" describe --tags --always 2>/dev/null)"
13
+ echo "cpu: $(grep -m1 'model name' /proc/cpuinfo | cut -d: -f2- | sed 's/^ //') x$(nproc)"; free -h | sed -n 2p
14
+ lspci 2>/dev/null | grep -i -E 'vga|3d|display' | cut -c1-120
15
+
16
+ section 'display + hyprland'
17
+ have hyprctl && { hyprctl version 2>/dev/null | sed -n '1,3p'; hyprctl monitors 2>/dev/null | grep -E '^Monitor|\s+[0-9]+x[0-9]+@|scale' | head -12; }
18
+ echo "XDG_CURRENT_DESKTOP=${XDG_CURRENT_DESKTOP:-} XDG_SESSION_TYPE=${XDG_SESSION_TYPE:-}"
19
+
20
+ section terminals
21
+ for t in ghostty alacritty kitty foot wezterm; do have $t && echo "$t: $($t --version 2>/dev/null | head -1)"; done
22
+ show "$HOME/.config/xdg-terminals.list" 10
23
+ echo "TERMINAL=${TERMINAL:-unset}"; grep -rhs 'TERMINAL' "$HOME/.config/uwsm/default" "$HOME/.config/uwsm/env" 2>/dev/null | head -5
24
+ show "$HOME/.config/ghostty/config" 60
25
+ ls -la "$HOME/.config/omarchy/current/" 2>/dev/null | cut -c1-140
26
+ ls "$HOME/.config/omarchy/current/theme/" 2>/dev/null | tr '\n' ' '; echo
27
+ have ghostty && { echo "--- ghostty default keybinds using ctrl+alt:"; ghostty +list-keybinds --default 2>/dev/null | grep -i 'ctrl+alt\|alt+ctrl' | head -20; }
28
+ infocmp xterm-ghostty >/dev/null 2>&1 && echo "terminfo xterm-ghostty: present" || echo "terminfo xterm-ghostty: MISSING"
29
+
30
+ section 'hyprland config (user-owned files)'
31
+ show "$HOME/.config/hypr/hyprland.conf" 40
32
+ echo "--- bindings that involve CTRL+ALT anywhere:"
33
+ grep -rhn -i -E '^\s*bind[a-z]*\s*=\s*[^,]*CTRL[^,]*ALT|^\s*bind[a-z]*\s*=\s*[^,]*ALT[^,]*CTRL' "$HOME/.config/hypr" "$HOME/.local/share/omarchy/default/hypr" 2>/dev/null | cut -c1-160 | head -30
34
+ echo "--- is SUPER SHIFT, L taken?"
35
+ grep -rhn -i -E '^\s*bind[a-z]*\s*=\s*SUPER SHIFT\s*,\s*L\s*,' "$HOME/.config/hypr" "$HOME/.local/share/omarchy/default/hypr" 2>/dev/null | head -5
36
+ echo "--- window rule syntax in use:"; grep -rhs -m3 -E '^\s*windowrule(v2)?\s*=' "$HOME/.local/share/omarchy/default/hypr" "$HOME/.config/hypr" 2>/dev/null | head -4
37
+ have uwsm && echo "uwsm: yes"; have uwsm-app && echo "uwsm-app: yes"; ls "$HOME/.local/share/omarchy/bin" 2>/dev/null | grep -E 'launch|tui|terminal' | tr '\n' ' '; echo
38
+
39
+ section 'network + ssh'
40
+ have tailscale && { tailscale version 2>/dev/null | head -1; tailscale status 2>&1 | head -4; }
41
+ systemctl is-enabled sshd 2>/dev/null | sed 's/^/sshd enabled: /'; systemctl is-active sshd 2>/dev/null | sed 's/^/sshd active: /'
42
+ (have ufw && sudo -n ufw status verbose 2>/dev/null | head -12) || echo "ufw status needs sudo (skipped)"
43
+ ssh -V 2>&1
44
+ ls -la "$HOME/.ssh" 2>/dev/null | awk '{print $1, $NF}' | grep -v -E '^total' | head -14
45
+ grep -n -i -E '^\s*(Host|Include)\s' "$HOME/.ssh/config" 2>/dev/null | head -20
46
+ echo "--- can this machine reach the lanes server directly?"
47
+ # Use the installed alias; never probe a particular deployment from a generic diagnostic.
48
+ ssh -o BatchMode=yes -o ConnectTimeout=4 lanes true </dev/null 2>/dev/null &&
49
+ echo 'lanes: SSH reachable and authorized' || echo 'lanes: not configured, unreachable, or unauthorized'
50
+ pgrep -af 'ssh .*-R ' 2>/dev/null | cut -c1-160 | head -4
51
+
52
+ section 'existing multiplexer / lanes bits'
53
+ for t in zellij tmux mosh coder; do have $t && echo "$t: $($t --version 2>/dev/null | head -1)"; done
54
+ ls -d "$HOME/.config/zellij" "$HOME/.config/lanes" "$HOME/lanes" "$HOME/.local/bin/coder-launch" "$HOME/.local/bin/lanes-launch" 2>/dev/null
55
+ fc-list 2>/dev/null | grep -i -E 'jetbrains|caskaydia|nerd' | cut -d: -f2 | sort -u | head -8
56
+ echo "shell: $SHELL"; echo done
@@ -0,0 +1,5 @@
1
+ # lanes on Hyprland with a hyprlang config (Omarchy 3.x). Sourced from ~/.config/hypr/hyprland.conf.
2
+ bindd = SUPER SHIFT, L, Lanes, exec, omarchy-launch-or-focus ^org\.omarchy\.lanes$ "uwsm-app -- lanes-window"
3
+ bindd = SUPER SHIFT ALT, L, Lanes (wide 2 viewports), exec, uwsm-app -- lanes-window w2
4
+ # Omarchy 3's terminal tag does not cover org.omarchy.*: tag the lanes window so Super+C/V use the terminal keys.
5
+ windowrule = tag +terminal, match:class ^(org\.omarchy\.lanes)$
@@ -0,0 +1,9 @@
1
+ [Desktop Entry]
2
+ Type=Application
3
+ Name=Lanes
4
+ Comment=Remote agent lanes on the workspace server
5
+ Exec=lanes-window
6
+ Icon=utilities-terminal
7
+ Terminal=false
8
+ Categories=Development;
9
+ StartupWMClass=org.omarchy.lanes
@@ -0,0 +1,14 @@
1
+ -- lanes workspace server client (see ~/lanes/docs/RUNBOOK.md). Loaded from ~/.config/hypr/hyprland.lua
2
+ -- by one line: require("hypr.lanes") — remove that line and this file to uninstall.
3
+
4
+ -- Launch-or-focus, Omarchy's own form (as shipped for Obsidian): pressing it again focuses the lanes window.
5
+ o.bind("SUPER + SHIFT + L", "Lanes", { launch = "lanes-window", focus = "^org\\.omarchy\\.lanes$" })
6
+ -- A WIDE view session for a full-width window: two viewports side by side per workspace (w3 = three).
7
+ -- In the sidebar: Enter opens in the first viewport, 2 / 3 open the lane in the second / third.
8
+ o.bind("SUPER + SHIFT + ALT + L", "Lanes (wide, 2 viewports)", { launch = "lanes-window w2" })
9
+
10
+ -- Screenshot / copied file on the clipboard -> the board's uploads/ folder, and its path typed into the lane you look at.
11
+ o.bind("SUPER + SHIFT + U", "Lanes: upload clipboard", "lanes-upload --clipboard --paste")
12
+
13
+ -- Optional, like the org.omarchy.herdr rule: keep the lanes window on a fixed workspace.
14
+ -- o.window([[^org\.omarchy\.lanes$]], { workspace = "4 silent" })
@@ -0,0 +1,54 @@
1
+ # lanes: direct path to the workspace server "@ALIAS@" (Tailscale, no relay in between).
2
+ # Generated by the lanes installer into ~/.ssh/config.d/lanes.conf and pulled in by ONE `Include` line in
3
+ # ~/.ssh/config; nothing else in your SSH setup is changed. Re-run the installer instead of editing this file.
4
+ # Options that only some OpenSSH builds know (ObscureKeystrokeTiming, UseKeychain) are written ONLY when this
5
+ # machine's ssh accepts them: an unknown option is a parse error for EVERY host, even inside a non-matching block.
6
+ Host lanes
7
+ HostName @HOST@
8
+ User @USER@
9
+ HostKeyAlias @ALIAS@
10
+ IdentityFile @IDENTITY@
11
+ IdentitiesOnly yes
12
+ RequestTTY force
13
+ # Patient on purpose. ConnectTimeout also covers banner + key exchange: a server under memory pressure needs more
14
+ # than 5 s for those alone and must not look unreachable. A dead path (sleep/wake, Wi-Fi change) is detected
15
+ # within ~60 s and the client loop reconnects; giving up after 15 s turned a slow server into a reconnect storm.
16
+ ConnectTimeout 20
17
+ ServerAliveInterval 10
18
+ ServerAliveCountMax 6
19
+ TCPKeepAlive no
20
+ #@OBSCURE@ # OpenSSH >= 9.5 quantises keystrokes to 20 ms and adds chaff; on a private WireGuard path that is pure latency.
21
+ #@OBSCURE@ ObscureKeystrokeTiming no
22
+ Compression no
23
+ # A passphrase is asked once: the key then lives in the agent (and, on macOS, the keychain).
24
+ AddKeysToAgent yes
25
+ #@KEYCHAIN@ UseKeychain yes
26
+ # One dedicated connection per window: never reuse a master left behind by a sleeping laptop.
27
+ ControlMaster no
28
+ ControlPath none
29
+ ForwardAgent no
30
+ ForwardX11 no
31
+ LogLevel ERROR
32
+ #@PUBLIC@
33
+ #@PUBLIC@ # Fallback path: the same sshd over the server's public address (key-only). lanes-launch uses it only after two
34
+ #@PUBLIC@ # immediate connect failures on `lanes` (tailnet down); it goes back to the tailnet on the next healthy session.
35
+ #@PUBLIC@ Host lanes-public
36
+ #@PUBLIC@ HostName @PUBLIC_HOST@
37
+ #@PUBLIC@ User @USER@
38
+ #@PUBLIC@ HostKeyAlias @ALIAS@
39
+ #@PUBLIC@ IdentityFile @IDENTITY@
40
+ #@PUBLIC@ IdentitiesOnly yes
41
+ #@PUBLIC@ RequestTTY force
42
+ #@PUBLIC@ ConnectTimeout 20
43
+ #@PUBLIC@ ServerAliveInterval 10
44
+ #@PUBLIC@ ServerAliveCountMax 6
45
+ #@PUBLIC@ TCPKeepAlive no
46
+ #@PUBLIC@ #@OBSCURE@ ObscureKeystrokeTiming no
47
+ #@PUBLIC@ Compression yes
48
+ #@PUBLIC@ AddKeysToAgent yes
49
+ #@PUBLIC@ #@KEYCHAIN@ UseKeychain yes
50
+ #@PUBLIC@ ControlMaster no
51
+ #@PUBLIC@ ControlPath none
52
+ #@PUBLIC@ ForwardAgent no
53
+ #@PUBLIC@ ForwardX11 no
54
+ #@PUBLIC@ LogLevel ERROR
@@ -0,0 +1,16 @@
1
+ # lanes env.sh — keep temp files OFF the RAM-backed /tmp. POSIX sh; SOURCED (never executed) from the shell profiles
2
+ # as ~/.lz/env.sh, so it must be safe under `set -eu`, print nothing, and leave no helper variable behind.
3
+ #
4
+ # /tmp in the container is a tmpfs: every byte in it is container memory (2026-09-19: 95 of 208 GiB). This points
5
+ # the two knobs that fill it — TMPDIR (compilers, test runners, mktemp) and CLAUDE_CODE_TMPDIR (agent scratch and
6
+ # task output) — at a directory on the home volume instead. `lanes tmp reap` keeps that directory from growing.
7
+ # * Only NEW shells and agents see it; whatever already runs keeps /tmp until it restarts.
8
+ # * tmux is NOT affected: it honours TMUX_TMPDIR only, so lane sockets stay in /tmp/tmux-<uid>.
9
+ # * Nothing is created here: no directory (or a read-only one) = no change. Opt out per shell or for good with
10
+ # LANES_TMP_OPTOUT=1 (any value, even empty, counts). LANES_TMP_DIR names another directory.
11
+ if [ -z "${LANES_TMP_OPTOUT+set}" ] && [ -d "${LANES_TMP_DIR:-/home/coder/.tmp}" ] \
12
+ && [ -w "${LANES_TMP_DIR:-/home/coder/.tmp}" ] && [ -x "${LANES_TMP_DIR:-/home/coder/.tmp}" ]; then
13
+ TMPDIR=${LANES_TMP_DIR:-/home/coder/.tmp}
14
+ CLAUDE_CODE_TMPDIR=$TMPDIR
15
+ export TMPDIR CLAUDE_CODE_TMPDIR
16
+ fi
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env python3
2
+ """lane — the in-container lane contract (Python 3 stdlib). Installed at /home/coder/.lz/bin/lane.
3
+
4
+ A lane is one long-lived agent terminal, held by a per-lane zmx daemon (byte pass-through + instant
5
+ re-attach). Viewers, orchestrators and the web harness use ONLY these verbs, so the holder stays swappable.
6
+
7
+ lane new <id> [--ws W] [--title T] [--cwd DIR] [--icon I] [--color C] -- <command...>
8
+ lane open <lane-id> the uniform entry point for editors (Cursor / VS Code terminals.json): attach to the
9
+ lane; if it does not exist, run its recorded create recipe. Never nests inside tmux.
10
+ While the lanes view shows the lane, attach with ignore-size (the lanes view owns the size).
11
+ lane attach <id> attach this terminal (never creates)
12
+ lane send <id> <text...> type into the lane (use $'\\r' for Enter)
13
+ lane capture <id> [--lines N] [--vt] print scrollback (plain text, or VT with --vt)
14
+ lane ls [--json] lanes with holder metadata
15
+ lane status <id> JSON: running?, last exit of the agent command, meta
16
+ lane kill <id> stop the lane (the ONLY verb that ends an agent)
17
+ """
18
+ import json
19
+ import os
20
+ import pty
21
+ import re
22
+ import select
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ from pathlib import Path
27
+
28
+ ROOT = Path(os.environ.get('LANE_ROOT', '/home/coder/.lz'))
29
+ VERSION = os.environ.get('LANE_ZMX_VERSION', '0.8.1')
30
+ ZMX = ROOT / 'bin' / f'zmx-{VERSION}'
31
+ SOCKETS, META, STATE = ROOT / 's', ROOT / 'meta', ROOT / 'state'
32
+ IDENT = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$')
33
+
34
+
35
+ def zmx_env(extra=None):
36
+ env = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'HOME': str(Path.home()), 'SHELL': '/bin/bash',
37
+ 'LANG': 'C.UTF-8', 'ZMX_DIR': str(SOCKETS), 'ZMX_NO_DETACH_KEY': '1',
38
+ 'TERM': os.environ.get('TERM', 'xterm-256color')}
39
+ env.update(extra or {})
40
+ return env
41
+
42
+
43
+ def zmx(*args, **kwargs):
44
+ return subprocess.run([str(ZMX), *args], env=zmx_env(), capture_output=True, text=True, timeout=30, **kwargs)
45
+
46
+
47
+ def lane_id(value):
48
+ if not IDENT.match(value or ''):
49
+ sys.exit('lane: invalid lane id (letters, digits, _ . -; max 64)')
50
+ return value
51
+
52
+
53
+ def alive(name):
54
+ """The socket file is the liveness signal; `zmx list` supplies the metadata.
55
+ (`zmx list` rows can be indented / carry a current-session marker: keys are stripped when parsed.)"""
56
+ try:
57
+ return (SOCKETS / name).is_socket()
58
+ except OSError:
59
+ return False
60
+
61
+
62
+ def sessions(expect=None, attempts=4):
63
+ rows = {}
64
+ for attempt in range(attempts):
65
+ rows = {}
66
+ for line in zmx('list').stdout.splitlines():
67
+ fields = {k.strip(' \u2192*>'): v for k, v in (part.split('=', 1) for part in line.split('\t') if '=' in part)}
68
+ if fields.get('name'):
69
+ rows[fields['name']] = fields
70
+ if not expect or expect in rows or not alive(expect):
71
+ break
72
+ time.sleep(0.4)
73
+ return rows
74
+
75
+
76
+ def option(args, name, default=None):
77
+ if name in args:
78
+ index = args.index(name)
79
+ value = args[index + 1]
80
+ del args[index:index + 2]
81
+ return value
82
+ return default
83
+
84
+
85
+ def cmd_new(args):
86
+ if '--' not in args:
87
+ sys.exit('lane new: give the command after `--`')
88
+ split = args.index('--')
89
+ head, command = args[:split], args[split + 1:]
90
+ if not command:
91
+ sys.exit('lane new: empty command')
92
+ workspace = option(head, '--ws', '')
93
+ title = option(head, '--title', '')
94
+ cwd = option(head, '--cwd', str(Path.home()))
95
+ icon = option(head, '--icon', '')
96
+ color = option(head, '--color', '')
97
+ name = lane_id(head[0] if head else '')
98
+ if workspace and not IDENT.match(workspace):
99
+ sys.exit('lane new: invalid workspace id')
100
+ if not Path(cwd).is_dir():
101
+ sys.exit(f'lane new: no such directory: {cwd}')
102
+ if alive(name) or name in sessions():
103
+ sys.exit(f'lane new: {name} already exists (never replaced implicitly)')
104
+ for directory in (SOCKETS, META, STATE):
105
+ directory.mkdir(parents=True, exist_ok=True)
106
+ meta = {'id': name, 'ws': workspace, 'title': title or name, 'cwd': cwd, 'icon': icon, 'color': color,
107
+ 'command': command, 'holder': 'zmx', 'zmx_version': VERSION, 'created_at': time.time()}
108
+ tmp = META / f'.{name}.json.tmp'
109
+ tmp.write_text(json.dumps(meta, ensure_ascii=False) + '\n')
110
+ tmp.replace(META / f'{name}.json')
111
+ labels = f'ws={workspace or "-"} holder=zmx-{VERSION}'
112
+ # zmx creates a session only through `attach`; do it on a throwaway pty and let go once it is listed.
113
+ # The lane's root process is lane-run: a defined, minimal environment; it never exits.
114
+ argv = [str(ZMX), 'attach', '--labels', labels, name, str(ROOT / 'bin/lane-run'), name, cwd, '--'] + command
115
+ pid, master = pty.fork()
116
+ if pid == 0:
117
+ os.chdir(cwd)
118
+ os.execve(argv[0], argv, zmx_env({'TERM': 'xterm-256color'}))
119
+ deadline = time.time() + 10
120
+ while time.time() < deadline and not alive(name):
121
+ if select.select([master], [], [], 0.1)[0]: # never block: a quiet client must not hang creation
122
+ try:
123
+ os.read(master, 65536)
124
+ except OSError:
125
+ break
126
+ time.sleep(0.3)
127
+ os.kill(pid, 15)
128
+ os.close(master)
129
+ os.waitpid(pid, 0)
130
+ if not alive(name) or name not in sessions(expect=name):
131
+ sys.exit(f'lane new: {name} did not start')
132
+ print(name)
133
+
134
+
135
+ VIEWER_TERM = 'xterm-lanesview' # TERM of host lanes-view tmux clients (holders/tmux.sh), seen as #{client_termname}
136
+
137
+
138
+ def registry_entry(lane):
139
+ try:
140
+ data = json.loads((ROOT / 'registry.json').read_text())
141
+ lanes = data['lanes']
142
+ except (OSError, ValueError, KeyError):
143
+ sys.exit('lane open: no registry yet (the host runs `lanes sync` every 2 minutes)')
144
+ if int(data.get('version', 1)) > 1:
145
+ sys.exit('lane open: the registry is newer than this `lane` — on the host run: lanes install-container <container>')
146
+ if lane not in lanes:
147
+ sys.exit(f'lane open: unknown lane {lane!r} — regenerate with `lanes cursor-sync` on the host')
148
+ return lanes[lane]
149
+
150
+
151
+ def tmux_cmd(socket, *args):
152
+ env = {k: v for k, v in os.environ.items() if k != 'TMUX'}
153
+ return subprocess.run(['tmux', '-L', socket, *args], capture_output=True, text=True, timeout=15, env=env)
154
+
155
+
156
+ def cmd_open(args):
157
+ if not args or not re.match(r'^[A-Za-z0-9][A-Za-z0-9_.-]*$', args[0]):
158
+ sys.exit('usage: lane open <lane-id>')
159
+ entry = registry_entry(args[0])
160
+ if os.environ.get('TMUX') and '--force' not in args:
161
+ sys.exit(f"lane open: already inside tmux — refusing to nest. The lane is: {json.dumps(entry.get('tmux') or entry.get('zmx'))}")
162
+ env = {k: v for k, v in os.environ.items() if k not in ('TMUX', 'TMUX_PANE')}
163
+ if entry.get('holder') == 'zmx':
164
+ name = entry['zmx']['name']
165
+ if alive(name):
166
+ os.execve(str(ZMX), [str(ZMX), 'attach', name, '/bin/false'], zmx_env())
167
+ else:
168
+ socket, session = entry['tmux']['socket'], entry['tmux']['session']
169
+ if tmux_cmd(socket, 'has-session', '-t', f'={session}').returncode == 0:
170
+ clients = tmux_cmd(socket, 'list-clients', '-t', f'={session}', '-F', '#{client_termname}').stdout.split()
171
+ argv = ['tmux', '-L', socket, 'attach-session']
172
+ if VIEWER_TERM in clients:
173
+ argv += ['-f', 'ignore-size'] # the lanes view is showing this lane: never resize it from here
174
+ os.execvpe('tmux', argv + ['-t', f'={session}'], env)
175
+ # The lane does not exist: run the recipe the repository recorded for it (it creates AND attaches).
176
+ recipe = entry.get('create')
177
+ folder = entry.get('folder') or str(Path.home())
178
+ if not recipe:
179
+ sys.exit(f"lane open: {entry.get('name')} is not running and has no create recipe")
180
+ recipe = recipe.replace('${workspaceFolder}', folder).replace('[workspaceFolder]', folder)
181
+ print(f"[lane] {entry.get('name')} is not running — creating it with its recorded recipe…", flush=True)
182
+ try:
183
+ os.chdir(folder)
184
+ except OSError:
185
+ pass
186
+ os.execvpe('bash', ['bash', '-lc', recipe], env)
187
+
188
+
189
+ def cmd_attach(args):
190
+ name = lane_id(args[0] if args else '')
191
+ if not alive(name):
192
+ sys.exit(f'lane attach: {name} is not running (never created implicitly)')
193
+ # Clean environment on purpose: with ZMX_SESSION set, zmx would SWITCH the calling session instead of attaching.
194
+ os.execve(str(ZMX), [str(ZMX), 'attach', name, '/bin/false'], zmx_env())
195
+
196
+
197
+ def cmd_send(args):
198
+ name = lane_id(args[0] if args else '')
199
+ result = zmx('send', name, ' '.join(args[1:]))
200
+ sys.stderr.write(result.stderr)
201
+ sys.exit(result.returncode)
202
+
203
+
204
+ def cmd_capture(args):
205
+ lines = int(option(args, '--lines', '0') or 0)
206
+ flags = ['--vt'] if '--vt' in args else []
207
+ name = lane_id(next((a for a in args if not a.startswith('--')), ''))
208
+ text = zmx('history', name, *flags).stdout
209
+ if lines:
210
+ text = '\n'.join(text.splitlines()[-lines:]) + '\n'
211
+ sys.stdout.write(text)
212
+
213
+
214
+ def describe():
215
+ rows = []
216
+ for name, fields in sorted(sessions().items()):
217
+ meta = {}
218
+ try:
219
+ meta = json.loads((META / f'{name}.json').read_text())
220
+ except (OSError, ValueError):
221
+ pass
222
+ state = {}
223
+ try:
224
+ state = json.loads((STATE / f'{name}.json').read_text())
225
+ except (OSError, ValueError):
226
+ pass
227
+ rows.append({'id': name, 'holder': 'zmx', 'pid': int(fields.get('pid', 0)), 'clients': int(fields.get('clients', 0)),
228
+ 'ws': meta.get('ws', ''), 'title': meta.get('title', name), 'cwd': meta.get('cwd', ''),
229
+ 'icon': meta.get('icon', ''), 'color': meta.get('color', ''), 'agent': state})
230
+ return rows
231
+
232
+
233
+ def cmd_ls(args):
234
+ rows = describe()
235
+ if '--json' in args:
236
+ print(json.dumps(rows, ensure_ascii=False, indent=1))
237
+ return
238
+ for row in rows:
239
+ agent = row['agent'].get('phase', '?')
240
+ print(f"{row['id']:<40} {row['ws']:<18} clients={row['clients']} {agent:<8} {row['title']}")
241
+
242
+
243
+ def cmd_status(args):
244
+ name = lane_id(args[0] if args else '')
245
+ row = next((r for r in describe() if r['id'] == name), None)
246
+ print(json.dumps(row or {'id': name, 'running': False}, ensure_ascii=False, indent=1))
247
+ sys.exit(0 if row else 1)
248
+
249
+
250
+ def cmd_kill(args):
251
+ name = lane_id(args[0] if args else '')
252
+ result = zmx('kill', name)
253
+ for path in (META / f'{name}.json', STATE / f'{name}.json'):
254
+ try:
255
+ path.unlink()
256
+ except OSError:
257
+ pass
258
+ sys.stderr.write(result.stderr)
259
+ sys.exit(result.returncode)
260
+
261
+
262
+ COMMANDS = {'new': cmd_new, 'open': cmd_open, 'attach': cmd_attach, 'send': cmd_send, 'capture': cmd_capture, 'ls': cmd_ls,
263
+ 'status': cmd_status, 'kill': cmd_kill}
264
+
265
+ if __name__ == '__main__':
266
+ if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
267
+ sys.exit(__doc__)
268
+ COMMANDS[sys.argv[1]](sys.argv[2:])
@@ -0,0 +1,34 @@
1
+ #!/bin/sh
2
+ # lane-run — root process of every zmx-held lane. It NEVER exits on its own (a zmx session ends when its
3
+ # root command ends): it runs the agent command, records how it ended, then offers a login shell in the
4
+ # same directory, then loops back to the shell forever. "Lanes never die on agent exit."
5
+ # usage: lane-run <lane-id> <cwd> -- <command...>
6
+ set -u
7
+ id=${1:?lane id}; cwd=${2:?cwd}; shift 2
8
+ [ "${1:-}" = "--" ] && shift
9
+ root=${LANE_ROOT:-/home/coder/.lz}
10
+ mkdir -p "$root/state"
11
+
12
+ record() { # phase, exit code
13
+ printf '{"phase": "%s", "exit": %s, "at": %s, "pid": %s}\n' "$1" "${2:-null}" "$(date +%s)" "$$" > "$root/state/.$id.json.tmp" \
14
+ && mv "$root/state/.$id.json.tmp" "$root/state/$id.json"
15
+ }
16
+
17
+ # A defined, minimal environment: nothing from whoever created the lane leaks into the agent.
18
+ # LANE_ID gives exact agent -> lane attribution (readable from /proc/<pid>/environ).
19
+ cd "$cwd" 2>/dev/null || cd "$HOME"
20
+ export LANE_ID="$id" TERM=xterm-256color COLORTERM=truecolor LANG=C.UTF-8 FORCE_COLOR=3 CLICOLOR=1 FORCE_HYPERLINK=1
21
+ unset NO_COLOR NODE_DISABLE_COLORS ZMX_SESSION TMUX ZELLIJ ZELLIJ_SESSION_NAME ZELLIJ_PANE_ID XDG_CACHE_HOME
22
+
23
+ trap '' HUP # a vanishing client must never take the lane down
24
+ record agent null
25
+ # Through a login shell so PATH and tool shims match an interactive lane.
26
+ bash -lc 'exec "$@"' lane-agent "$@"
27
+ code=$?
28
+ while :; do
29
+ record shell "$code"
30
+ printf '\n\033[2m[lane %s] command ended (exit %s) — login shell in %s. The lane stays.\033[0m\n' "$id" "$code" "$cwd"
31
+ bash -l
32
+ code=$?
33
+ sleep 1
34
+ done
@@ -0,0 +1,93 @@
1
+ # How the setup fits together
2
+
3
+ Lanes has three parts: a Linux workspace host, long-running terminal sessions inside its containers, and
4
+ views on desktop or phone. The host owns views and derived registries. The container owns each agent's
5
+ terminal. Installing or closing a view does not end a lane.
6
+
7
+ ## Source of truth and sync
8
+
9
+ 1. The deployment's `catalog.json` orders workspaces and selects their manifests.
10
+ 2. Each manifest identifies container names and repository `.vscode/terminals.json` paths.
11
+ 3. `lanes sync` reads those files through the container's mounted home and emits the host registry,
12
+ exact socket/session resolve files, and the container's `.lz/registry.json` projection.
13
+ 4. The status daemon observes tmux sessions and publishes status/health snapshots. Failed or timed-out
14
+ probes mean unknown; they do not prove a missing session. A bad terminal registry retains the last good board.
15
+
16
+ `lanes cursor-sync` is a separate, explicit writer. Its default is a diff; `--write` takes backups and changes
17
+ editor commands to `lane open <id>`. The original create recipe and exact tmux binding remain in the registry's
18
+ `lanes` block. Automatic sync never commits or pushes those edits. `cursor-commit --push` is an opt-in workflow.
19
+
20
+ ## Views and panes
21
+
22
+ `lanes attach DEVICE` creates or reattaches the device's Zellij view. Layouts have one tab per workspace,
23
+ a lane sidebar, and one to three viewports. The mobile layout uses separate picker and full-width view tabs.
24
+ Its hidden viewports do not attach at placeholder dimensions; they wait until a real client displays them.
25
+
26
+ The tmux holder reaches the container's socket through `/proc/<init-pid>/root`, scrubs the viewer environment,
27
+ and uses `tmux -N` so an attach cannot create a server accidentally. Guarded geometry and client identification
28
+ keep the visible view and editor attachments coordinated. Zmx is a separately pinned alternative holder;
29
+ installing it does not migrate existing tmux lanes.
30
+
31
+ The sidebar always includes **Unregistered** and **New +**. Discovery observes live sockets in workspace
32
+ containers, including shared and unlisted servers. A session GROUPED onto another live session is that session's
33
+ VIEWER — counted in the viewed lane's `attached`/`viewers`, never listed as a session of its own
34
+ (`statusd.viewer_of`); when the group's named leader is gone its OLDEST surviving member leads for that rule,
35
+ and a DECLARED session is always its own row whatever it is grouped onto. Every other session is associated with
36
+ a board by previous assignment, then by the board its own NAME carries (a Throttle Studio `st_<board>_<slug>`
37
+ lane, a `view-<board>-<ch>` browser attach), then by socket ownership or working-directory roots. Optional
38
+ manifest `discoveryRoots` extends those roots. A discovered assignment remains stable after a directory change;
39
+ a session no single board can claim is listed by EVERY owner of its socket rather than dropped, and ambiguity is
40
+ logged rather than resolved by guess.
41
+
42
+ Explicit **New +** starts a detached tmux shell inside the workspace container on its dedicated
43
+ `lanes-shells-<workspace>` socket. Reopening only attaches. Explicit **×** closes an unregistered session after
44
+ rechecking its session ID and creation time and refusing registered lanes; affected viewers become empty.
45
+ The tmux close condition is checked inside the server, protecting a replacement created between observation
46
+ and the action. `user-sessions/` stores display metadata; `status.json` and `resolve.dynamic/` are projections.
47
+ Legacy Docker shells imported into `legacy-sessions/` are observed by PID/start identity. Closing rechecks the
48
+ owner, preserves that root identity through the process-family scan, and requires a verified root pidfd before
49
+ signaling the original process family. Failed verification keeps the recovery record. These older connections cannot be recreated; their
50
+ metadata is local recovery state, never a recipe to start replacement jobs. See [state contracts](../../docs/master-datamodel.md).
51
+
52
+ The `lane` command lives in `/home/coder/.lz/bin`. `open` attaches or creates using its saved recipe;
53
+ `attach` never creates; `send`, `capture`, `ls` and `status` are explicit operations. Only `kill` ends a lane.
54
+ These commands operate inside the container, independently of the Zellij viewer's lifetime.
55
+
56
+ ## Desktop and uploads
57
+
58
+ The client installer creates an SSH alias and dedicated terminal configuration, using the selected profile's
59
+ host key and the user's SSH key. `lanes-launch` reconnects its view and provides a helper channel for uploads.
60
+ A background helper can serve the same channel when installed; on macOS it runs under the Lanes Helper app's
61
+ identity so folder permissions work.
62
+
63
+ The sidebar sends an upload request naming paths, workspace and device. The client checks its folder allowlist,
64
+ streams an archive over SSH, and reports original errors and progress. The host extracts data into that
65
+ workspace's `uploads/` folder without overwriting files; an optional paste places the resulting paths in the
66
+ selected lane. Active transfers have an idle timeout, not a short total-duration limit. Credentials, `.git`
67
+ metadata and disallowed folders cannot be uploaded through the helper.
68
+
69
+ ## Web and phone
70
+
71
+ Tailscale Serve terminates private HTTPS on 8443 and forwards to the dedicated Caddy user service on loopback
72
+ 8083. Caddy serves `web/` and forwards native assets, authentication and WebSockets to Zellij on loopback 8082.
73
+ Zellij's existing token/cookie checks remain authoritative; the mobile layer adds no second login system.
74
+
75
+ The phone shell uses the pinned client's window hooks for workspaces, panes, input and sizing. A visible
76
+ **Type here** field holds the original terminal input. Programmatic input focus is blocked on phones, so a
77
+ swipe, refresh or server message cannot summon the keyboard. Native taps on input fields still work.
78
+ Safe-area insets and the visual viewport handle the Home indicator, rotation and keyboard.
79
+
80
+ ## Installed files and services
81
+
82
+ `install/services.py` renders the service templates against the actual checkout and installed binary paths.
83
+ It replaces old unit symlinks atomically and creates `~/.local/share/lanes/current` as a discovery shortcut.
84
+ No service requires the checkout to be named `~/lanes`. Re-run the server installer after moving it.
85
+
86
+ Core units are the status observer and sync, ensure and garbage-collection timers. The web units are opt-in.
87
+ Temporary-file cleanup and process protection have explicit plan/apply or enable steps; installation does not
88
+ arm them. The root host guard is also separate and records rollback values. See [the runbook](RUNBOOK.md)
89
+ and [host protection](HOST-GUARD.md) for those operations.
90
+
91
+ The package uses Python's standard library and POSIX shell. Host binaries are versioned and checksum-pinned
92
+ in `holders.lock`; Zellij's web assets are served by that binary and integrity-pinned in `web/index.html`.
93
+ Deployment profiles, workspace catalogs and runtime state remain distinct from shared source code.