@vibelord/lanes 1.1.1 → 1.3.0

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 (71) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +47 -23
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2753 -202
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +147 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1150 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1318 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +793 -16
  20. package/payload/lanes/docs/DESIGN.md +127 -46
  21. package/payload/lanes/docs/INSTALL.md +175 -18
  22. package/payload/lanes/docs/RUNBOOK.md +968 -76
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +967 -150
  25. package/payload/lanes/holders.lock +5 -0
  26. package/payload/lanes/install/enable-docs.sh +78 -0
  27. package/payload/lanes/install/enable-web.sh +2 -0
  28. package/payload/lanes/install/host-install.sh +197 -7
  29. package/payload/lanes/install/services.py +148 -18
  30. package/payload/lanes/install.sh +68 -7
  31. package/payload/lanes/libexec/agent_state.py +74 -1
  32. package/payload/lanes/libexec/cursor_sync.py +6 -0
  33. package/payload/lanes/libexec/device_sync.py +47 -10
  34. package/payload/lanes/libexec/docs_store.py +995 -0
  35. package/payload/lanes/libexec/docsd.py +2010 -0
  36. package/payload/lanes/libexec/economy_poll.py +1 -0
  37. package/payload/lanes/libexec/enrold.py +41 -22
  38. package/payload/lanes/libexec/keys.py +54 -23
  39. package/payload/lanes/libexec/lanes_lib.py +620 -22
  40. package/payload/lanes/libexec/mdlite.py +575 -0
  41. package/payload/lanes/libexec/members.py +21 -2
  42. package/payload/lanes/libexec/notify.py +169 -29
  43. package/payload/lanes/libexec/registrations.py +216 -18
  44. package/payload/lanes/libexec/session_actions.py +182 -14
  45. package/payload/lanes/libexec/sidebar +11 -3
  46. package/payload/lanes/libexec/sidebar.py +2232 -365
  47. package/payload/lanes/libexec/statusd.py +231 -12
  48. package/payload/lanes/libexec/studio_host.py +8 -1
  49. package/payload/lanes/libexec/tokens.py +148 -35
  50. package/payload/lanes/libexec/tokens.sh +86 -2
  51. package/payload/lanes/libexec/update.py +1044 -0
  52. package/payload/lanes/libexec/uploads.py +538 -0
  53. package/payload/lanes/libexec/users.py +614 -0
  54. package/payload/lanes/libexec/view.sh +34 -6
  55. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  56. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  57. package/payload/lanes/systemd/lanes-gc.service +1 -1
  58. package/payload/lanes/systemd/lanes-update.service +17 -0
  59. package/payload/lanes/systemd/lanes-update.timer +17 -0
  60. package/payload/lanes/web/Caddyfile +24 -1
  61. package/payload/lanes/web/README.md +165 -28
  62. package/payload/lanes/web/browser.js +4 -8
  63. package/payload/lanes/web/docs.css +52 -0
  64. package/payload/lanes/web/docs.js +36 -0
  65. package/payload/lanes/web/mobile.css +17 -17
  66. package/payload/lanes/web/mobile.js +45 -32
  67. package/payload/lanes/web/upload.css +17 -0
  68. package/payload/lanes/web/upload.js +84 -0
  69. package/payload/lanes/workspaces.example/README.md +3 -1
  70. package/payload/lanes/zellij/config.kdl +106 -56
  71. package/payload/manifest.json +6 -3
@@ -2,24 +2,24 @@
2
2
  # Optional: the cockpit palette regardless of your everyday Ghostty theme (install.sh --client --palette).
3
3
  # Slots 1-4 are the four tones, so 16-colour mode here equals truecolor mode. No font, padding or cursor keys.
4
4
  background = #f3f5f2
5
- foreground = #17241b
6
- cursor-color = #2f6b45
5
+ foreground = #111a14
6
+ cursor-color = #1f6f3f
7
7
  selection-background = #d5ded6
8
- selection-foreground = #17241b
9
- palette = 0=#3b4a42
10
- palette = 1=#b7322a
11
- palette = 2=#1b7142
12
- palette = 3=#8a5a00
13
- palette = 4=#2456a8
14
- palette = 5=#7a3fb8
15
- palette = 6=#0f7b7b
16
- palette = 7=#56616d
17
- palette = 8=#5f6d66
18
- palette = 9=#c2332a
19
- palette = 10=#1b7a38
20
- palette = 11=#8f5a00
21
- palette = 12=#2860c4
22
- palette = 13=#8a4dc8
23
- palette = 14=#0f7272
24
- palette = 15=#17241b
8
+ selection-foreground = #111a14
9
+ palette = 0=#2f3b34
10
+ palette = 1=#c41c1c
11
+ palette = 2=#0d7338
12
+ palette = 3=#a04d00
13
+ palette = 4=#1a56c4
14
+ palette = 5=#8228c4
15
+ palette = 6=#006f73
16
+ palette = 7=#4a5661
17
+ palette = 8=#55625b
18
+ palette = 9=#d0221a
19
+ palette = 10=#0e7a3a
20
+ palette = 11=#a85200
21
+ palette = 12=#1f5fd8
22
+ palette = 13=#9133d0
23
+ palette = 14=#00797c
24
+ palette = 15=#111a14
25
25
  # END lanes tokens
@@ -2,24 +2,24 @@
2
2
  # Optional: the cockpit palette regardless of your everyday Ghostty theme (install.sh --client --palette).
3
3
  # Slots 1-4 are the four tones, so 16-colour mode here equals truecolor mode. No font, padding or cursor keys.
4
4
  background = #101318
5
- foreground = #edf1f5
6
- cursor-color = #b4e3bf
5
+ foreground = #f2f5f8
6
+ cursor-color = #8fe3a6
7
7
  selection-background = #2c333d
8
- selection-foreground = #edf1f5
9
- palette = 0=#7d8896
10
- palette = 1=#f28b7d
11
- palette = 2=#6fd39a
12
- palette = 3=#e5b84a
13
- palette = 4=#7fb3f2
14
- palette = 5=#c9a6ff
15
- palette = 6=#6fd6d0
16
- palette = 7=#9ca7b5
17
- palette = 8=#8a95a3
18
- palette = 9=#ff9d96
19
- palette = 10=#8fe0a3
20
- palette = 11=#f7cf7f
21
- palette = 12=#a5cbff
22
- palette = 13=#dcc6ff
23
- palette = 14=#9ae6e0
24
- palette = 15=#edf1f5
8
+ selection-foreground = #f2f5f8
9
+ palette = 0=#8792a0
10
+ palette = 1=#ff7b6e
11
+ palette = 2=#5fdc8a
12
+ palette = 3=#ffc233
13
+ palette = 4=#6fb6ff
14
+ palette = 5=#c58bff
15
+ palette = 6=#3fd9cf
16
+ palette = 7=#a6b1be
17
+ palette = 8=#97a3b1
18
+ palette = 9=#ff9a8e
19
+ palette = 10=#86f0ad
20
+ palette = 11=#ffd866
21
+ palette = 12=#98cbff
22
+ palette = 13=#dcb2ff
23
+ palette = 14=#7deee6
24
+ palette = 15=#f2f5f8
25
25
  # END lanes tokens
@@ -3,17 +3,24 @@
3
3
  # lanes-launch [device] attach this terminal to the device's view session (default: see below)
4
4
  # lanes-launch <workspace> same, and bring that workspace's tab to the front (e.g. `coder-launch worlds`)
5
5
  # lanes-launch <device> --viewports N a wide view session: N (2-3) viewports side by side per workspace
6
- # lanes-launch <device> [--ascii] [--theme dark|light|auto] [--glyphs M] [--color C] the device's look (lanes attach)
6
+ # lanes-launch <device> [--ascii] [--theme T] [--glyphs M] [--color C] [--notify 1|0] [--dim 90|2] [--device-only] look (lanes attach)
7
+ # [--editor micro|hx|nvim|vim|auto] and the editor quick edit (Ctrl-Alt-e) starts (auto: none chosen)
7
8
  # lanes-launch lane <lane-id> attach straight to ONE lane, no multiplexer in between
8
9
  # lanes-launch ls list lanes with live status
10
+ # lanes-launch here [device] show here the lane you opened last on another device of yours (`lanes here`; default:
11
+ # this machine's device); an open window follows, a closed one shows it when it attaches
9
12
  # lanes-launch web phone|laptop|<device> [--desktop|--mobile] [--no-qr]
10
13
  # a browser view in one step: the server prepares it, prints its private URL and a QR
11
14
  # code of it (--no-qr: none), then a login token in THIS terminal. phone = <your
12
15
  # device>-phone (phone layout), laptop = <your device>-web (desktop layout); any other
13
16
  # name is that device
14
17
  # lanes-launch update [install.sh options] the newest client from npm, with your remembered install options
18
+ # lanes-launch --version this client's release (the VERSION beside it; `dev` without one)
15
19
  # lanes-launch --helper <device> [--service | --while-pid PID] run only the upload helper
16
20
  # (--service: the per-machine background service that install.sh sets up)
21
+ # Self-update: at most every 6 h a window (a device, a workspace or `lane <id>`) first asks the server whether this
22
+ # client is older; then, once npm has that release, it installs exactly it with your remembered install options and
23
+ # starts again as that client, once. LANES_NO_AUTOUPDATE=1: never. A client run from a checkout never does.
17
24
  # It never ends by itself: after network loss, sleep, or a server that dropped or refused it, it says so, waits and
18
25
  # reattaches (Enter = now). Closing the window (or Ctrl-C) ends it. Log: ~/.local/state/lanes-client/launch-<target>.log
19
26
  set -u
@@ -25,6 +32,15 @@ fallbacks=${LANES_FALLBACK_HOST:-lanes-ip lanes-public}
25
32
  remote='$HOME/.local/bin/lanes'
26
33
  config=${XDG_CONFIG_HOME:-$HOME/.config}/lanes
27
34
  state=${XDG_STATE_HOME:-$HOME/.local/state}/lanes-client # logs and helper.alive; `install.sh --check` reads them
35
+ nl='
36
+ '
37
+ # The command line exactly as given, each argument single-quoted for one `eval "set -- $argv"`: the parsing below
38
+ # shifts the arguments away, and a self-update starts this client again with them (self_update).
39
+ argv=
40
+ for arg in "$@"; do
41
+ case "$arg" in *\'*) arg=$(printf '%s' "$arg" | sed "s/'/'\\\\''/g") ;; esac
42
+ argv="$argv '$arg'"
43
+ done
28
44
 
29
45
  default_device() {
30
46
  if [ -r "$config/device" ]; then head -n 1 "$config/device"; return; fi
@@ -97,48 +113,193 @@ web_view() {
97
113
  exec ssh -t "$host" "sh -c 'wv_release=\$($remote hello 2>/dev/null); wv_release=\${wv_release##* version=}; wv_release=\${wv_release%% *}; case \$wv_release in 0.*|1.0.*) echo \"$wv_old\" >&2; exec $wv_view ;; *) exec $wv_view --default-device $wv_base$wv_qr ;; esac'"
98
114
  }
99
115
 
100
- # `lanes-launch update`: the newest client from npm with this machine's remembered choices. Every KEY=VALUE line of
101
- # ~/.config/lanes/install-options becomes its install.sh flag, checked as install.sh checks the file (a value it would
102
- # ignore is not passed; it names that one itself), so the update repeats the last install even where the new package
103
- # would resolve a default differently. Arguments given here follow the replayed ones: on install.sh's command line the
104
- # last flag wins.
116
+ # A client linked to a checkout (install.sh --link, or ./install.sh in one): it is updated there with git pull, and
117
+ # npm must never replace it with a copy. An installed copy has no install.sh beside its folder (install.sh drops it).
118
+ from_checkout() { [ -r "$(self_dir)/../install.sh" ]; }
119
+
120
+ # This machine's remembered choices as install.sh flags, one argument per line (no value holds a newline: the file is
121
+ # read line by line). Every KEY=VALUE line of ~/.config/lanes/install-options becomes its flag, checked as install.sh
122
+ # checks the file (a value it would ignore is not passed; it names that one itself), so an update repeats the last
123
+ # install even where the new package would resolve a default differently. Nothing, and status 0, without a readable
124
+ # file; nothing, and status 2, for a file a newer installer wrote (its SCHEMA is unknown here: not replayed).
125
+ replay_options() {
126
+ ro_options=$config/install-options
127
+ [ -r "$ro_options" ] || return 0
128
+ case "$(sed -n 's/^SCHEMA=//p' "$ro_options" | tail -n 1)" in ''|1) ;; *) return 2 ;; esac
129
+ while IFS= read -r ro_line || [ -n "$ro_line" ]; do
130
+ ro_value=${ro_line#*=}
131
+ ro_flag=$(printf '%s' "${ro_line%%=*}" | tr 'A-Z_' 'a-z-')
132
+ case "$ro_line" in
133
+ DEVICE=*) case "$ro_value" in ''|*[!a-z0-9_.-]*|.*) ;; *) printf '%s\n' --device "$ro_value" ;; esac ;;
134
+ KEY=/*|KEY='~/'*) printf '%s\n' --key "$ro_value" ;;
135
+ SITE=/*) printf '%s\n' --site "$ro_value" ;;
136
+ SITE=*) case "$ro_value" in ''|.*|*/*|*[!A-Za-z0-9_.-]*) ;; *) printf '%s\n' --site "$ro_value" ;; esac ;;
137
+ DESKTOP=1|UPLOADS=1|PALETTE=1|CODER_LAUNCH=1) printf '%s\n' "--$ro_flag" ;;
138
+ DESKTOP=0|UPLOADS=0|PALETTE=0|CODER_LAUNCH=0) printf '%s\n' "--no-$ro_flag" ;;
139
+ esac # (SCHEMA, comments, unknown keys: install.sh does not read them either)
140
+ done < "$ro_options"
141
+ return 0
142
+ }
143
+
144
+ # `lanes-launch update`: the newest client from npm with this machine's remembered choices (replay_options).
145
+ # Arguments given here follow the replayed ones: on install.sh's command line the last flag wins.
105
146
  update_client() {
106
147
  for uc_arg in "$@"; do
107
148
  case "$uc_arg" in --server) echo 'lanes-launch update updates this client; a server updates with: npx @vibelord/lanes@latest --server' >&2; exit 64 ;; esac
108
149
  done
109
- uc_dir=$(self_dir)
110
- if [ -r "$uc_dir/../install.sh" ]; then
111
- # (a linked or checkout client: npm would replace it with a copy)
112
- echo "lanes-launch update: this client runs from a checkout ($(dirname "$uc_dir")): update it there with git pull, then ./install.sh" >&2
150
+ if from_checkout; then
151
+ echo "lanes-launch update: this client runs from a checkout ($(dirname "$(self_dir)")): update it there with git pull, then ./install.sh" >&2
113
152
  exit 64
114
153
  fi
115
154
  command -v npx >/dev/null 2>&1 || { echo 'lanes-launch update: needs Node.js 18 or newer (npx), https://nodejs.org' >&2; exit 69; }
116
- uc_given=$#
117
- uc_options=$config/install-options
118
- if [ -r "$uc_options" ]; then
119
- case "$(sed -n 's/^SCHEMA=//p' "$uc_options" | tail -n 1)" in
120
- ''|1)
121
- while IFS= read -r uc_line || [ -n "$uc_line" ]; do
122
- uc_value=${uc_line#*=}
123
- uc_flag=$(printf '%s' "${uc_line%%=*}" | tr 'A-Z_' 'a-z-')
124
- case "$uc_line" in
125
- DEVICE=*) case "$uc_value" in ''|*[!a-z0-9_.-]*|.*) ;; *) set -- "$@" --device "$uc_value" ;; esac ;;
126
- KEY=/*|KEY='~/'*) set -- "$@" --key "$uc_value" ;;
127
- SITE=/*) set -- "$@" --site "$uc_value" ;;
128
- SITE=*) case "$uc_value" in ''|.*|*/*|*[!A-Za-z0-9_.-]*) ;; *) set -- "$@" --site "$uc_value" ;; esac ;;
129
- DESKTOP=1|UPLOADS=1|PALETTE=1|CODER_LAUNCH=1) set -- "$@" "--$uc_flag" ;;
130
- DESKTOP=0|UPLOADS=0|PALETTE=0|CODER_LAUNCH=0) set -- "$@" "--no-$uc_flag" ;;
131
- esac # (SCHEMA, comments, unknown keys: install.sh does not read them either)
132
- done < "$uc_options" ;;
133
- *) echo "lanes-launch update: $uc_options was written by a newer installer: not replayed" >&2 ;;
134
- esac
135
- fi
136
- # The arguments given here go last: move them behind the replayed flags.
137
- while [ "$uc_given" -gt 0 ]; do set -- "$@" "$1"; shift; uc_given=$((uc_given - 1)); done
155
+ uc_replayed=$(replay_options) || echo "lanes-launch update: $config/install-options was written by a newer installer: not replayed" >&2
156
+ # One argument per line, the replayed ones first (set -f: a value is never a glob pattern; IFS unset: the default).
157
+ set -f; IFS=$nl; set -- $uc_replayed "$@"; unset IFS; set +f
138
158
  printf 'lanes-launch update: npx --yes @vibelord/lanes@latest %s\n' "$*" >&2
139
159
  exec npx --yes @vibelord/lanes@latest "$@"
140
160
  }
141
161
 
162
+ # ---- self-update (W9 K2) --------------------------------------------------------------------------------------------
163
+ # Before a window connects (attach and `lane <id>`; never the helper, `ls`, `web`, `update` or --version), at most
164
+ # once every 6 h: ask the server whether this client is older (`lanes hello --client-version <this release>`, which
165
+ # then ends its greeting with ` update=<release>`, W9 K1). If it is, and npm has that release, install EXACTLY it,
166
+ # never @latest, with the remembered install options, as `lanes-launch update` would, and start again as the new
167
+ # client with the same command line — once: the restarted client carries LANES_AUTOUPDATED=1 and never updates itself
168
+ # again. LANES_NO_AUTOUPDATE (any value) turns it off; a client run from a checkout, or one without a release, never
169
+ # asks. Nothing here keeps a window from connecting: an update that fails says so in one dim line, and the window goes
170
+ # on with this client. A question that gets no answer (no server, an older one that takes the flag for a device name,
171
+ # a busy one) is only logged: the connection that follows says what is wrong with the path. So is a release npm does
172
+ # not have (yet): a server run from a checkout says its VERSION as soon as it is pulled, which can be before the
173
+ # publish, and no machine can act on that — the next question, 6 h later, asks npm again.
174
+ checked=$state/checked # its time: when a window of this machine last asked (touched on every question)
175
+ claim=$state/checking # the folder of the one window asking right now (mkdir is atomic); the answers land in it
176
+ claimed=
177
+ # Whether a question is due: no stamp, one 6 h old or older, or one from the future (a clock that was wrong).
178
+ update_due() {
179
+ ud_seen=$(mtime "$checked") || return 0
180
+ [ -n "$ud_seen" ] || return 0
181
+ ud_age=$(( $(date +%s) - ud_seen ))
182
+ [ "$ud_age" -ge 21600 ] || [ "$ud_age" -lt 0 ]
183
+ }
184
+ # Windows that open together (a restored session) ask once between them: the first takes the claim, the others go
185
+ # on. A claim older than 10 min was left by a window that closed while it asked; it is taken over.
186
+ take_claim() {
187
+ mkdir -p "$state" 2>/dev/null || return 1
188
+ if ! mkdir "$claim" 2>/dev/null; then
189
+ tc_since=$(mtime "$claim") || return 1
190
+ [ $(( $(date +%s) - tc_since )) -ge 600 ] || return 1
191
+ rm -rf "$claim"; mkdir "$claim" 2>/dev/null || return 1
192
+ fi
193
+ claimed=1
194
+ }
195
+ drop_claim() {
196
+ [ -n "$claimed" ] || return 0
197
+ rm -f "$claim/reply" "$claim/npm.err"; rmdir "$claim" 2>/dev/null || :
198
+ claimed=
199
+ }
200
+ # npx -> $npx_path and the npm beside it -> $npm_path, both absolute: npx on this PATH, else where the login shell
201
+ # finds it — a window a desktop app opened has the system's minimal PATH (macOS: no Homebrew, no Node.js installer
202
+ # folder), as install.sh knows of an ssh session. Node.js puts npm and npx in one folder. Status 1 without both.
203
+ find_npx() {
204
+ npx_path=$(command -v npx 2>/dev/null) || npx_path=
205
+ case "$npx_path" in /*) ;; *)
206
+ limit=10; tree=1
207
+ run "${SHELL:-/bin/sh}" -lc 'command -v npx' </dev/null >"$claim/reply" 2>/dev/null || :
208
+ limit= tree=
209
+ npx_path=$(tail -n 1 "$claim/reply" 2>/dev/null) ;; # (a talkative profile prints before it)
210
+ esac
211
+ npm_path=$(dirname "$npx_path")/npm
212
+ case "$npx_path" in /*) [ -x "$npx_path" ] && [ -x "$npm_path" ] ;; *) return 1 ;; esac
213
+ }
214
+ # on_npm <release>: whether npm has that release of the package, asked as npx would fetch it (the same npm, its config
215
+ # and registry). 0: `npm view` printed it. 1: not published (yet) — npm 10 and later end with code E404 ("No match
216
+ # found for version"), earlier ones print nothing. 2: no answer (offline, a proxy or the registry failing, 15 s), with
217
+ # npm's error code, when it gave one, in $npm_code ([A-Z0-9_] only: it is shown). --fetch-retries=0: the window waits
218
+ # for this, and npm's own retries kept a refused registry going past 60 s (MEASURED, npm 11.6.2: 0.3 s without them);
219
+ # the next question, 6 h later, is the retry.
220
+ on_npm() {
221
+ npm_code=
222
+ limit=15; tree=1
223
+ run env PATH="$(dirname "$npx_path"):$PATH" "$npm_path" view "@vibelord/lanes@$1" version --fetch-retries=0 </dev/null >"$claim/reply" 2>"$claim/npm.err" || :
224
+ limit= tree=
225
+ if grep -qxF -- "$1" "$claim/reply" 2>/dev/null; then return 0; fi
226
+ npm_code=$(sed -n 's/.*code \(E[A-Z0-9_]*\).*/\1/p' "$claim/npm.err" 2>/dev/null | head -n 1)
227
+ if [ "$rc" = 0 ] && [ ! -s "$claim/reply" ]; then return 1; fi
228
+ [ "$npm_code" = E404 ] && return 1
229
+ return 2
230
+ }
231
+ # One dim line on the screen, and the same in the log.
232
+ say_dim() { printf '\033[2mlanes: %s\033[0m\r\n' "$1"; log "self-update: $1"; }
233
+ self_update() {
234
+ [ -z "${LANES_NO_AUTOUPDATE:-}" ] && [ -z "${LANES_AUTOUPDATED:-}" ] && [ -n "$release" ] || return 0
235
+ ! from_checkout || return 0
236
+ update_due || return 0
237
+ take_claim || return 0
238
+ # (looked at again under the claim: another window may have asked between the first look and the claim)
239
+ if ! update_due || ! touch "$checked" 2>/dev/null; then drop_claim; return 0; fi
240
+ # Said while it asks (a busy server needs seconds for banner and key exchange); wiped once it has the answer.
241
+ printf '\033[2mlanes %s: asking %s for a newer client…\033[0m' "$release" "$host"
242
+ limit=10
243
+ run ssh -n -o RequestTTY=no -o BatchMode=yes -o ConnectTimeout=5 "$host" "$remote hello$version" >"$claim/reply" 2>/dev/null || :
244
+ limit=
245
+ printf '\r\033[K'
246
+ su_to=$(awk '$1 == "ok" { for (i = 2; i <= NF; i++) if (substr($i, 1, 7) == "update=") print substr($i, 8); exit }' "$claim/reply" 2>/dev/null)
247
+ # An exact release only: digits first, nothing npm could read as a tag, a range, a path or a URL (and such text is
248
+ # never echoed, not even into the log).
249
+ su_ok=
250
+ case "$su_to" in
251
+ ''|"$release") log "self-update: asked $host (exit $rc): no update"; drop_claim; return 0 ;;
252
+ *[!A-Za-z0-9.+-]*) ;;
253
+ [0-9]*.[0-9]*.[0-9]*) su_ok=1 ;;
254
+ esac
255
+ if [ -z "$su_ok" ]; then log "self-update: asked $host: ignored an update= that names no release"; drop_claim; return 0; fi
256
+ if ! su_replayed=$(replay_options); then
257
+ drop_claim
258
+ say_dim "the server runs $su_to; not updating by itself: $config/install-options was written by a newer installer — run lanes-launch update"
259
+ return 0
260
+ fi
261
+ if ! find_npx; then
262
+ drop_claim
263
+ say_dim "the server runs $su_to; no npx and npm (Node.js 18 or newer) on this machine to update with — this window goes on with $release"
264
+ return 0
265
+ fi
266
+ # Only a release npm has is installed: npx would fail on any other one only after its own registry round trip, the
267
+ # same way on every machine and every 6 h, with nothing the person at this window can do about it (DEF-1786).
268
+ printf '\033[2mlanes %s: the server runs %s; asking npm for it…\033[0m' "$release" "$su_to"
269
+ on_npm "$su_to"; su_npm=$?
270
+ printf '\r\033[K'
271
+ case "$su_npm" in
272
+ 1) log "self-update: the server runs $su_to, which npm does not have (yet): not installed; asked again in 6 h"
273
+ drop_claim; return 0 ;;
274
+ 2) drop_claim
275
+ say_dim "the server runs $su_to; asking npm for it failed (exit $rc${npm_code:+, $npm_code}) — this window goes on with $release; windows try again in 6 h"
276
+ return 0 ;;
277
+ esac
278
+ set -f; IFS=$nl; set -- $su_replayed; unset IFS; set +f
279
+ say_dim "the server runs $su_to — updating this client from $release: npx --yes @vibelord/lanes@$su_to $*"
280
+ # The terminal stays this command's (install.sh may ask something; closing the window or Ctrl-C ends both). npx's
281
+ # folder goes first on its PATH: npx is `#!/usr/bin/env node`, and node lives beside it. tree: npx is the first of a
282
+ # chain (npm, its shell, the npm wrapper's node, install.sh, its ssh), each waiting for the next; the watchdog and
283
+ # a closed window end all of it, not only npx, so nothing of the install goes on under the view (DEF-1818).
284
+ limit=600; tree=1
285
+ run env PATH="$(dirname "$npx_path"):$PATH" "$npx_path" --yes "@vibelord/lanes@$su_to" "$@" || :
286
+ limit= tree=
287
+ drop_claim
288
+ # install.sh: 0 ready · 3 and 4 installed, but the key or the server is not ready (it said so) · anything else:
289
+ # npm, the wrapper or the installer failed, or the watchdog ended them, and this client's own files may still be
290
+ # the old ones. The remedy is this release (npm has it): `lanes-launch update` would install @latest instead.
291
+ case "$rc" in
292
+ 0|3|4) ;;
293
+ *) say_dim "the update to $su_to did not finish (exit $rc) — this window goes on with $release; windows try again in 6 h, or now: npx @vibelord/lanes@$su_to"; return 0 ;;
294
+ esac
295
+ log "self-update: installed $su_to (exit $rc); starting it"
296
+ eval "set -- $argv"
297
+ LANES_AUTOUPDATED=1; export LANES_AUTOUPDATED
298
+ # (the installed copy is executable; a copy run as `sh <file>` may not be)
299
+ if [ -x "$0" ]; then exec "$0" "$@"; fi
300
+ exec /bin/sh "$0" "$@"
301
+ }
302
+
142
303
  while_pid=
143
304
  service=
144
305
  extra=
@@ -154,12 +315,20 @@ case "${1:-}" in
154
315
  esac
155
316
  done ;;
156
317
  ls) exec ssh -o RequestTTY=no "$host" "$remote ls" ;;
318
+ here)
319
+ # `lanes here <device>` on the server: one connection without a terminal (it opens no view; the device's running
320
+ # window follows). The name is spliced into the remote command line, so it is checked before any connection.
321
+ [ $# -le 2 ] || { echo 'usage: lanes-launch here [device]' >&2; exit 64; }
322
+ here_device=${2:-$(default_device)}
323
+ case "$here_device" in ''|-*|*[!A-Za-z0-9_.-]*) echo "invalid name: $here_device" >&2; exit 64 ;; esac
324
+ exec ssh -o RequestTTY=no "$host" "$remote here $here_device" ;;
157
325
  web) shift; web_view "$@" ;;
158
326
  update) shift; update_client "$@" ;;
159
327
  lane)
160
328
  [ -n "${2:-}" ] || { echo 'usage: lanes-launch lane <lane-id>' >&2; exit 64; }
161
329
  mode=open target=$2 ;;
162
- -h|--help) sed -n '2,18p' "$0"; exit 0 ;;
330
+ -h|--help) sed -n '1d;/^#/!q;p' "$0"; exit 0 ;; # the comment header, however long it grows
331
+ --version) cv=$(client_version); printf 'lanes-launch %s\n' "${cv:-dev}"; exit 0 ;;
163
332
  *) mode=attach target=${1:-$(default_device)} ;;
164
333
  esac
165
334
  if [ "$mode" = attach ] && [ $# -gt 1 ]; then
@@ -172,7 +341,13 @@ if [ "$mode" = attach ] && [ $# -gt 1 ]; then
172
341
  --ascii) extra="$extra --ascii" ;;
173
342
  --theme) case "${2:-}" in dark|light|auto) extra="$extra --theme $2"; shift ;; *) echo '--theme takes dark, light or auto' >&2; exit 64 ;; esac ;;
174
343
  --glyphs) case "${2:-}" in unicode|ascii|emoji) extra="$extra --glyphs $2"; shift ;; *) echo '--glyphs takes unicode, ascii or emoji' >&2; exit 64 ;; esac ;;
175
- --color) case "${2:-}" in truecolor|16|none) extra="$extra --color $2"; shift ;; *) echo '--color takes truecolor, 16 or none' >&2; exit 64 ;; esac ;;
344
+ --color) case "${2:-}" in truecolor|256|16|none) extra="$extra --color $2"; shift ;; *) echo '--color takes truecolor, 256, 16 or none' >&2; exit 64 ;; esac ;;
345
+ --notify) case "${2:-}" in 1|0) extra="$extra --notify $2"; shift ;; *) echo '--notify takes 1 or 0' >&2; exit 64 ;; esac ;;
346
+ --dim) case "${2:-}" in 90|2) extra="$extra --dim $2"; shift ;; *) echo '--dim takes 90 or 2' >&2; exit 64 ;; esac ;;
347
+ --device-only) extra="$extra --device-only" ;;
348
+ # Quick edit's editor (Ctrl-Alt-e, W9 E2): the server's own five values. A server older than `lanes edit`
349
+ # takes it as an unknown flag and records nothing, which costs nothing there: it has no quick edit.
350
+ --editor) case "${2:-}" in micro|hx|nvim|vim|auto) extra="$extra --editor $2"; shift ;; *) echo '--editor takes micro, hx, nvim, vim or auto' >&2; exit 64 ;; esac ;;
176
351
  *) echo "lanes-launch: ignoring unknown option $1" >&2 ;;
177
352
  esac
178
353
  shift
@@ -182,9 +357,11 @@ fi
182
357
  case "$target" in ''|-*|*[!A-Za-z0-9_.-]*) echo "invalid name: $target" >&2; exit 64 ;; esac
183
358
  client="$(hostname -s 2>/dev/null || echo client)-$$"
184
359
  client=$(printf '%s' "$client" | tr -c 'A-Za-z0-9_.-' '-')
185
- # Told to `lanes attach`, which says in one line when this client is older than the server (`lanes-launch update`).
186
- version=$(client_version)
187
- [ -z "$version" ] || version=" --client-version $version"
360
+ # Told to `lanes attach`, which says in one line when this client is older than the server (`lanes-launch update`),
361
+ # and shown in the connecting line below.
362
+ release=$(client_version)
363
+ version=
364
+ [ -z "$release" ] || version=" --client-version $release"
188
365
 
189
366
  # ---- log: what a window that showed nothing never left behind ------------------------------------------------------
190
367
  # One line per connection event, rotated to .1 at 256 KiB. The service writes to stderr instead: launchd / journald
@@ -208,14 +385,21 @@ reader=
208
385
  holding=
209
386
  now=
210
387
  limit=
388
+ tree= # set: a timeout or a signal ends the child's whole process tree (kill_tree), not only the child
211
389
  helper_pid=
212
390
  stop() {
213
391
  trap - HUP TERM INT
214
392
  exec 2>/dev/null # bash reports every child it sees die of a signal: noise in a terminal that stays open
215
- if [ -n "$child" ]; then kill -TERM "$child" 2>/dev/null || :; fi
393
+ if [ -n "$child" ] && [ -n "$tree" ]; then
394
+ # (no second signal may cut the walk short: it stops each process before it ends them all)
395
+ trap '' HUP TERM INT; kill_tree "$child"
396
+ elif [ -n "$child" ]; then
397
+ kill -TERM "$child" 2>/dev/null || :
398
+ fi
216
399
  cancel_watchdog
217
400
  if [ -n "$reader" ]; then kill -TERM "$reader" 2>/dev/null || :; fi
218
401
  end_helper
402
+ drop_claim
219
403
  log 'stopped: window closed, or a signal'
220
404
  exit 0
221
405
  }
@@ -267,6 +451,14 @@ run() {
267
451
  wait "$timer" 2>/dev/null || :
268
452
  if [ -n "$cancelled" ]; then
269
453
  wait "$timer" 2>/dev/null || :
454
+ elif [ -n "$tree" ]; then
455
+ # (the timer has ended and is reaped: a cancellation during the walk must not signal its old pid)
456
+ timer=
457
+ # Nor run the trap: bash that runs a trap between a pipeline's first and last command never starts the
458
+ # last one and waits for the first, whose output then fills a pipe bash itself holds — for ever
459
+ # (DEF-1791). run() cancels as soon as the child ends, mid-walk; there is nothing left to cancel.
460
+ trap '' HUP TERM INT
461
+ kill_tree "$child"
270
462
  else
271
463
  kill -TERM "$child" 2>/dev/null || :
272
464
  fi
@@ -279,6 +471,61 @@ run() {
279
471
  cancel_watchdog
280
472
  return "$rc"
281
473
  }
474
+ # ---- process trees: an installer is more than one process --------------------------------------------------------
475
+ # `npx` runs npm, npm a shell, that shell node (the npm wrapper), node install.sh, install.sh ssh — each waiting in the
476
+ # foreground for the next. Ending the first leaves the rest on this terminal: npm passes TERM to its own child only,
477
+ # node's spawnSync child outlives node, and a command this script starts in the background ignores Ctrl-C (POSIX, for
478
+ # a shell without job control). Its own process group is no way out either: a background group that reads the
479
+ # terminal is stopped (install.sh may ask something). So with $tree set, the watchdog and stop() end the whole tree.
480
+ # children <pid>…: the pids whose parent is one of them, from one `ps` listing (macOS and Linux print the same columns).
481
+ children() { ps -A -o pid= -o ppid= 2>/dev/null | awk -v of=" $* " 'index(of, " " $2 " ") { print $1 }'; }
482
+ # running <pid>…: whether any of them still runs (a zombie has ended; its new parent reaps it).
483
+ running() { ps -A -o pid= -o stat= 2>/dev/null | awk -v of=" $* " 'index(of, " " $1 " ") && $2 !~ /^Z/ { up = 1 } END { exit !up }'; }
484
+ # kill_tree <pid>: TERM to it and to every process under it. Each generation is stopped before its children are
485
+ # listed: a stopped process starts nothing, and no child can be re-parented out of reach (its parent ended) mid-walk.
486
+ # Then TERM and CONT for all, 2 s by the clock for TERM traps (install.sh puts the terminal back), KILL for what is
487
+ # left, and up to 2 s more by the clock until no listing shows it — so a caller goes on only once the whole install has
488
+ # ended. Its callers ignore HUP, TERM and INT first: no trap may run during its pipelines (bash, DEF-1791).
489
+ kill_tree() {
490
+ unset IFS
491
+ # Only from a child of this shell ($$ in the watchdog too): one that ended and was reaped may have left its pid to
492
+ # an unrelated process by now. No answer from ps (gone, or a ps without -p): the child alone, as before.
493
+ case "$(ps -o ppid= -p "$1" 2>/dev/null | tr -d ' ')" in
494
+ "$$") ;;
495
+ '') kill -TERM "$1" 2>/dev/null; return 0 ;;
496
+ *) return 0 ;;
497
+ esac
498
+ kt_all= kt_gen=$1 kt_n=0
499
+ while [ -n "$kt_gen" ] && [ "$kt_n" -lt 32 ]; do
500
+ kill -STOP $kt_gen 2>/dev/null
501
+ kt_all="$kt_all $kt_gen"
502
+ kt_gen=$(children $kt_gen)
503
+ kt_n=$((kt_n + 1))
504
+ done
505
+ kill -TERM $kt_all 2>/dev/null
506
+ kill -CONT $kt_all 2>/dev/null
507
+ # A timer, not a count of polls: each poll lists every process (MEASURED 0.18 s for 3335 processes at load 25),
508
+ # and 20 polls of `sleep .1` had made the 2 s 6 s (DEF-1790). The shell reaps the ended timer during its next
509
+ # command (the listing or the sleep; dash and bash alike), so kill -0 fails from then on.
510
+ sleep 2 >/dev/null 2>&1 &
511
+ kt_timer=$! kt_killed=
512
+ while running $kt_all; do
513
+ if ! kill -0 "$kt_timer" 2>/dev/null; then
514
+ # A KILLed process ends only once it runs again: once it gets a CPU, or once a disk wait it sleeps in
515
+ # uninterruptibly ends. So the walk goes on listing after the KILL, on a second 2 s timer (DEF-1776). What
516
+ # still runs after that is held in the kernel, and the caller goes on without it.
517
+ if [ -n "$kt_killed" ]; then kt_timer=; break; fi
518
+ kill -KILL $kt_all 2>/dev/null
519
+ kt_killed=1
520
+ sleep 2 >/dev/null 2>&1 &
521
+ kt_timer=$!
522
+ fi
523
+ sleep .1
524
+ done
525
+ # (the tree ended within a timer's 2 s: the timer, this shell's own child, still ran one poll ago)
526
+ if [ -n "$kt_timer" ]; then kill -KILL "$kt_timer" 2>/dev/null; wait "$kt_timer" 2>/dev/null; fi
527
+ return 0
528
+ }
282
529
  # hold <seconds> [jitter]: wait, but Enter continues at once. No raw mode, so there is no terminal state to restore
283
530
  # when the window closes mid-wait: a line reader signals this shell instead. End of input (no terminal) is no Enter.
284
531
  trap 'if [ -n "$holding" ]; then now=1; [ -z "$child" ] || { kill -TERM "$child"; wait "$child"; } 2>/dev/null; fi' USR1
@@ -450,6 +697,8 @@ if [ "$mode" = helper ]; then
450
697
  trap - HUP TERM INT
451
698
  upload_helper "$target"; exit 0
452
699
  fi
700
+ # A window (attach, or `lane <id>`) checks for a newer client first: a restart then leaves no helper of this one behind.
701
+ self_update
453
702
  if [ "$mode" = attach ]; then
454
703
  while_pid=$$ # ends the helper also when this client is killed without a chance to clean up
455
704
  upload_helper "$target" </dev/null >/dev/null 2>&1 &
@@ -458,8 +707,8 @@ fi
458
707
  while :; do
459
708
  printf '\033]0;lanes · %s\007' "$target"
460
709
  # Said before ssh draws anything: a server under pressure needs many seconds for banner and key exchange, and a
461
- # window that stays blank meanwhile reads as dead.
462
- printf '\033[2mlanes: connecting to %s…\033[0m\r\n' "$host"
710
+ # window that stays blank meanwhile reads as dead. It names this client's release (client_version's charset).
711
+ printf '\033[2mlanes%s: connecting to %s…\033[0m\r\n' "${release:+ $release}" "$host"
463
712
  log "connecting: $mode $target via $host"
464
713
  started=$(date +%s)
465
714
  if [ "$mode" = attach ]; then
@@ -1,9 +1,13 @@
1
1
  #!/bin/sh
2
- # Open the lanes window in its own terminal instance. usage: lanes-window [--ascii] [--light|--dark] [suffix]
2
+ # Open the lanes window in its own terminal instance.
3
+ # usage: lanes-window [--ascii] [--light|--dark] [--device-only] [suffix] (-h / --help: this text)
3
4
  # suffix: an independent view session "<device>-<suffix>" (second monitor / other half of an ultrawide).
4
5
  # w2 / w3: a WIDE view session with 2 / 3 viewports side by side per workspace (for a full-width window).
5
- # --ascii / --light / --dark: the cockpit's look for this device (`lanes attach … --ascii|--theme`), remembered on
6
- # the server in devices/<device>.json and picked up by a running sidebar and its status screens.
6
+ # --ascii / --light / --dark: the cockpit's look, remembered on the server as `lanes attach … --ascii|--theme` does.
7
+ # On a device with an owner (enrolled with an owner, a window of such a machine, or `lanes device claim`) it goes to
8
+ # that person's profile, shared by all their devices: every running sidebar of theirs paints it within a tick.
9
+ # --device-only keeps it in devices/<device>.json for this device alone, where it wins over the profile and a
10
+ # running sidebar and its status screens pick it up within a tick — as for every look on a device without an owner.
7
11
  # macOS: Ghostty. Linux: Ghostty when installed, otherwise Foot with the same window class.
8
12
  set -u
9
13
  config=${XDG_CONFIG_HOME:-$HOME/.config}/lanes
@@ -12,13 +16,21 @@ class=org.omarchy.lanes
12
16
  device=
13
17
  file=$config/ghostty.conf
14
18
  look=
19
+ only=
15
20
  while [ $# -gt 0 ]; do
16
21
  case "$1" in
22
+ -h|--help) sed -n '2,/^[^#]/{/^#/p;}' "$0"; exit 0 ;;
17
23
  --ascii) look="$look --ascii"; shift ;;
18
24
  --light|--dark) look="$look --theme ${1#--}"; shift ;;
25
+ --device-only) only=' --device-only'; shift ;;
19
26
  *) break ;;
20
27
  esac
21
28
  done
29
+ if [ -n "$only" ]; then
30
+ # Alone it would record nothing: say so instead of opening a window that looks like it did.
31
+ [ -n "$look" ] || { echo 'lanes-window: --device-only goes with --ascii, --light or --dark' >&2; exit 64; }
32
+ look="$look$only"
33
+ fi
22
34
  if [ -n "${1:-}" ]; then
23
35
  case "$1" in *[!A-Za-z0-9_.-]*) echo 'invalid suffix' >&2; exit 64 ;; esac
24
36
  device="$(head -n 1 "$config/device")-$1"
@@ -1,5 +1,6 @@
1
1
  {"hooks": {
2
2
  "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal working --event UserPromptSubmit --harness claude", "timeout": 2}]}],
3
+ "PreToolUse": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal working --event PreToolUse --harness claude", "timeout": 2}]}],
3
4
  "PostToolUse": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal working --event PostToolUse --harness claude", "timeout": 2}]}],
4
5
  "Stop": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal needs_input --event Stop --harness claude", "timeout": 2}]}],
5
6
  "Notification": [{"matcher": "permission_prompt", "hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal needs_approval --event Notification --harness claude", "timeout": 2}]},