@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.
- package/package.json +1 -1
- package/payload/lanes/README.md +92 -7
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +928 -150
- package/payload/lanes/client/ghostty/common.conf +3 -0
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
- package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
- package/payload/lanes/client/ghostty/omarchy.conf +3 -2
- package/payload/lanes/client/lanes-launch +62 -5
- package/payload/lanes/client/lanes-window +17 -1
- package/payload/lanes/container/env.sh +8 -0
- package/payload/lanes/container/hooks/claude-settings.json +10 -0
- package/payload/lanes/container/hooks/codex-hooks.json +22 -0
- package/payload/lanes/container/lane +326 -25
- package/payload/lanes/container/lane-signal +173 -0
- package/payload/lanes/docs/ARCHITECTURE.md +158 -6
- package/payload/lanes/docs/DESIGN.md +250 -0
- package/payload/lanes/docs/INSTALL.md +39 -0
- package/payload/lanes/docs/RUNBOOK.md +507 -24
- package/payload/lanes/docs/SHARING.md +1 -1
- package/payload/lanes/holders/exec.sh +2 -5
- package/payload/lanes/holders/ssh.sh +18 -0
- package/payload/lanes/holders.lock +3 -0
- package/payload/lanes/install/enable-phone.sh +15 -6
- package/payload/lanes/install/enable-web.sh +19 -6
- package/payload/lanes/install/host-install.sh +10 -1
- package/payload/lanes/install/services.py +30 -3
- package/payload/lanes/install.sh +108 -3
- package/payload/lanes/libexec/agent_state.py +246 -0
- package/payload/lanes/libexec/connection.py +178 -0
- package/payload/lanes/libexec/cursor_sync.py +147 -6
- package/payload/lanes/libexec/device_sync.py +134 -0
- package/payload/lanes/libexec/economy_poll.py +685 -0
- package/payload/lanes/libexec/guard.py +27 -1
- package/payload/lanes/libexec/lanes_lib.py +436 -36
- package/payload/lanes/libexec/notify.py +317 -0
- package/payload/lanes/libexec/registrations.py +148 -0
- package/payload/lanes/libexec/session_actions.py +53 -22
- package/payload/lanes/libexec/sidebar.py +1190 -186
- package/payload/lanes/libexec/statusd.py +352 -90
- package/payload/lanes/libexec/tokens.py +310 -0
- package/payload/lanes/libexec/tokens.sh +11 -0
- package/payload/lanes/libexec/view.sh +22 -11
- package/payload/lanes/systemd/lanes-economy.service +20 -0
- package/payload/lanes/systemd/lanes-notify.service +19 -0
- package/payload/lanes/systemd/lanes-sync.service +3 -0
- package/payload/lanes/web/README.md +96 -2
- package/payload/lanes/web/browser.css +22 -0
- package/payload/lanes/web/browser.js +73 -0
- package/payload/lanes/web/index.html +2 -1
- package/payload/lanes/web/mobile.css +108 -221
- package/payload/lanes/web/mobile.js +105 -55
- package/payload/lanes/workspaces.example/README.md +9 -3
- package/payload/lanes/zellij/config.kdl +128 -19
- package/payload/manifest.json +7 -3
- 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,
|
|
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
|
|
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=
|
|
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
|
-
|
|
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
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
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"
|
|
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 '\
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
package/payload/lanes/install.sh
CHANGED
|
@@ -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,
|
|
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
|