@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.
Files changed (46) 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 +651 -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 +80 -13
  17. package/payload/lanes/docs/DESIGN.md +182 -4
  18. package/payload/lanes/docs/RUNBOOK.md +429 -39
  19. package/payload/lanes/docs/SHARING.md +1 -1
  20. package/payload/lanes/holders/exec.sh +2 -5
  21. package/payload/lanes/holders.lock +3 -0
  22. package/payload/lanes/install/host-install.sh +10 -1
  23. package/payload/lanes/install/services.py +30 -3
  24. package/payload/lanes/install.sh +108 -3
  25. package/payload/lanes/libexec/agent_state.py +32 -0
  26. package/payload/lanes/libexec/cursor_sync.py +10 -2
  27. package/payload/lanes/libexec/economy_poll.py +685 -0
  28. package/payload/lanes/libexec/guard.py +27 -1
  29. package/payload/lanes/libexec/lanes_lib.py +285 -38
  30. package/payload/lanes/libexec/notify.py +317 -0
  31. package/payload/lanes/libexec/registrations.py +1 -1
  32. package/payload/lanes/libexec/session_actions.py +7 -8
  33. package/payload/lanes/libexec/sidebar.py +911 -218
  34. package/payload/lanes/libexec/statusd.py +238 -69
  35. package/payload/lanes/libexec/tokens.py +38 -7
  36. package/payload/lanes/libexec/view.sh +13 -3
  37. package/payload/lanes/systemd/lanes-economy.service +20 -0
  38. package/payload/lanes/systemd/lanes-notify.service +19 -0
  39. package/payload/lanes/systemd/lanes-sync.service +3 -0
  40. package/payload/lanes/web/README.md +83 -4
  41. package/payload/lanes/web/index.html +2 -1
  42. package/payload/lanes/web/mobile.css +53 -198
  43. package/payload/lanes/web/mobile.js +87 -55
  44. package/payload/lanes/zellij/config.kdl +67 -16
  45. package/payload/manifest.json +4 -3
  46. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -91,7 +91,7 @@ clears on its next refresh. Previously read output cannot be recalled.
91
91
  ## What viewers can see
92
92
 
93
93
  - The selected workspace's current and future **registered lanes**. Unregistered
94
- sessions, legacy shells, extra/tool tabs and unrelated workspaces are excluded.
94
+ sessions, tool tabs and unrelated workspaces are excluded.
95
95
  - Lane names and observed activity; “active” describes recent terminal output,
96
96
  not a build verdict.
97
97
  - For tmux lanes, a selectable pane and its latest 2,000 lines, capped at
@@ -1,13 +1,10 @@
1
1
  #!/bin/sh
2
2
  # View-owned displays; interactive shells are persistent tmux sessions created by New +.
3
- # usage: exec.sh <container> monitor|legacy [cwd-or-id]
3
+ # usage: exec.sh <container> monitor [cwd] (the `legacy` view of pre-persistence Docker shells was retired by
4
+ # plan Task 19: a leftover `exec … legacy` resolve file now ends as an unknown helper, and `lanes gc` removes it)
4
5
  set -u
5
6
  container=${1:-} kind=${2:-} cwd=${3:-/home/coder}
6
7
  case "$container" in ''|-*|*[!A-Za-z0-9_.-]*) echo 'invalid container' >&2; exit 64 ;; esac
7
- if [ "$kind" = legacy ]; then
8
- repo=$(cd "$(dirname "$0")/.." && pwd)
9
- exec python3 -S -E "$repo/libexec/legacy_sessions.py" view "$container" "$cwd"
10
- fi
11
8
  case "$cwd" in /*) ;; *) cwd=/home/coder ;; esac
12
9
  case "$cwd" in *[!A-Za-z0-9_./-]*) cwd=/home/coder ;; esac
13
10
  common="-it -u coder -e TERM=xterm-256color -e COLORTERM=truecolor -e LANG=C.UTF-8"
@@ -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
- install -m 0755 "$temporary/$name" "$target"
53
+ binary=$temporary/$name
54
+ # zellij and zmx ship the binary at the top of the tarball; glow nests it in glow_<version>_Linux_x86_64/.
55
+ [ -f "$binary" ] || binary=$(find "$temporary" -type f -name "$name" | head -n 1)
56
+ [ -n "$binary" ] && [ -f "$binary" ] || { echo "no $name binary in $archive" >&2; exit 1; }
57
+ install -m 0755 "$binary" "$target"
49
58
  rm -rf "$temporary"
50
59
  trap - EXIT HUP INT TERM
51
60
  echo "installed $target"
@@ -14,7 +14,28 @@ def unit_value(value, command=False):
14
14
  return value.replace('$', '$$') if command else value
15
15
 
16
16
 
17
- def register(repo, home, share, state, workspaces=None):
17
+ # The opt-in economy poller's settings (libexec/economy_poll.py). Rendered into lanes-economy.service only, and only
18
+ # when set in the installer's environment; the unit itself is never enabled by an install (operator gate).
19
+ ECONOMY_SETTINGS = ('LANES_THROTTLE_CONTROL', 'LANES_THROTTLE_TOKEN_FILE', 'LANES_THROTTLE_LOG_DIR',
20
+ 'LANES_THROTTLE_CONTAINER')
21
+ ECONOMY_UNITS = ('lanes-economy.service',)
22
+ # The opt-in push notifier's config path (libexec/notify.py), rendered into lanes-notify.service only and only when
23
+ # set in the installer's environment (a config FILE path; the ntfy token stays in the file its token_file names).
24
+ NOTIFY_SETTINGS = ('LANES_NOTIFY_CONF',)
25
+ NOTIFY_UNITS = ('lanes-notify.service',)
26
+
27
+
28
+ def economy_settings(environ):
29
+ """The non-empty LANES_THROTTLE_* settings of `environ` (a token FILE path, never a token)."""
30
+ return {name: environ[name] for name in ECONOMY_SETTINGS if environ.get(name)}
31
+
32
+
33
+ def notify_settings(environ):
34
+ """The non-empty LANES_NOTIFY_* settings of `environ`."""
35
+ return {name: environ[name] for name in NOTIFY_SETTINGS if environ.get(name)}
36
+
37
+
38
+ def register(repo, home, share, state, workspaces=None, economy=None, notify=None):
18
39
  repo, home, share, state = (Path(p).resolve() for p in (repo, home, share, state))
19
40
  current = home / '.local/share/lanes/current'
20
41
  links = ((current, repo), (home / '.local/bin/lanes', repo / 'bin/lanes'))
@@ -25,6 +46,10 @@ def register(repo, home, share, state, workspaces=None):
25
46
  if workspaces:
26
47
  settings.append(('LANES_WORKSPACES', Path(workspaces).resolve()))
27
48
  environment = ''.join(f'Environment="{name}={unit_value(value)}"\n' for name, value in settings)
49
+ economy_environment = ''.join(f'Environment="{name}={unit_value(value)}"\n'
50
+ for name, value in sorted((economy or {}).items()) if name in ECONOMY_SETTINGS)
51
+ notify_environment = ''.join(f'Environment="{name}={unit_value(value)}"\n'
52
+ for name, value in sorted((notify or {}).items()) if name in NOTIFY_SETTINGS)
28
53
  rendered = {}
29
54
  for source in sorted((repo / 'systemd').iterdir()):
30
55
  if source.name.startswith('.') or source.suffix not in ('.service', '.timer', '.slice') or not source.is_file():
@@ -33,7 +58,9 @@ def register(repo, home, share, state, workspaces=None):
33
58
  content = content.replace('@SHARE@', unit_value(share, command=True))
34
59
  content = content.replace('@CADDY@', unit_value(shutil.which('caddy') or '/usr/bin/caddy', command=True))
35
60
  if '[Service]\n' in content:
36
- content = content.replace('[Service]\n', '[Service]\n' + environment, 1)
61
+ extra = (economy_environment if source.name in ECONOMY_UNITS else
62
+ notify_environment if source.name in NOTIFY_UNITS else '')
63
+ content = content.replace('[Service]\n', '[Service]\n' + environment + extra, 1)
37
64
  rendered[source.name] = '# Generated by lanes/install/services.py; re-run the server installer to update.\n' + content
38
65
  units = home / '.config/systemd/user'
39
66
  units.mkdir(parents=True, exist_ok=True)
@@ -58,4 +85,4 @@ if __name__ == '__main__':
58
85
  register(Path(__file__).resolve().parents[1], Path.home(),
59
86
  os.environ.get('LANES_SHARE', Path.home() / '.local/share/lanes'),
60
87
  os.environ.get('LANES_STATE', Path.home() / '.local/state/lanes'),
61
- os.environ.get('LANES_WORKSPACES'))
88
+ os.environ.get('LANES_WORKSPACES'), economy_settings(os.environ), notify_settings(os.environ))
@@ -16,6 +16,8 @@
16
16
  # --coder-launch also provide the command name `coder-launch` (an existing one is kept as coder-launch-legacy)
17
17
  # --no-desktop skip desktop integration (Lanes.app / .desktop entry / Hyprland key binding)
18
18
  # --no-uploads skip the uploads watch folder and the upload helper service
19
+ # --palette give the lanes window the cockpit's own Ghostty palette instead of your everyday theme
20
+ # (off by default; a re-run without it puts the inherited look back)
19
21
  # --yes never prompt (a new key is then created WITHOUT a passphrase)
20
22
  #
21
23
  # Exit status: 0 ready to use · 3 installed, but this machine's key is not authorized on the server yet ·
@@ -35,7 +37,7 @@ stamp=$(date -u +%Y%m%dT%H%M%SZ)
35
37
  label=ai.beyondwork.lanes-upload
36
38
  helper_label=ai.beyondwork.lanes-helper
37
39
 
38
- mode=install device= key= site_file=$here/site/site.conf link=0 coder_launch=0 desktop=1 uploads=1 assume_yes=0
40
+ mode=install device= key= site_file=$here/site/site.conf link=0 coder_launch=0 desktop=1 uploads=1 assume_yes=0 palette=0
39
41
  [ -r "$site_file" ] || site_file=$config/client-site.conf
40
42
  while [ $# -gt 0 ]; do
41
43
  case "$1" in
@@ -48,8 +50,9 @@ while [ $# -gt 0 ]; do
48
50
  --coder-launch) coder_launch=1 ;;
49
51
  --no-desktop) desktop=0 ;;
50
52
  --no-uploads) uploads=0 ;;
53
+ --palette) palette=1 ;;
51
54
  --yes|-y) assume_yes=1 ;;
52
- -h|--help) sed -n '2,22p' "$0"; exit 0 ;;
55
+ -h|--help) sed -n '2,24p' "$0"; exit 0 ;;
53
56
  *) echo "install.sh: unknown option $1 (see --help)" >&2; exit 1 ;;
54
57
  esac
55
58
  shift
@@ -214,6 +217,108 @@ install_client() {
214
217
  cat "$target/ghostty/common.conf"
215
218
  if [ "$os" = Darwin ]; then cat "$target/ghostty/macos.conf"; else cat "$target/ghostty/omarchy.conf"; fi
216
219
  } > "$config/ghostty.conf"
220
+ install_palette
221
+ }
222
+
223
+ # The Ghostty binary, for its version only: on PATH, else inside the macOS app bundle.
224
+ ghostty_bin() {
225
+ if command -v ghostty >/dev/null 2>&1; then command -v ghostty; return 0; fi
226
+ for app in /Applications/Ghostty.app "$HOME/Applications/Ghostty.app"; do
227
+ [ -x "$app/Contents/MacOS/ghostty" ] && { printf '%s' "$app/Contents/MacOS/ghostty"; return 0; }
228
+ done
229
+ return 1
230
+ }
231
+ # Ghostty >= 1.1 reads `theme = light:…,dark:…` (with absolute theme paths); unknown or older = no.
232
+ ghostty_pairs_themes() {
233
+ gb=$(ghostty_bin) || return 1
234
+ ver=$("$gb" +version 2>/dev/null | sed -n '1s/^Ghostty \([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2/p')
235
+ [ -n "$ver" ] || return 1
236
+ # shellcheck disable=SC2086 # split "MAJOR MINOR" into two words on purpose
237
+ set -- $ver
238
+ [ "$1" -gt 1 ] || { [ "$1" -eq 1 ] && [ "$2" -ge 1 ]; }
239
+ }
240
+ # Ghostty applies a `theme` UNDER every colour key set explicitly anywhere in the loaded configuration, whatever the
241
+ # file order (Config.zig `loadTheme`: the theme loads, then the configuration is replayed over it). The lanes window
242
+ # includes the everyday config first, so when that config — or a file it reaches through `config-file` (Omarchy: the
243
+ # current theme's ghostty.conf) — sets a colour key, a theme pair would be a silent no-op.
244
+ # And Ghostty loads `config-file` entries as ONE first-in-first-out list (Config.zig `loadRecursiveFiles` iterates the
245
+ # list while each loaded file appends its own includes to the end): breadth-first, so a file two includes deep loads
246
+ # after every direct include of the lanes ghostty.conf — the palette included there last included. Last assignment wins.
247
+ # Walks the include graph from $1 the same way (bounded at 64 entries, cycle-safe: a repeat is skipped as Ghostty skips
248
+ # it; `?`, quotes, `~/` and paths relative to the including file as Ghostty reads them) and sets
249
+ # ec_colour = the first file (in Ghostty's load order) that sets a colour key, empty when none does;
250
+ # ec_depth = the deepest include depth of an existing file ($1 = 0, its direct includes = 1).
251
+ scan_everyday_chain() {
252
+ ec_nl='
253
+ '
254
+ ec_tab=$(printf '\t')
255
+ ec_queue="0$ec_tab$1$ec_nl" ec_seen=$ec_nl ec_n=0 ec_colour='' ec_depth=0
256
+ while [ -n "$ec_queue" ] && [ "$ec_n" -lt 64 ]; do
257
+ ec_e=${ec_queue%%"$ec_nl"*}; ec_queue=${ec_queue#*"$ec_nl"}; ec_n=$((ec_n + 1))
258
+ ec_d=${ec_e%%"$ec_tab"*}; ec_f=${ec_e#*"$ec_tab"}
259
+ case "$ec_seen" in *"$ec_nl$ec_f$ec_nl"*) continue ;; esac
260
+ ec_seen="$ec_seen$ec_f$ec_nl"
261
+ [ -f "$ec_f" ] && [ -r "$ec_f" ] || continue
262
+ [ "$ec_d" -gt "$ec_depth" ] && ec_depth=$ec_d
263
+ if [ -z "$ec_colour" ] && grep -Eq '^[[:space:]]*(background|foreground|palette|cursor-color|cursor-text|selection-background|selection-foreground)[[:space:]]*=' "$ec_f"; then
264
+ ec_colour=$ec_f
265
+ fi
266
+ ec_dir=$(dirname "$ec_f") ec_c=$((ec_d + 1))
267
+ ec_incs=$(sed -n 's/^[[:space:]]*config-file[[:space:]]*=[[:space:]]*//p' "$ec_f" \
268
+ | sed -e 's/[[:space:]]*$//' -e 's/^?//' -e 's/^"\(.*\)"$/\1/' -e 's/^?//')
269
+ while IFS= read -r ec_inc; do
270
+ case "$ec_inc" in
271
+ '') ;;
272
+ '~/'*) ec_queue="$ec_queue$ec_c$ec_tab$HOME/${ec_inc#"~/"}$ec_nl" ;;
273
+ /*) ec_queue="$ec_queue$ec_c$ec_tab$ec_inc$ec_nl" ;;
274
+ *) ec_queue="$ec_queue$ec_c$ec_tab$ec_dir/$ec_inc$ec_nl" ;;
275
+ esac
276
+ done <<EOF
277
+ $ec_incs
278
+ EOF
279
+ done
280
+ }
281
+ # --palette (spec §7): the cockpit tokens instead of the everyday theme. Two rules make it win:
282
+ # 1. ORDER — the include is the last line of ghostty.conf, hence the last depth-1 entry of Ghostty's breadth-first
283
+ # list; the palette keys go in a chain of one-include hop files (ghostty-palette.conf → ghostty-palette.1.conf → …)
284
+ # so they sit at the everyday chain's deepest depth, where they are the last entry of that depth (every hop is its
285
+ # parent's only include) and nothing loads later. Omarchy's theme file sits two deep: one hop.
286
+ # 2. KIND — a theme pair loses to ANY explicit colour key in the everyday chain (above), so the pair is installed only
287
+ # when that chain sets none; otherwise the explicit dark palette (last assignment wins) and `say` names the file.
288
+ # Both rules are read from Ghostty's source, not observed on a running Ghostty here (DEF-1466 holds the live check).
289
+ # Without the flag the include is absent and every earlier palette file is removed: inherit-first is the default.
290
+ install_palette() {
291
+ rm -f "$config"/ghostty-palette*.conf
292
+ [ "$palette" = 1 ] || return 0
293
+ scan_everyday_chain "$config/ghostty.conf"
294
+ hops=0; [ "$ec_depth" -gt 1 ] && hops=$((ec_depth - 1))
295
+ keys="$config/ghostty-palette.conf" i=0
296
+ while [ "$i" -lt "$hops" ]; do
297
+ i=$((i + 1))
298
+ printf '%s\n' "# lanes palette hop $i of $hops (install.sh --palette): Ghostty loads includes breadth-first, so the palette" \
299
+ '# keys sit as deep as the everyday config'"'"'s deepest include and load after it.' \
300
+ "config-file = ?$config/ghostty-palette.$i.conf" > "$keys"
301
+ keys="$config/ghostty-palette.$i.conf"
302
+ done
303
+ cp "$target/ghostty/lanes-palette-light.conf" "$config/ghostty-palette-light.conf"
304
+ if ghostty_pairs_themes; then
305
+ if [ -n "$ec_colour" ]; then
306
+ cp "$target/ghostty/lanes-palette.conf" "$keys"
307
+ variant="dark only: $(tilde "$ec_colour") sets colours explicitly, which would override a light/dark theme pair (light variant: ghostty-palette-light.conf)"
308
+ else
309
+ # Both as theme files: explicit colours in the included file would override the pair, so it holds only this.
310
+ cp "$target/ghostty/lanes-palette.conf" "$config/ghostty-palette-dark.conf"
311
+ printf '%s\n' '# lanes cockpit palette (install.sh --palette): follows the system light/dark setting.' \
312
+ "theme = light:$config/ghostty-palette-light.conf,dark:$config/ghostty-palette-dark.conf" > "$keys"
313
+ variant='light and dark, following the system'
314
+ fi
315
+ else
316
+ cp "$target/ghostty/lanes-palette.conf" "$keys"
317
+ variant='dark (Ghostty 1.1+ follows the system; light variant: ghostty-palette-light.conf)'
318
+ fi
319
+ [ "$hops" -gt 0 ] && variant="$variant; $hops include hop(s) deep, after the everyday config's deepest include (depth $ec_depth)"
320
+ printf '\n# palette (install.sh --palette): the cockpit tokens instead of the everyday theme; remove with a plain re-run\nconfig-file = ?%s/ghostty-palette.conf\n' "$(tilde "$config")" >> "$config/ghostty.conf"
321
+ say "Ghostty palette: lanes tokens, $variant"
217
322
  }
218
323
 
219
324
  find_terminal() {
@@ -591,7 +696,7 @@ do_uninstall() {
591
696
  rm -rf "$share/client" "$state"
592
697
  # A Linux account can run both roles. Keep server catalogs and any files
593
698
  # the client did not create in the shared configuration directory.
594
- rm -f "$config/device" "$config/ghostty.conf" "$config"/ghostty.*.conf \
699
+ rm -f "$config/device" "$config/ghostty.conf" "$config"/ghostty.*.conf "$config"/ghostty-palette*.conf \
595
700
  "$config/client-site.conf" "$config/client-known-hosts" "$config/site.cache" \
596
701
  "$config/upload-allow" "$config/uploads-dir" "$config/upload-watch.log"
597
702
  rmdir "$config" 2>/dev/null || :
@@ -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`), 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-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