@vibelord/lanes 1.0.1 → 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 +37 -7
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +651 -50
- 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 +18 -3
- package/payload/lanes/client/lanes-window +17 -1
- package/payload/lanes/container/env.sh +1 -1
- 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 +310 -26
- package/payload/lanes/container/lane-signal +173 -0
- package/payload/lanes/docs/ARCHITECTURE.md +80 -13
- package/payload/lanes/docs/DESIGN.md +182 -4
- package/payload/lanes/docs/RUNBOOK.md +429 -39
- package/payload/lanes/docs/SHARING.md +1 -1
- package/payload/lanes/holders/exec.sh +2 -5
- package/payload/lanes/holders.lock +3 -0
- 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 +32 -0
- package/payload/lanes/libexec/cursor_sync.py +10 -2
- package/payload/lanes/libexec/economy_poll.py +685 -0
- package/payload/lanes/libexec/guard.py +27 -1
- package/payload/lanes/libexec/lanes_lib.py +285 -38
- package/payload/lanes/libexec/notify.py +317 -0
- package/payload/lanes/libexec/registrations.py +1 -1
- package/payload/lanes/libexec/session_actions.py +7 -8
- package/payload/lanes/libexec/sidebar.py +911 -218
- package/payload/lanes/libexec/statusd.py +238 -69
- package/payload/lanes/libexec/tokens.py +38 -7
- package/payload/lanes/libexec/view.sh +13 -3
- 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 +83 -4
- package/payload/lanes/web/index.html +2 -1
- package/payload/lanes/web/mobile.css +53 -198
- package/payload/lanes/web/mobile.js +87 -55
- package/payload/lanes/zellij/config.kdl +67 -16
- package/payload/manifest.json +4 -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"
|
|
@@ -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
|
|
@@ -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 || :
|
|
@@ -187,6 +187,38 @@ def claude_sessions(sessions_dir, proc_root):
|
|
|
187
187
|
return out
|
|
188
188
|
|
|
189
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
|
+
|
|
190
222
|
def merge(pane_row, claude_by_pane, sidecar):
|
|
191
223
|
"""Precedence: hook sidecar (event-driven, both harnesses) > Claude session file > Codex title > bell.
|
|
192
224
|
Any Codex title — idle, spinner or Action Required, bell or not — over a bare-shell pane whose shell has no
|
|
@@ -14,13 +14,14 @@ Text is edited minimally (command strings replaced in place, the `lanes` block i
|
|
|
14
14
|
git diffs stay small. Dry-run by default: prints a unified diff. `--write` applies (with a timestamped backup).
|
|
15
15
|
|
|
16
16
|
`--canonicalize` additionally replaces every tmux lane's `create` that cannot create the lane — null, attach-only
|
|
17
|
-
(`attach-session` without `new`),
|
|
17
|
+
(`attach-session` without `new`), naming the retired in-container coder-launch config (the three classes `lanes
|
|
18
|
+
doctor` warns about), or naming a script that is missing in the container — with
|
|
18
19
|
`lanes_lib.canonical_recipe()` (the recorded binding + the repository folder + `bash -l`), which is also what gives a
|
|
19
20
|
NEW lane its identity (LANE_ID, LANE_WS, the lanes/<ws>/<id> trace). `--identity` additionally gives a WORKING
|
|
20
21
|
recipe of the plain shape `[exec] tmux -L <socket> new -ADs <session> [-c <dir>] <command…>` the same identity
|
|
21
22
|
(`plain_tmux_new`: the command argv is kept, only the `-e` flags are added); any other working recipe is left
|
|
22
23
|
alone and its lane carries no identity. The old value is kept as `createPrevious` in both cases. Identity reaches a
|
|
23
|
-
tmux lane ONLY through these `-e` flags: `lane open` never exports it (DEF-
|
|
24
|
+
tmux lane ONLY through these `-e` flags: `lane open` never exports it (DEF-1478, lanes/docs/ARCHITECTURE.md).
|
|
24
25
|
Boards whose registry lives outside a repository are additionally PROJECTED into the file Cursor
|
|
25
26
|
reads for that folder, named by `"cursor": "<path>"` on the repository entry of the workspace manifest.
|
|
26
27
|
"""
|
|
@@ -45,6 +46,8 @@ ATTACH_VERBS = {'attach-session'[:n] for n in range(1, 15)} # tmux t
|
|
|
45
46
|
NEW_VERBS = {'new-session'[:n] for n in range(3, 12)}
|
|
46
47
|
TMUX_OPTION_ARGS = {'-L', '-S', '-f', '-T', '-c'}
|
|
47
48
|
SCRIPT_SUFFIXES = ('.sh', '.bash', '.py', '.js', '.mjs', '.cjs', '.rb', '.pl')
|
|
49
|
+
CLASS_REASONS = {'null': 'no create recipe', 'attach-only': 'attach-only',
|
|
50
|
+
'retired-path': 'retired coder-launch path'}
|
|
48
51
|
|
|
49
52
|
|
|
50
53
|
def _words(text, depth=3):
|
|
@@ -79,6 +82,11 @@ def needs_canonical(create, folder, exists):
|
|
|
79
82
|
if not isinstance(create, str) or not create.strip():
|
|
80
83
|
return 'no create recipe'
|
|
81
84
|
recipe = create.replace('${workspaceFolder}', folder).replace('[workspaceFolder]', folder)
|
|
85
|
+
# Every class `lanes doctor` warns about (lanes_lib.classify_recipe: null, attach-only, retired-path) is one this
|
|
86
|
+
# repairs, read by the same parser, so the doctor's "fix with --canonicalize" is never a no-op (DEF-1460).
|
|
87
|
+
reason = CLASS_REASONS.get(L.classify_recipe(recipe))
|
|
88
|
+
if reason:
|
|
89
|
+
return reason
|
|
82
90
|
words = _words(recipe)
|
|
83
91
|
if words is None:
|
|
84
92
|
return None
|