@vibelord/lanes 1.0.0 → 1.0.2

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 (56) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +92 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +928 -150
  5. package/payload/lanes/client/ghostty/common.conf +3 -0
  6. package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
  7. package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
  8. package/payload/lanes/client/ghostty/omarchy.conf +3 -2
  9. package/payload/lanes/client/lanes-launch +62 -5
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +8 -0
  12. package/payload/lanes/container/hooks/claude-settings.json +10 -0
  13. package/payload/lanes/container/hooks/codex-hooks.json +22 -0
  14. package/payload/lanes/container/lane +326 -25
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +158 -6
  17. package/payload/lanes/docs/DESIGN.md +250 -0
  18. package/payload/lanes/docs/INSTALL.md +39 -0
  19. package/payload/lanes/docs/RUNBOOK.md +507 -24
  20. package/payload/lanes/docs/SHARING.md +1 -1
  21. package/payload/lanes/holders/exec.sh +2 -5
  22. package/payload/lanes/holders/ssh.sh +18 -0
  23. package/payload/lanes/holders.lock +3 -0
  24. package/payload/lanes/install/enable-phone.sh +15 -6
  25. package/payload/lanes/install/enable-web.sh +19 -6
  26. package/payload/lanes/install/host-install.sh +10 -1
  27. package/payload/lanes/install/services.py +30 -3
  28. package/payload/lanes/install.sh +108 -3
  29. package/payload/lanes/libexec/agent_state.py +246 -0
  30. package/payload/lanes/libexec/connection.py +178 -0
  31. package/payload/lanes/libexec/cursor_sync.py +147 -6
  32. package/payload/lanes/libexec/device_sync.py +134 -0
  33. package/payload/lanes/libexec/economy_poll.py +685 -0
  34. package/payload/lanes/libexec/guard.py +27 -1
  35. package/payload/lanes/libexec/lanes_lib.py +436 -36
  36. package/payload/lanes/libexec/notify.py +317 -0
  37. package/payload/lanes/libexec/registrations.py +148 -0
  38. package/payload/lanes/libexec/session_actions.py +53 -22
  39. package/payload/lanes/libexec/sidebar.py +1190 -186
  40. package/payload/lanes/libexec/statusd.py +352 -90
  41. package/payload/lanes/libexec/tokens.py +310 -0
  42. package/payload/lanes/libexec/tokens.sh +11 -0
  43. package/payload/lanes/libexec/view.sh +22 -11
  44. package/payload/lanes/systemd/lanes-economy.service +20 -0
  45. package/payload/lanes/systemd/lanes-notify.service +19 -0
  46. package/payload/lanes/systemd/lanes-sync.service +3 -0
  47. package/payload/lanes/web/README.md +96 -2
  48. package/payload/lanes/web/browser.css +22 -0
  49. package/payload/lanes/web/browser.js +73 -0
  50. package/payload/lanes/web/index.html +2 -1
  51. package/payload/lanes/web/mobile.css +108 -221
  52. package/payload/lanes/web/mobile.js +105 -55
  53. package/payload/lanes/workspaces.example/README.md +9 -3
  54. package/payload/lanes/zellij/config.kdl +128 -19
  55. package/payload/manifest.json +7 -3
  56. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -91,7 +91,7 @@ clears on its next refresh. Previously read output cannot be recalled.
91
91
  ## What viewers can see
92
92
 
93
93
  - The selected workspace's current and future **registered lanes**. Unregistered
94
- sessions, legacy shells, extra/tool tabs and unrelated workspaces are excluded.
94
+ sessions, tool tabs and unrelated workspaces are excluded.
95
95
  - Lane names and observed activity; “active” describes recent terminal output,
96
96
  not a build verdict.
97
97
  - For tmux lanes, a selectable pane and its latest 2,000 lines, capped at
@@ -1,13 +1,10 @@
1
1
  #!/bin/sh
2
2
  # View-owned displays; interactive shells are persistent tmux sessions created by New +.
3
- # usage: exec.sh <container> monitor|legacy [cwd-or-id]
3
+ # usage: exec.sh <container> monitor [cwd] (the `legacy` view of pre-persistence Docker shells was retired by
4
+ # plan Task 19: a leftover `exec … legacy` resolve file now ends as an unknown helper, and `lanes gc` removes it)
4
5
  set -u
5
6
  container=${1:-} kind=${2:-} cwd=${3:-/home/coder}
6
7
  case "$container" in ''|-*|*[!A-Za-z0-9_.-]*) echo 'invalid container' >&2; exit 64 ;; esac
7
- if [ "$kind" = legacy ]; then
8
- repo=$(cd "$(dirname "$0")/.." && pwd)
9
- exec python3 -S -E "$repo/libexec/legacy_sessions.py" view "$container" "$cwd"
10
- fi
11
8
  case "$cwd" in /*) ;; *) cwd=/home/coder ;; esac
12
9
  case "$cwd" in *[!A-Za-z0-9_./-]*) cwd=/home/coder ;; esac
13
10
  common="-it -u coder -e TERM=xterm-256color -e COLORTERM=truecolor -e LANG=C.UTF-8"
@@ -0,0 +1,18 @@
1
+ #!/bin/sh
2
+ # Reconnecting viewer for an existing persistent session on another host.
3
+ # The remote command is an attach-only helper: disconnecting never stops its agent.
4
+ set -u
5
+ host=${1:-} command=${2:-} pane=${3:-}
6
+ # View sessions can be created by a clean server environment with no TERM. ssh
7
+ # then starts the remote tmux client without a terminal type and the helper
8
+ # exits immediately with “open terminal failed”. Keep the viewer attach-only,
9
+ # but give the remote terminal a conservative type when the caller omitted it.
10
+ export TERM=${TERM:-xterm-256color}
11
+ case "$host" in ''|-*|*@*@*|*[!A-Za-z0-9_.@-]*) echo 'invalid SSH host' >&2; exit 64 ;; esac
12
+ case "$command" in /*) ;; *) echo 'SSH command must be absolute' >&2; exit 64 ;; esac
13
+ case "$command" in *[!A-Za-z0-9_./-]*|*/../*|*/..) echo 'invalid SSH command' >&2; exit 64 ;; esac
14
+ case "$pane" in ''|-*|*[!A-Za-z0-9_.-]*) echo 'invalid pane' >&2; exit 64 ;; esac
15
+ repo=$(cd "$(dirname "$0")/.." && pwd)
16
+ exec python3 "$repo/libexec/connection.py" --title "$pane on $host" --retry -- \
17
+ ssh -tt -o BatchMode=yes -o ConnectTimeout=8 -o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
18
+ "$host" "exec $command $pane"
@@ -3,3 +3,6 @@
3
3
  # zellij 0.45.1: tarball digest matches GitHub's asset digest; inner binary d006c521…19dd matches the published .sha256sum.
4
4
  zellij 0.45.1 40bcc2e03f5d5ae8e054e39f676081fe12ab70871506996ba595834c3718eefc https://github.com/zellij-org/zellij/releases/download/v0.45.1/zellij-x86_64-unknown-linux-musl.tar.gz
5
5
  zmx 0.8.1 dfd75720b942466f28870731cc86dbc07afa72fb8f3bd5eeb4ff707e4eecebe8 https://github.com/neurosnap/zmx/releases/download/v0.8.1/zmx-0.8.1-linux-x86_64.tar.gz
6
+ # glow 3.0.0 (markdown preview, `lanes preview`): tarball digest matches GitHub's asset digest and the release's checksums.txt;
7
+ # the binary sits in glow_3.0.0_Linux_x86_64/. Installed only when no glow is on PATH; without either, the preview uses less -R.
8
+ glow 3.0.0 13e05e4b2acc18d2aee44291aefe6325b077ec321b631a0cfa780e8e3bc33f78 https://github.com/charmbracelet/glow/releases/download/v3.0.0/glow_3.0.0_Linux_x86_64.tar.gz
@@ -2,20 +2,27 @@
2
2
  # Key-authenticated client entry point and local server setup. Tokens stay in the caller's terminal.
3
3
  set -eu
4
4
  repo=$(cd "$(dirname "$0")/.." && pwd)
5
- host= device=phone local=0 named=0
5
+ host= device= local=0 named=0 layout=
6
6
  while [ "$#" -gt 0 ]; do
7
7
  case "$1" in
8
8
  --host) host=${2:?--host SSH_ALIAS}; shift ;;
9
9
  --local) local=1 ;;
10
+ --desktop|--mobile)
11
+ [ -z "$layout" ] || [ "$layout" = "$1" ] || { echo '--desktop and --mobile are exclusive' >&2; exit 2; }
12
+ layout=$1 ;;
10
13
  -h|--help)
11
- echo 'usage: enable-phone.sh [--host SSH_ALIAS | --local] [device]'
14
+ echo 'usage: enable-phone.sh [--host SSH_ALIAS | --local] [--desktop|--mobile] [device]'
12
15
  echo 'Client: --host lanes uses the SSH key from ./install.sh. Server: --local.'
16
+ echo 'Mobile (phone lane pickers, device phone) is the default; --desktop serves the desktop sidebar layout'
17
+ echo '(device browser) for a laptop or tablet browser. A running view of the other layout is refused.'
13
18
  exit 0 ;;
14
19
  -*) echo "unknown option: $1" >&2; exit 2 ;;
15
20
  *) [ "$named" = 0 ] || { echo 'only one device name is allowed' >&2; exit 2; }; device=$1; named=1 ;;
16
21
  esac
17
22
  shift
18
23
  done
24
+ # Mobile stays the default (and sends no flag to an older server); --desktop names its own default device.
25
+ if [ "$layout" = --desktop ]; then device=${device:-browser} suffix=' --desktop'; else layout=--mobile device=${device:-phone} suffix=; fi
19
26
  case "$device" in ''|-*|*[!A-Za-z0-9_.-]*) echo 'invalid device name' >&2; exit 2 ;; esac
20
27
  case "$host" in -*|*[!A-Za-z0-9_.@:-]*) echo 'invalid SSH host' >&2; exit 2 ;; esac
21
28
  [ "$local" = 0 ] || [ -z "$host" ] || { echo '--local and --host are mutually exclusive' >&2; exit 2; }
@@ -23,10 +30,10 @@ if [ "$local" = 0 ] && [ "$(uname -s)" = Darwin ] && [ -z "$host" ]; then host=l
23
30
  [ -t 0 ] && [ -t 1 ] || { echo 'Run enable-phone.sh in your own terminal; it displays a private login token.' >&2; exit 64; }
24
31
  if [ -n "$host" ]; then
25
32
  exec ssh -t -o BatchMode=yes -o PreferredAuthentications=publickey -o ForwardAgent=no "$host" \
26
- "exec sh \"\$HOME/.local/share/lanes/current/install/enable-phone.sh\" --local '$device'"
33
+ "exec sh \"\$HOME/.local/share/lanes/current/install/enable-phone.sh\" --local '$device'$suffix"
27
34
  fi
28
35
  command -v tailscale >/dev/null 2>&1 || { echo 'Install and connect Tailscale on the server first.' >&2; exit 1; }
29
- sh "$repo/install/enable-web.sh" "$device"
36
+ sh "$repo/install/enable-web.sh" "$device" "$layout"
30
37
  if ! tailscale serve --bg --https=8443 http://127.0.0.1:8083; then
31
38
  echo 'The server admin must allow this account to manage Tailscale Serve:' >&2
32
39
  printf ' sudo tailscale set --operator=%s\n' "$(id -un)" >&2
@@ -35,9 +42,11 @@ if ! tailscale serve --bg --https=8443 http://127.0.0.1:8083; then
35
42
  fi
36
43
  dns=$(tailscale status --json | python3 -c 'import json,sys; print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))')
37
44
  [ -n "$dns" ] || { echo 'This node needs Tailscale MagicDNS and HTTPS enabled.' >&2; exit 1; }
38
- printf '\nOn your phone, connect Tailscale and open:\n\n https://%s:8443/%s\n\n' "$dns" "$device"
45
+ if [ -n "$suffix" ]; then where='In a browser on your laptop or tablet'; else where='On your phone'; fi
46
+ printf '\n%s, connect Tailscale and open:\n\n https://%s:8443/%s\n\n' "$where" "$dns" "$device"
39
47
  printf 'The last line is NAME: TOKEN. Paste only TOKEN into the sign-in screen.\n'
40
- printf 'In Safari, Share > Add to Home Screen installs the app.\n\n'
48
+ [ -n "$suffix" ] || printf 'In Safari, Share > Add to Home Screen installs the app.\n'
49
+ printf '\n'
41
50
  # Zellij 0.45.1 requires --create-token by itself; --token-name cannot accompany it.
42
51
  exec "${LANES_SHARE:-$HOME/.local/share/lanes}/bin/zellij-0.45.1" \
43
52
  --config-dir "$repo/zellij" --config "$repo/zellij/config.kdl" web --create-token
@@ -1,16 +1,29 @@
1
1
  #!/bin/sh
2
2
  # Enable loopback web services as the installed workspace user. Does not print a token.
3
+ # The view is mobile (the phone's lane pickers, default device `phone`) unless --desktop asks for the desktop
4
+ # sidebar layout (default device `browser`); a running view of the other layout is refused, never rebuilt.
3
5
  set -eu
4
6
  repo=$(cd "$(dirname "$0")/.." && pwd)
5
- device=${1:-phone}
6
- [ "$#" -le 1 ] || { echo 'usage: enable-web.sh [device]' >&2; exit 2; }
7
- case "$device" in ''|-*|*[!A-Za-z0-9_.-]*) echo 'invalid device name' >&2; exit 2 ;; esac
7
+ usage='usage: enable-web.sh [device] [--desktop|--mobile]'
8
+ device= layout=
9
+ while [ "$#" -gt 0 ]; do
10
+ case "$1" in
11
+ --desktop|--mobile)
12
+ [ -z "$layout" ] || [ "$layout" = "$1" ] || { echo '--desktop and --mobile are exclusive' >&2; exit 2; }
13
+ layout=$1 ;;
14
+ -*) echo "$usage" >&2; exit 2 ;;
15
+ *) [ -z "$device" ] || { echo "$usage" >&2; exit 2; }; device=$1 ;;
16
+ esac
17
+ shift
18
+ done
19
+ if [ "$layout" = --desktop ]; then device=${device:-browser} suffix=' --desktop'; else layout=--mobile device=${device:-phone} suffix=; fi
20
+ case "$device" in -*|*[!A-Za-z0-9_.-]*) echo 'invalid device name' >&2; exit 2 ;; esac
8
21
  command -v caddy >/dev/null 2>&1 || { echo 'Install Caddy first (see docs/INSTALL.md).' >&2; exit 1; }
9
22
  export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}
10
- "$repo/bin/lanes" prepare "$device" --mobile
23
+ "$repo/bin/lanes" prepare "$device" "$layout"
11
24
  python3 "$repo/install/services.py"
12
25
  systemctl --user daemon-reload
13
26
  systemctl --user enable --now lanes-web.service lanes-mobile.service
14
27
  systemctl --user is-active --quiet lanes-web.service lanes-mobile.service
15
- printf '\nPhone frontend: 127.0.0.1:8083; Zellij: 127.0.0.1:8082 (loopback only).\n'
16
- printf 'For private HTTPS and a login token, run in your terminal:\n sh "%s/install/enable-phone.sh" --local %s\n' "$repo" "$device"
28
+ printf '\nWeb frontend: 127.0.0.1:8083; Zellij: 127.0.0.1:8082 (loopback only).\n'
29
+ printf 'For private HTTPS and a login token, run in your terminal:\n sh "%s/install/enable-phone.sh" --local %s%s\n' "$repo" "$device" "$suffix"
@@ -34,10 +34,15 @@ fi
34
34
  share=${LANES_SHARE:-$HOME/.local/share/lanes}
35
35
  state=${LANES_STATE:-$HOME/.local/state/lanes}
36
36
  mkdir -p "$share/bin" "$share/dl" "$state/flags"
37
+ # The view server's PATH (bin/lanes clean_env): the Run pane that renders `lanes preview` sees this, never the
38
+ # installer's PATH, so a glow in ~/.local/bin or ~/go/bin does not count as installed.
39
+ pane_path=$share/bin:$repo/bin:/usr/local/bin:/usr/bin:/bin
37
40
  grep -v '^#' "$repo/holders.lock" | while read -r name version sha url; do
38
41
  [ -n "$name" ] || continue
39
42
  target=$share/bin/$name-$version
40
43
  if [ -x "$target" ]; then echo "ok $name $version"; continue; fi
44
+ # The markdown preview's renderer is optional: a glow the pane can find serves `lanes preview` as well.
45
+ if [ "$name" = glow ] && system_glow=$(PATH=$pane_path; command -v glow); then echo "ok glow (system: $system_glow)"; continue; fi
41
46
  archive=$share/dl/$(basename "$url")
42
47
  curl -fsSL --retry 3 -o "$archive" "$url"
43
48
  actual=$(sha256sum "$archive" | cut -d' ' -f1)
@@ -45,7 +50,11 @@ grep -v '^#' "$repo/holders.lock" | while read -r name version sha url; do
45
50
  temporary=$(mktemp -d)
46
51
  trap 'rm -rf "$temporary"' EXIT HUP INT TERM
47
52
  tar -xzf "$archive" -C "$temporary"
48
- install -m 0755 "$temporary/$name" "$target"
53
+ binary=$temporary/$name
54
+ # zellij and zmx ship the binary at the top of the tarball; glow nests it in glow_<version>_Linux_x86_64/.
55
+ [ -f "$binary" ] || binary=$(find "$temporary" -type f -name "$name" | head -n 1)
56
+ [ -n "$binary" ] && [ -f "$binary" ] || { echo "no $name binary in $archive" >&2; exit 1; }
57
+ install -m 0755 "$binary" "$target"
49
58
  rm -rf "$temporary"
50
59
  trap - EXIT HUP INT TERM
51
60
  echo "installed $target"
@@ -14,7 +14,28 @@ def unit_value(value, command=False):
14
14
  return value.replace('$', '$$') if command else value
15
15
 
16
16
 
17
- def register(repo, home, share, state, workspaces=None):
17
+ # The opt-in economy poller's settings (libexec/economy_poll.py). Rendered into lanes-economy.service only, and only
18
+ # when set in the installer's environment; the unit itself is never enabled by an install (operator gate).
19
+ ECONOMY_SETTINGS = ('LANES_THROTTLE_CONTROL', 'LANES_THROTTLE_TOKEN_FILE', 'LANES_THROTTLE_LOG_DIR',
20
+ 'LANES_THROTTLE_CONTAINER')
21
+ ECONOMY_UNITS = ('lanes-economy.service',)
22
+ # The opt-in push notifier's config path (libexec/notify.py), rendered into lanes-notify.service only and only when
23
+ # set in the installer's environment (a config FILE path; the ntfy token stays in the file its token_file names).
24
+ NOTIFY_SETTINGS = ('LANES_NOTIFY_CONF',)
25
+ NOTIFY_UNITS = ('lanes-notify.service',)
26
+
27
+
28
+ def economy_settings(environ):
29
+ """The non-empty LANES_THROTTLE_* settings of `environ` (a token FILE path, never a token)."""
30
+ return {name: environ[name] for name in ECONOMY_SETTINGS if environ.get(name)}
31
+
32
+
33
+ def notify_settings(environ):
34
+ """The non-empty LANES_NOTIFY_* settings of `environ`."""
35
+ return {name: environ[name] for name in NOTIFY_SETTINGS if environ.get(name)}
36
+
37
+
38
+ def register(repo, home, share, state, workspaces=None, economy=None, notify=None):
18
39
  repo, home, share, state = (Path(p).resolve() for p in (repo, home, share, state))
19
40
  current = home / '.local/share/lanes/current'
20
41
  links = ((current, repo), (home / '.local/bin/lanes', repo / 'bin/lanes'))
@@ -25,6 +46,10 @@ def register(repo, home, share, state, workspaces=None):
25
46
  if workspaces:
26
47
  settings.append(('LANES_WORKSPACES', Path(workspaces).resolve()))
27
48
  environment = ''.join(f'Environment="{name}={unit_value(value)}"\n' for name, value in settings)
49
+ economy_environment = ''.join(f'Environment="{name}={unit_value(value)}"\n'
50
+ for name, value in sorted((economy or {}).items()) if name in ECONOMY_SETTINGS)
51
+ notify_environment = ''.join(f'Environment="{name}={unit_value(value)}"\n'
52
+ for name, value in sorted((notify or {}).items()) if name in NOTIFY_SETTINGS)
28
53
  rendered = {}
29
54
  for source in sorted((repo / 'systemd').iterdir()):
30
55
  if source.name.startswith('.') or source.suffix not in ('.service', '.timer', '.slice') or not source.is_file():
@@ -33,7 +58,9 @@ def register(repo, home, share, state, workspaces=None):
33
58
  content = content.replace('@SHARE@', unit_value(share, command=True))
34
59
  content = content.replace('@CADDY@', unit_value(shutil.which('caddy') or '/usr/bin/caddy', command=True))
35
60
  if '[Service]\n' in content:
36
- content = content.replace('[Service]\n', '[Service]\n' + environment, 1)
61
+ extra = (economy_environment if source.name in ECONOMY_UNITS else
62
+ notify_environment if source.name in NOTIFY_UNITS else '')
63
+ content = content.replace('[Service]\n', '[Service]\n' + environment + extra, 1)
37
64
  rendered[source.name] = '# Generated by lanes/install/services.py; re-run the server installer to update.\n' + content
38
65
  units = home / '.config/systemd/user'
39
66
  units.mkdir(parents=True, exist_ok=True)
@@ -58,4 +85,4 @@ if __name__ == '__main__':
58
85
  register(Path(__file__).resolve().parents[1], Path.home(),
59
86
  os.environ.get('LANES_SHARE', Path.home() / '.local/share/lanes'),
60
87
  os.environ.get('LANES_STATE', Path.home() / '.local/state/lanes'),
61
- os.environ.get('LANES_WORKSPACES'))
88
+ os.environ.get('LANES_WORKSPACES'), economy_settings(os.environ), notify_settings(os.environ))
@@ -16,6 +16,8 @@
16
16
  # --coder-launch also provide the command name `coder-launch` (an existing one is kept as coder-launch-legacy)
17
17
  # --no-desktop skip desktop integration (Lanes.app / .desktop entry / Hyprland key binding)
18
18
  # --no-uploads skip the uploads watch folder and the upload helper service
19
+ # --palette give the lanes window the cockpit's own Ghostty palette instead of your everyday theme
20
+ # (off by default; a re-run without it puts the inherited look back)
19
21
  # --yes never prompt (a new key is then created WITHOUT a passphrase)
20
22
  #
21
23
  # Exit status: 0 ready to use · 3 installed, but this machine's key is not authorized on the server yet ·
@@ -35,7 +37,7 @@ stamp=$(date -u +%Y%m%dT%H%M%SZ)
35
37
  label=ai.beyondwork.lanes-upload
36
38
  helper_label=ai.beyondwork.lanes-helper
37
39
 
38
- mode=install device= key= site_file=$here/site/site.conf link=0 coder_launch=0 desktop=1 uploads=1 assume_yes=0
40
+ mode=install device= key= site_file=$here/site/site.conf link=0 coder_launch=0 desktop=1 uploads=1 assume_yes=0 palette=0
39
41
  [ -r "$site_file" ] || site_file=$config/client-site.conf
40
42
  while [ $# -gt 0 ]; do
41
43
  case "$1" in
@@ -48,8 +50,9 @@ while [ $# -gt 0 ]; do
48
50
  --coder-launch) coder_launch=1 ;;
49
51
  --no-desktop) desktop=0 ;;
50
52
  --no-uploads) uploads=0 ;;
53
+ --palette) palette=1 ;;
51
54
  --yes|-y) assume_yes=1 ;;
52
- -h|--help) sed -n '2,22p' "$0"; exit 0 ;;
55
+ -h|--help) sed -n '2,24p' "$0"; exit 0 ;;
53
56
  *) echo "install.sh: unknown option $1 (see --help)" >&2; exit 1 ;;
54
57
  esac
55
58
  shift
@@ -214,6 +217,108 @@ install_client() {
214
217
  cat "$target/ghostty/common.conf"
215
218
  if [ "$os" = Darwin ]; then cat "$target/ghostty/macos.conf"; else cat "$target/ghostty/omarchy.conf"; fi
216
219
  } > "$config/ghostty.conf"
220
+ install_palette
221
+ }
222
+
223
+ # The Ghostty binary, for its version only: on PATH, else inside the macOS app bundle.
224
+ ghostty_bin() {
225
+ if command -v ghostty >/dev/null 2>&1; then command -v ghostty; return 0; fi
226
+ for app in /Applications/Ghostty.app "$HOME/Applications/Ghostty.app"; do
227
+ [ -x "$app/Contents/MacOS/ghostty" ] && { printf '%s' "$app/Contents/MacOS/ghostty"; return 0; }
228
+ done
229
+ return 1
230
+ }
231
+ # Ghostty >= 1.1 reads `theme = light:…,dark:…` (with absolute theme paths); unknown or older = no.
232
+ ghostty_pairs_themes() {
233
+ gb=$(ghostty_bin) || return 1
234
+ ver=$("$gb" +version 2>/dev/null | sed -n '1s/^Ghostty \([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2/p')
235
+ [ -n "$ver" ] || return 1
236
+ # shellcheck disable=SC2086 # split "MAJOR MINOR" into two words on purpose
237
+ set -- $ver
238
+ [ "$1" -gt 1 ] || { [ "$1" -eq 1 ] && [ "$2" -ge 1 ]; }
239
+ }
240
+ # Ghostty applies a `theme` UNDER every colour key set explicitly anywhere in the loaded configuration, whatever the
241
+ # file order (Config.zig `loadTheme`: the theme loads, then the configuration is replayed over it). The lanes window
242
+ # includes the everyday config first, so when that config — or a file it reaches through `config-file` (Omarchy: the
243
+ # current theme's ghostty.conf) — sets a colour key, a theme pair would be a silent no-op.
244
+ # And Ghostty loads `config-file` entries as ONE first-in-first-out list (Config.zig `loadRecursiveFiles` iterates the
245
+ # list while each loaded file appends its own includes to the end): breadth-first, so a file two includes deep loads
246
+ # after every direct include of the lanes ghostty.conf — the palette included there last included. Last assignment wins.
247
+ # Walks the include graph from $1 the same way (bounded at 64 entries, cycle-safe: a repeat is skipped as Ghostty skips
248
+ # it; `?`, quotes, `~/` and paths relative to the including file as Ghostty reads them) and sets
249
+ # ec_colour = the first file (in Ghostty's load order) that sets a colour key, empty when none does;
250
+ # ec_depth = the deepest include depth of an existing file ($1 = 0, its direct includes = 1).
251
+ scan_everyday_chain() {
252
+ ec_nl='
253
+ '
254
+ ec_tab=$(printf '\t')
255
+ ec_queue="0$ec_tab$1$ec_nl" ec_seen=$ec_nl ec_n=0 ec_colour='' ec_depth=0
256
+ while [ -n "$ec_queue" ] && [ "$ec_n" -lt 64 ]; do
257
+ ec_e=${ec_queue%%"$ec_nl"*}; ec_queue=${ec_queue#*"$ec_nl"}; ec_n=$((ec_n + 1))
258
+ ec_d=${ec_e%%"$ec_tab"*}; ec_f=${ec_e#*"$ec_tab"}
259
+ case "$ec_seen" in *"$ec_nl$ec_f$ec_nl"*) continue ;; esac
260
+ ec_seen="$ec_seen$ec_f$ec_nl"
261
+ [ -f "$ec_f" ] && [ -r "$ec_f" ] || continue
262
+ [ "$ec_d" -gt "$ec_depth" ] && ec_depth=$ec_d
263
+ if [ -z "$ec_colour" ] && grep -Eq '^[[:space:]]*(background|foreground|palette|cursor-color|cursor-text|selection-background|selection-foreground)[[:space:]]*=' "$ec_f"; then
264
+ ec_colour=$ec_f
265
+ fi
266
+ ec_dir=$(dirname "$ec_f") ec_c=$((ec_d + 1))
267
+ ec_incs=$(sed -n 's/^[[:space:]]*config-file[[:space:]]*=[[:space:]]*//p' "$ec_f" \
268
+ | sed -e 's/[[:space:]]*$//' -e 's/^?//' -e 's/^"\(.*\)"$/\1/' -e 's/^?//')
269
+ while IFS= read -r ec_inc; do
270
+ case "$ec_inc" in
271
+ '') ;;
272
+ '~/'*) ec_queue="$ec_queue$ec_c$ec_tab$HOME/${ec_inc#"~/"}$ec_nl" ;;
273
+ /*) ec_queue="$ec_queue$ec_c$ec_tab$ec_inc$ec_nl" ;;
274
+ *) ec_queue="$ec_queue$ec_c$ec_tab$ec_dir/$ec_inc$ec_nl" ;;
275
+ esac
276
+ done <<EOF
277
+ $ec_incs
278
+ EOF
279
+ done
280
+ }
281
+ # --palette (spec §7): the cockpit tokens instead of the everyday theme. Two rules make it win:
282
+ # 1. ORDER — the include is the last line of ghostty.conf, hence the last depth-1 entry of Ghostty's breadth-first
283
+ # list; the palette keys go in a chain of one-include hop files (ghostty-palette.conf → ghostty-palette.1.conf → …)
284
+ # so they sit at the everyday chain's deepest depth, where they are the last entry of that depth (every hop is its
285
+ # parent's only include) and nothing loads later. Omarchy's theme file sits two deep: one hop.
286
+ # 2. KIND — a theme pair loses to ANY explicit colour key in the everyday chain (above), so the pair is installed only
287
+ # when that chain sets none; otherwise the explicit dark palette (last assignment wins) and `say` names the file.
288
+ # Both rules are read from Ghostty's source, not observed on a running Ghostty here (DEF-1466 holds the live check).
289
+ # Without the flag the include is absent and every earlier palette file is removed: inherit-first is the default.
290
+ install_palette() {
291
+ rm -f "$config"/ghostty-palette*.conf
292
+ [ "$palette" = 1 ] || return 0
293
+ scan_everyday_chain "$config/ghostty.conf"
294
+ hops=0; [ "$ec_depth" -gt 1 ] && hops=$((ec_depth - 1))
295
+ keys="$config/ghostty-palette.conf" i=0
296
+ while [ "$i" -lt "$hops" ]; do
297
+ i=$((i + 1))
298
+ printf '%s\n' "# lanes palette hop $i of $hops (install.sh --palette): Ghostty loads includes breadth-first, so the palette" \
299
+ '# keys sit as deep as the everyday config'"'"'s deepest include and load after it.' \
300
+ "config-file = ?$config/ghostty-palette.$i.conf" > "$keys"
301
+ keys="$config/ghostty-palette.$i.conf"
302
+ done
303
+ cp "$target/ghostty/lanes-palette-light.conf" "$config/ghostty-palette-light.conf"
304
+ if ghostty_pairs_themes; then
305
+ if [ -n "$ec_colour" ]; then
306
+ cp "$target/ghostty/lanes-palette.conf" "$keys"
307
+ variant="dark only: $(tilde "$ec_colour") sets colours explicitly, which would override a light/dark theme pair (light variant: ghostty-palette-light.conf)"
308
+ else
309
+ # Both as theme files: explicit colours in the included file would override the pair, so it holds only this.
310
+ cp "$target/ghostty/lanes-palette.conf" "$config/ghostty-palette-dark.conf"
311
+ printf '%s\n' '# lanes cockpit palette (install.sh --palette): follows the system light/dark setting.' \
312
+ "theme = light:$config/ghostty-palette-light.conf,dark:$config/ghostty-palette-dark.conf" > "$keys"
313
+ variant='light and dark, following the system'
314
+ fi
315
+ else
316
+ cp "$target/ghostty/lanes-palette.conf" "$keys"
317
+ variant='dark (Ghostty 1.1+ follows the system; light variant: ghostty-palette-light.conf)'
318
+ fi
319
+ [ "$hops" -gt 0 ] && variant="$variant; $hops include hop(s) deep, after the everyday config's deepest include (depth $ec_depth)"
320
+ printf '\n# palette (install.sh --palette): the cockpit tokens instead of the everyday theme; remove with a plain re-run\nconfig-file = ?%s/ghostty-palette.conf\n' "$(tilde "$config")" >> "$config/ghostty.conf"
321
+ say "Ghostty palette: lanes tokens, $variant"
217
322
  }
218
323
 
219
324
  find_terminal() {
@@ -591,7 +696,7 @@ do_uninstall() {
591
696
  rm -rf "$share/client" "$state"
592
697
  # A Linux account can run both roles. Keep server catalogs and any files
593
698
  # the client did not create in the shared configuration directory.
594
- rm -f "$config/device" "$config/ghostty.conf" "$config"/ghostty.*.conf \
699
+ rm -f "$config/device" "$config/ghostty.conf" "$config"/ghostty.*.conf "$config"/ghostty-palette*.conf \
595
700
  "$config/client-site.conf" "$config/client-known-hosts" "$config/site.cache" \
596
701
  "$config/upload-allow" "$config/uploads-dir" "$config/upload-watch.log"
597
702
  rmdir "$config" 2>/dev/null || :
@@ -0,0 +1,246 @@
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 read_sidecar_dict(d, now, max_age, proc_root=None):
191
+ """A hook-written sidecar (`~/.lz/state/hooks/<lane id>.json`, container `lane-signal`), or None when it is not
192
+ ours, malformed, older than `max_age` seconds, or written by an agent that no longer runs — an hour-old hook state
193
+ loses to the live signals (`merge` falls through to the session file / title).
194
+
195
+ Freshness is the LAST event (`at`; `since` when a writer predates `at`), while `since` is when the status began:
196
+ a `working` stretch re-signalled by every tool call keeps its start. lane-run's `{phase, exit, at, pid}` records
197
+ live one directory up and are never a sidecar (no `v`; the two writers never share a file name, DEF-1436). With
198
+ `proc_root` (the container's /proc as the host sees it) a recorded `pid` must be live and, when the writer
199
+ recorded `start`, still be the same process (field 22 of /proc/<pid>/stat, as `claude_sessions` checks Claude's
200
+ `procStart`): a crashed agent's last word is never published. `pid` 0 means the writer could not name its agent;
201
+ freshness alone then decides."""
202
+ if not isinstance(d, dict) or d.get('v') != 1 or d.get('status') not in STATUSES:
203
+ return None
204
+ try:
205
+ since = int(d.get('since') or 0)
206
+ at = int(d.get('at') or since)
207
+ pid = int(d.get('pid') or 0)
208
+ except (TypeError, ValueError):
209
+ return None
210
+ if since <= 0 or now - at > max_age:
211
+ return None
212
+ if proc_root is not None and pid > 0:
213
+ if not os.path.exists(os.path.join(proc_root, str(pid))):
214
+ return None
215
+ if d.get('start') is not None and not _same_process(proc_root, str(pid), d['start']):
216
+ return None
217
+ return {'harness': str(d.get('harness') or ''), 'status': d['status'], 'since': since,
218
+ 'name': str(d.get('name') or ''), 'session_id': str(d.get('session') or ''),
219
+ 'pid': pid, 'source': 'hook', 'event': str(d.get('event') or '')}
220
+
221
+
222
+ def merge(pane_row, claude_by_pane, sidecar):
223
+ """Precedence: hook sidecar (event-driven, both harnesses) > Claude session file > Codex title > bell.
224
+ Any Codex title — idle, spinner or Action Required, bell or not — over a bare-shell pane whose shell has no
225
+ child process (`pane_has_child is False`, statusd's row key) publishes `shell`: the harness ended and tmux kept
226
+ its last title, so no ghost idle/done/working/needs_approval. Unknown liveness leaves the title in charge.
227
+ Returns None when nothing says an agent is here — the sidebar then draws a plain shell."""
228
+ if sidecar and sidecar.get('status') in STATUSES:
229
+ return dict(sidecar, source=sidecar.get('source') or 'hook')
230
+ key = f"{pane_row.get('session_name')}:{pane_row.get('window_id')}.{pane_row.get('pane_id')}"
231
+ if key in claude_by_pane:
232
+ return dict(claude_by_pane[key])
233
+ title = parse_codex_title(pane_row.get('pane_title'))
234
+ bell = pane_row.get('window_bell_flag') == '1'
235
+ if title:
236
+ if _harness_ended(pane_row):
237
+ title['status'] = 'shell' # stale title on a childless shell: the harness ended, no ghost of any kind
238
+ elif title['status'] == 'idle' and bell:
239
+ title['status'] = 'done'
240
+ title['source'] = 'title'
241
+ try:
242
+ title['since'] = int(pane_row.get('window_activity') or 0)
243
+ except ValueError:
244
+ title['since'] = 0
245
+ return title
246
+ return None