@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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +37 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +664 -50
  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 +18 -3
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +1 -1
  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 +310 -26
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +87 -13
  17. package/payload/lanes/docs/DESIGN.md +182 -4
  18. package/payload/lanes/docs/INSTALL.md +37 -2
  19. package/payload/lanes/docs/RUNBOOK.md +458 -39
  20. package/payload/lanes/docs/SHARING.md +240 -5
  21. package/payload/lanes/holders/exec.sh +2 -5
  22. package/payload/lanes/holders.lock +13 -0
  23. package/payload/lanes/install/enable-studio-host.sh +14 -0
  24. package/payload/lanes/install/host-install.sh +15 -3
  25. package/payload/lanes/install/services.py +128 -4
  26. package/payload/lanes/install.sh +108 -3
  27. package/payload/lanes/libexec/agent_state.py +32 -0
  28. package/payload/lanes/libexec/cursor_sync.py +10 -2
  29. package/payload/lanes/libexec/economy_poll.py +685 -0
  30. package/payload/lanes/libexec/guard.py +27 -1
  31. package/payload/lanes/libexec/lanes_lib.py +285 -38
  32. package/payload/lanes/libexec/notify.py +317 -0
  33. package/payload/lanes/libexec/registrations.py +1 -1
  34. package/payload/lanes/libexec/session_actions.py +7 -8
  35. package/payload/lanes/libexec/sidebar.py +911 -218
  36. package/payload/lanes/libexec/statusd.py +258 -74
  37. package/payload/lanes/libexec/studio_host.py +1608 -0
  38. package/payload/lanes/libexec/tokens.py +38 -7
  39. package/payload/lanes/libexec/view.sh +13 -3
  40. package/payload/lanes/systemd/lanes-economy.service +20 -0
  41. package/payload/lanes/systemd/lanes-notify.service +19 -0
  42. package/payload/lanes/systemd/lanes-studio-host-enrol.service +11 -0
  43. package/payload/lanes/systemd/lanes-studio-host-enrol.timer +10 -0
  44. package/payload/lanes/systemd/lanes-studio-host.service +37 -0
  45. package/payload/lanes/systemd/lanes-sync.service +3 -0
  46. package/payload/lanes/web/README.md +83 -4
  47. package/payload/lanes/web/index.html +2 -1
  48. package/payload/lanes/web/mobile.css +53 -198
  49. package/payload/lanes/web/mobile.js +87 -55
  50. package/payload/lanes/zellij/config.kdl +67 -16
  51. package/payload/manifest.json +5 -3
  52. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -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-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`), or naming a script that is missing in the container — with
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-1422, lanes/docs/ARCHITECTURE.md).
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