@vibelord/lanes 1.0.1 → 1.0.3
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 +664 -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 +87 -13
- package/payload/lanes/docs/DESIGN.md +182 -4
- package/payload/lanes/docs/INSTALL.md +37 -2
- package/payload/lanes/docs/RUNBOOK.md +458 -39
- package/payload/lanes/docs/SHARING.md +240 -5
- package/payload/lanes/holders/exec.sh +2 -5
- package/payload/lanes/holders.lock +13 -0
- package/payload/lanes/install/enable-studio-host.sh +14 -0
- package/payload/lanes/install/host-install.sh +15 -3
- package/payload/lanes/install/services.py +128 -4
- 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 +258 -74
- package/payload/lanes/libexec/studio_host.py +1608 -0
- 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-studio-host-enrol.service +11 -0
- package/payload/lanes/systemd/lanes-studio-host-enrol.timer +10 -0
- package/payload/lanes/systemd/lanes-studio-host.service +37 -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 +5 -3
- package/payload/lanes/libexec/legacy_sessions.py +0 -130
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-1547 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-1572). 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-1559, 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-1541).
|
|
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
|