@vibelord/lanes 1.0.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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/bin/lanes-install.mjs +158 -0
  4. package/lib/plan.mjs +65 -0
  5. package/package.json +37 -0
  6. package/payload/lanes/README.md +152 -0
  7. package/payload/lanes/VERSION +1 -0
  8. package/payload/lanes/bin/lanes +1035 -0
  9. package/payload/lanes/client/ghostty/common.conf +22 -0
  10. package/payload/lanes/client/ghostty/macos.conf +6 -0
  11. package/payload/lanes/client/ghostty/omarchy.conf +19 -0
  12. package/payload/lanes/client/install.sh +16 -0
  13. package/payload/lanes/client/lanes-launch +315 -0
  14. package/payload/lanes/client/lanes-upload +147 -0
  15. package/payload/lanes/client/lanes-window +36 -0
  16. package/payload/lanes/client/macos-helper.applescript +14 -0
  17. package/payload/lanes/client/omarchy/discover.sh +56 -0
  18. package/payload/lanes/client/omarchy/lanes.conf +5 -0
  19. package/payload/lanes/client/omarchy/lanes.desktop +9 -0
  20. package/payload/lanes/client/omarchy/lanes.lua +14 -0
  21. package/payload/lanes/client/ssh.stanza.in +54 -0
  22. package/payload/lanes/container/env.sh +16 -0
  23. package/payload/lanes/container/lane +268 -0
  24. package/payload/lanes/container/lane-run +34 -0
  25. package/payload/lanes/docs/ARCHITECTURE.md +93 -0
  26. package/payload/lanes/docs/HOST-GUARD.md +108 -0
  27. package/payload/lanes/docs/INSTALL.md +192 -0
  28. package/payload/lanes/docs/RUNBOOK.md +385 -0
  29. package/payload/lanes/docs/SHARING.md +124 -0
  30. package/payload/lanes/docs/ZMX-RING0.md +51 -0
  31. package/payload/lanes/docs/images/desktop.png +0 -0
  32. package/payload/lanes/docs/images/iphone.png +0 -0
  33. package/payload/lanes/holders/exec.sh +21 -0
  34. package/payload/lanes/holders/tmux.sh +111 -0
  35. package/payload/lanes/holders/zmx.sh +22 -0
  36. package/payload/lanes/holders.lock +5 -0
  37. package/payload/lanes/install/enable-phone.sh +43 -0
  38. package/payload/lanes/install/enable-web.sh +16 -0
  39. package/payload/lanes/install/host-install.sh +72 -0
  40. package/payload/lanes/install/services.py +61 -0
  41. package/payload/lanes/install.sh +603 -0
  42. package/payload/lanes/libexec/cursor_commit.py +159 -0
  43. package/payload/lanes/libexec/cursor_sync.py +166 -0
  44. package/payload/lanes/libexec/guard.py +231 -0
  45. package/payload/lanes/libexec/lanes_lib.py +610 -0
  46. package/payload/lanes/libexec/legacy_sessions.py +130 -0
  47. package/payload/lanes/libexec/session_actions.py +276 -0
  48. package/payload/lanes/libexec/sidebar +8 -0
  49. package/payload/lanes/libexec/sidebar.py +872 -0
  50. package/payload/lanes/libexec/statusd.py +764 -0
  51. package/payload/lanes/libexec/tmpscan.py +921 -0
  52. package/payload/lanes/libexec/view.sh +179 -0
  53. package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
  54. package/payload/lanes/site/README.md +28 -0
  55. package/payload/lanes/site/beyondwork/known_hosts +1 -0
  56. package/payload/lanes/site/beyondwork/site.conf +11 -0
  57. package/payload/lanes/site/site.example.conf +10 -0
  58. package/payload/lanes/sudo/host-guard.sh +356 -0
  59. package/payload/lanes/sudo/one-time-root.sh +32 -0
  60. package/payload/lanes/systemd/lanes-ensure.service +7 -0
  61. package/payload/lanes/systemd/lanes-ensure.timer +10 -0
  62. package/payload/lanes/systemd/lanes-gc.service +7 -0
  63. package/payload/lanes/systemd/lanes-gc.timer +9 -0
  64. package/payload/lanes/systemd/lanes-guard.service +8 -0
  65. package/payload/lanes/systemd/lanes-guard.timer +10 -0
  66. package/payload/lanes/systemd/lanes-mobile.service +23 -0
  67. package/payload/lanes/systemd/lanes-statusd.service +15 -0
  68. package/payload/lanes/systemd/lanes-sync.service +10 -0
  69. package/payload/lanes/systemd/lanes-sync.timer +10 -0
  70. package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
  71. package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
  72. package/payload/lanes/systemd/lanes-web.service +13 -0
  73. package/payload/lanes/systemd/lanes.slice +11 -0
  74. package/payload/lanes/web/Caddyfile +37 -0
  75. package/payload/lanes/web/README.md +35 -0
  76. package/payload/lanes/web/icon-192.png +0 -0
  77. package/payload/lanes/web/icon.svg +4 -0
  78. package/payload/lanes/web/index.html +30 -0
  79. package/payload/lanes/web/manifest.webmanifest +15 -0
  80. package/payload/lanes/web/mobile.css +301 -0
  81. package/payload/lanes/web/mobile.js +367 -0
  82. package/payload/lanes/workspaces.example/README.md +27 -0
  83. package/payload/lanes/workspaces.example/catalog.json +7 -0
  84. package/payload/lanes/workspaces.example/commit-targets.json +8 -0
  85. package/payload/lanes/workspaces.example/terminals.example.json +19 -0
  86. package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
  87. package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
  88. package/payload/lanes/zellij/config.kdl +360 -0
  89. package/payload/manifest.json +31 -0
@@ -0,0 +1,356 @@
1
+ #!/bin/sh
2
+ # The lanes HOST GUARD — the second and last privileged piece of the lanes server. Run by hand on the workspace host:
3
+ # sudo sh ./sudo/host-guard.sh read-only status + the plan of what --apply would do
4
+ # sudo sh ./sudo/host-guard.sh --apply memory envelope for the harness/login path + the 60 s OOM-score guard
5
+ # sudo sh ./sudo/host-guard.sh --undo recorded values back, guard removed
6
+ # Why root: MemoryLow on user.slice and below is PID 1's to set (a user manager cannot protect itself: its ancestors
7
+ # cap whatever it asks for), and LOWERING an oom_score_adj needs CAP_SYS_RESOURCE.
8
+ # It never restarts, stops or signals anything, never calls docker, never runs or opens anything inside a container.
9
+ # That is all it does, on purpose. The plan's DEFERRED measures (a smaller tmpfs /tmp, a container memory.high) are
10
+ # not code: docs/HOST-GUARD.md has them as by-hand procedures with their preconditions, next to the NEVER list.
11
+ set -eu
12
+ umask 022
13
+
14
+ # The three overrides exist for tests against a fake tree on a Mac; sudo's env_reset keeps them out of a real run.
15
+ P=${LANES_PROC_ROOT:-/proc}
16
+ CG=${LANES_CGROUP_ROOT:-/sys/fs/cgroup}
17
+ D=${LANES_HG_DESTDIR:-}
18
+ PREV=$D/var/lib/lanes-host-guard/prev
19
+ GUARD=$D/usr/local/sbin/lanes-oom-guard
20
+ UNITS=$D/etc/systemd/system
21
+ CONF=$D/etc/lanes-host-guard.conf
22
+
23
+ die() { echo "host-guard: $*" >&2; exit 1; }
24
+ usage() { sed -n '2,10s/^# \{0,1\}//p' "$0"; }
25
+
26
+ mode= guard_user=${SUDO_USER:-}
27
+ while [ "$#" -gt 0 ]; do
28
+ case "$1" in
29
+ --user) guard_user=${2:?--user ACCOUNT}; shift ;;
30
+ --apply|--undo) [ -z "$mode" ] || { usage >&2; exit 2; }; mode=$1 ;;
31
+ -h|--help) usage; echo 'Add --user ACCOUNT when not invoked by the workspace user through sudo.'; exit 0 ;;
32
+ *) usage >&2; exit 2 ;;
33
+ esac
34
+ shift
35
+ done
36
+ [ "$(id -u)" = 0 ] || die 'run with sudo'
37
+ case $guard_user in ''|root|*[!A-Za-z0-9._-]*) die 'specify the workspace account with --user ACCOUNT';; esac
38
+ user=$guard_user
39
+ [ -f "$CG/cgroup.controllers" ] || die "cgroup v2 is not mounted at $CG (a v1/hybrid host has no memory.low to set)"
40
+ uid=$(id -u "$user") || die "no such user: $user"
41
+ slice=$CG/user.slice/user-$uid.slice
42
+ [ -d "$slice" ] || die "$slice does not exist: $user has no session and no lingering user manager"
43
+ eval "guard_home=~$user"
44
+ STATE=${LANES_STATE:-$guard_home/.local/state/lanes}
45
+
46
+ # unit, the MemoryLow --apply gives it, its cgroup. Parent first: a child's protection is capped by every ancestor.
47
+ # 4G on the slices = the ~1 GiB harness + sshd sessions (session-N.scope sits beside user@, not under it) + headroom;
48
+ # 3G of that for the user manager, where statusd, the zellij servers and the viewers live.
49
+ units() {
50
+ echo "user.slice 4G $CG/user.slice"
51
+ echo "user-$uid.slice 4G $slice"
52
+ echo "user@$uid.service 3G $slice/user@$uid.service"
53
+ }
54
+
55
+ first_line() { v='?'; if [ -r "$1" ]; then read -r v < "$1" || :; fi; echo "$v"; }
56
+
57
+ gib() { # bytes -> "12.3 GiB"; cgroup files say "max" for "no limit"
58
+ case $1 in ''|*[!0-9]*) echo "${1:-?}";; *) echo "$(($1 / 1073741824)).$(($1 % 1073741824 * 10 / 1073741824)) GiB";; esac
59
+ }
60
+
61
+ docker_cgroup() { # $1 = pid -> its container's cgroup dir; fails unless that IS a docker container's cgroup
62
+ [ -r "$P/$1/cgroup" ] || return 1
63
+ while read -r line; do
64
+ p=${line#0::}
65
+ case $line in
66
+ 0::*/docker-*.scope*) p=${p%%.scope*}.scope;; # systemd driver; cut an in-container sub-cgroup off
67
+ 0::/docker/*) p=${p#/docker/}; p=/docker/${p%%/*};; # cgroupfs driver
68
+ *) continue;;
69
+ esac
70
+ if [ -d "$CG$p" ]; then echo "$CG$p"; return 0; fi
71
+ done < "$P/$1/cgroup"
72
+ return 1
73
+ }
74
+
75
+ # $1 = pid -> "fstype size_kib used_kib" of the container's /tmp: one statfs(2) through /proc/<pid>/root, no nsenter.
76
+ # GNU `stat -f` first, because GNU df insists on NAMING the mount, and a tmpfs that exists only in the container's
77
+ # mount namespace has no name out here (getcwd fails: "no file systems processed"). BSD stat has no -c: df it is.
78
+ tmp_usage() {
79
+ dir=$P/$1/root/tmp
80
+ set -- $(stat -f -c '%T %S %b %f' "$dir" 2>/dev/null) # type, block size, blocks, free blocks
81
+ case $#:${2:-}${3:-}${4:-} in
82
+ 4:*[!0-9]*) ;;
83
+ 4:*) echo "$1 $(($3 * $2 / 1024)) $((($3 - $4) * $2 / 1024))"; return 0;;
84
+ esac
85
+ df -Pk "$dir" 2>/dev/null | { read -r header; read -r fs size used rest && echo "$fs $size $used"; } || :
86
+ }
87
+
88
+ mem_stat() { # $1 = cgroup dir -> anon, shmem (bytes)
89
+ anon=0 shmem=0
90
+ [ -r "$1/memory.stat" ] || return 0
91
+ while read -r key val; do case $key in anon) anon=$val;; shmem) shmem=$val;; esac; done < "$1/memory.stat"
92
+ }
93
+
94
+ # ---------------------------------------------------------------------------------------------------- the 60 s guard
95
+ # One source for the installed script AND for the status summary (run from here with `sh -s status`), so what the
96
+ # status shows is by construction what the timer does.
97
+ guard_src() {
98
+ cat <<'GUARD'
99
+ #!/bin/sh
100
+ # lanes-oom-guard — installed by lanes/sudo/host-guard.sh --apply, which overwrites it: edit the source there.
101
+ # Root, every 60 s (lanes-oom-guard.timer). Goal: when a container hits its memory limit the kernel's OOM killer takes
102
+ # a build, never a lane. Pure /proc reads and writes; the only forks are one find+cat per cgroup tree, never one per
103
+ # pid, so it stays cheap on a host that is already thrashing.
104
+ # lanes-oom-guard protect (the timer)
105
+ # lanes-oom-guard status print "<class> <oom_score_adj>" per protected process, change nothing
106
+ # lanes-oom-guard reset put every mark of ours back to 0 (host-guard.sh --undo)
107
+ set -u
108
+ P=${LANES_PROC_ROOT:-/proc}
109
+ CG=${LANES_CGROUP_ROOT:-/sys/fs/cgroup}
110
+ CONF=${LANES_HOST_GUARD_CONF:-/etc/lanes-host-guard.conf}
111
+ : "${agents:=claude codex codex-code-mode}" "${uid:=1000}" "${state_dir:=}"
112
+ if [ -r "$CONF" ]; then . "$CONF"; fi
113
+ mode=${1:-apply}
114
+
115
+ # The policy is -900 (tmux servers, container init) and -500 (agents, zellij); the values written are one lower ON
116
+ # PURPOSE. oom_score_adj is inherited over fork: every pane shell a marked tmux server starts, and every build a
117
+ # marked agent starts, is born with the parent's mark — left alone, within days everything descended from a lane is
118
+ # as protected as the lane and the killer takes the Coder agent or sshd instead. So each pass also puts an inherited
119
+ # mark back to 0, and -901/-501 (values nobody else uses; docker's own is -500) are how it knows a mark is ours.
120
+ # Never -1000: a cgroup in which nothing can be killed turns an OOM into a hang.
121
+ SERVER=-901
122
+ AGENT=-501
123
+
124
+ ns_init() { # $1 = pid: pid 1 of its own pid namespace AND in a docker cgroup? The pid files are written by the
125
+ # unprivileged lanes server and can be stale; root must never act on a recycled pid.
126
+ [ -r "$P/$1/cgroup" ] && [ -r "$P/$1/status" ] || return 1
127
+ ok=
128
+ while read -r line; do case $line in 0::*/docker-*.scope*|0::/docker/*) ok=1;; esac; done < "$P/$1/cgroup"
129
+ [ -n "$ok" ] || return 1
130
+ while read -r key val; do
131
+ case $key in NSpid:) set -- $val; [ $# -ge 2 ] || return 1; shift $(($# - 1)); [ "$1" = 1 ]; return;; esac
132
+ done < "$P/$1/status"
133
+ return 1
134
+ }
135
+
136
+ inits=' '
137
+ if [ -n "$state_dir" ]; then
138
+ for f in "$state_dir"/containers/*.pid; do
139
+ [ -r "$f" ] || continue
140
+ read -r pid < "$f" || continue
141
+ case $pid in ''|0|*[!0-9]*) continue;; esac
142
+ if ns_init "$pid"; then inits="$inits$pid "; fi
143
+ done
144
+ fi
145
+
146
+ {
147
+ for pid in $inits; do echo "$pid init"; done
148
+ # Every docker container (either cgroup driver) + the whole user slice: the host-side zellij servers and any
149
+ # host tmux server live there, in scopes whose names vary. Classes are exact comm strings, so scanning wide
150
+ # touches nothing else. find, because a container may keep its processes in sub-cgroups.
151
+ for d in "$CG"/system.slice/docker-*.scope "$CG"/docker/* "$CG/user.slice/user-$uid.slice"; do
152
+ if [ -d "$d" ]; then find "$d" -name cgroup.procs -exec cat {} + 2>/dev/null; fi
153
+ done
154
+ } | {
155
+ nset=0 nreset=0
156
+ while read -r pid forced; do
157
+ case $pid in ''|*[!0-9]*) continue;; esac
158
+ if [ -z "$forced" ]; then case $inits in *" $pid "*) continue;; esac; fi # already handled as "init"
159
+ { read -r comm < "$P/$pid/comm" && read -r adj < "$P/$pid/oom_score_adj"; } 2>/dev/null || continue # gone
160
+ case $adj in ''|*[!0-9-]*) continue;; esac
161
+ if [ -n "$forced" ]; then cls=init want=$SERVER
162
+ else
163
+ cls=$comm
164
+ case $comm in
165
+ 'tmux: server') want=$SERVER;; # tmux names itself so (prctl); clients are 'tmux: client'
166
+ zellij) want=$AGENT;;
167
+ '') want=0;;
168
+ # Claude Code's native binary is a file NAMED after its version (…/claude/versions/2.1.274), so that
169
+ # is its comm. Measured on the host 2026-09-19: claude x33, 2.1.274 x16, codex x40, codex-code-mode x10.
170
+ *[!0-9.]*) case " $agents " in *" $comm "*) want=$AGENT;; *) want=0;; esac;;
171
+ [0-9]*.[0-9]*.[0-9]*) want=$AGENT;;
172
+ *) want=0;;
173
+ esac
174
+ fi
175
+ if [ "$mode" = status ]; then
176
+ if [ "$want" != 0 ]; then echo "$cls $adj"
177
+ else case $adj in "$SERVER"|"$AGENT") echo "(inherited-mark) $adj";; esac; fi
178
+ continue
179
+ fi
180
+ if [ "$mode" = reset ]; then want=0; fi
181
+ new=
182
+ case $adj in
183
+ "$want") ;;
184
+ "$SERVER"|"$AGENT") new=$want;; # our mark on the wrong process: inherited, or the class changed
185
+ *) if [ "$want" != 0 ] && [ "$adj" -gt "$want" ]; then new=$want; fi;; # never RAISE a foreign value
186
+ esac
187
+ [ -n "$new" ] || continue
188
+ if { echo "$new" > "$P/$pid/oom_score_adj"; } 2>/dev/null; then
189
+ if [ "$new" = 0 ]; then nreset=$((nreset + 1)); else nset=$((nset + 1)); fi
190
+ fi
191
+ done
192
+ # Quiet when nothing changed: 1440 identical journal lines a day help nobody.
193
+ if [ "$mode" != status ] && [ $((nset + nreset)) -gt 0 ]; then
194
+ echo "lanes-oom-guard: $nset protected, $nreset inherited marks cleared"
195
+ fi
196
+ }
197
+ GUARD
198
+ }
199
+
200
+ run_guard() { # before --apply there is no conf yet: uid and state dir go in through the environment, the conf wins
201
+ guard_src | LANES_PROC_ROOT=$P LANES_CGROUP_ROOT=$CG LANES_HOST_GUARD_CONF=$CONF uid=$uid state_dir=$STATE sh -s "$1"
202
+ }
203
+
204
+ # ------------------------------------------------------------------------------------------------------------ status
205
+ status() {
206
+ echo "lanes host guard — status (read-only). user $user, uid $uid"
207
+ echo
208
+ echo 'Memory envelope (MemoryLow):'
209
+ units | while read -r unit val dir; do
210
+ now=$(systemctl show -p MemoryLow --value "$unit" 2>/dev/null </dev/null) || now='?'
211
+ printf ' %-20s systemd %-12s memory.low %-12s --apply: %s\n' "$unit" "${now:-?}" "$(first_line "$dir/memory.low")" "$val"
212
+ done
213
+ recursive=no
214
+ if [ -r "$P/mounts" ]; then
215
+ while read -r dev mp fs opts rest; do
216
+ if [ "$fs" = cgroup2 ]; then case ",$opts," in *,memory_recursiveprot,*) recursive=yes;; esac; fi
217
+ done < "$P/mounts"
218
+ fi
219
+ echo " memory_recursiveprot: $recursive"
220
+ if [ "$recursive" = no ]; then
221
+ echo ' WARNING: without it a MemoryLow on these three units does not reach the leaf cgroups below them.'
222
+ fi
223
+
224
+ echo; echo 'Pressure (PSI):'
225
+ for r in memory io cpu; do
226
+ if [ -r "$P/pressure/$r" ]; then while read -r line; do printf ' %-7s %s\n' "$r" "$line"; done < "$P/pressure/$r"
227
+ else printf ' %-7s (no PSI in this kernel)\n' "$r"; fi
228
+ done
229
+
230
+ echo; echo "Containers (from $STATE/containers/*.pid):"
231
+ for f in "$STATE"/containers/*.pid; do
232
+ [ -r "$f" ] || continue
233
+ name=${f##*/}; name=${name%.pid}; pid=$(first_line "$f")
234
+ case $pid in ''|0|*[!0-9]*) echo " $name: not running"; continue;; esac
235
+ if ! cg=$(docker_cgroup "$pid"); then echo " $name: pid $pid is not in a docker cgroup (stale pid file)"; continue; fi
236
+ mem_stat "$cg"
237
+ kills='?'
238
+ if [ -r "$cg/memory.events" ]; then
239
+ while read -r key val; do case $key in oom_kill) kills=$val;; esac; done < "$cg/memory.events"
240
+ fi
241
+ echo " $name (pid $pid, ${cg##*/})"
242
+ echo " memory.current $(gib "$(first_line "$cg/memory.current")") of max $(gib "$(first_line "$cg/memory.max")")," \
243
+ "high $(gib "$(first_line "$cg/memory.high")"); anon $(gib "$anon"), shmem $(gib "$shmem")"
244
+ echo " swap.max $(gib "$(first_line "$cg/memory.swap.max")"), oom.group $(first_line "$cg/memory.oom.group"), oom_kill $kills"
245
+ set -- $(tmp_usage "$pid")
246
+ if [ $# -eq 3 ]; then
247
+ echo " /tmp ($1): $(gib $(($3 * 1024))) used of $(gib $(($2 * 1024)))"
248
+ else echo ' /tmp: could not statvfs'; fi
249
+ done
250
+
251
+ echo; echo 'oom_score_adj by class (count, class, value; ours are -901 / -501):'
252
+ run_guard status | sort | uniq -c | sed 's/^/ /'
253
+
254
+ echo
255
+ oomd=$(systemctl is-active systemd-oomd.service 2>/dev/null) || :
256
+ echo "systemd-oomd: ${oomd:-unknown}"
257
+ if [ "$oomd" = active ]; then
258
+ echo ' WARNING: check `oomctl` — it must never manage system.slice or a docker scope (it kills whole cgroups).'
259
+ fi
260
+ if [ -e "$UNITS/lanes-oom-guard.timer" ]; then
261
+ timer=$(systemctl is-active lanes-oom-guard.timer 2>/dev/null) || :
262
+ echo "guard timer: installed, ${timer:-unknown}"
263
+ else echo 'guard timer: not installed'; fi
264
+
265
+ cat <<PLAN
266
+
267
+ Plan — what --apply would do (this run changed nothing):
268
+ 1. record the current MemoryLow of the three units under $PREV/ (once; a second --apply keeps the first record)
269
+ 2. systemctl set-property user.slice MemoryLow=4G · user-$uid.slice MemoryLow=4G · user@$uid.service MemoryLow=3G
270
+ (live and persistent; no daemon-reload, no restart of anything)
271
+ 3. install $GUARD + lanes-oom-guard.service/.timer (every 60 s) and $CONF:
272
+ tmux servers and container inits -> -901, agents ($(if [ -r "$CONF" ]; then . "$CONF"; fi; echo "${agents:-claude codex}")) and zellij -> -501, inherited marks -> 0
273
+ --undo puts the recorded values back, clears the marks and removes the guard.
274
+ PLAN
275
+ }
276
+
277
+ # ------------------------------------------------------------------------------------------------------ apply / undo
278
+ apply() {
279
+ mkdir -p "$PREV" "${GUARD%/*}" "$UNITS"
280
+ units | while read -r unit val dir; do
281
+ # Record ONCE: a second --apply must not replace the real previous value with our own.
282
+ if [ ! -e "$PREV/$unit.MemoryLow" ]; then
283
+ now=$(systemctl show -p MemoryLow --value "$unit" </dev/null)
284
+ echo "${now:-0}" > "$PREV/$unit.MemoryLow"
285
+ fi
286
+ systemctl set-property "$unit" "MemoryLow=$val" </dev/null
287
+ echo "$unit: MemoryLow=$val (was $(first_line "$PREV/$unit.MemoryLow"))"
288
+ done
289
+
290
+ # Via a temp name + mv: the timer may exec the script at any moment, and root must never run half a file.
291
+ guard_src > "$GUARD.new"; chmod 755 "$GUARD.new"; mv "$GUARD.new" "$GUARD"
292
+ if [ ! -e "$CONF" ]; then # kept on re-apply: the agent list is the one thing meant to be edited
293
+ cat > "$CONF" <<CONF_EOF
294
+ # Read (sourced, sh syntax) by /usr/local/sbin/lanes-oom-guard. Written once by lanes/sudo/host-guard.sh --apply.
295
+ # agents: exact comm strings (cat /proc/<pid>/comm, max 15 chars, no spaces) of the agent processes to protect.
296
+ agents='claude codex'
297
+ uid=$uid
298
+ state_dir='$STATE'
299
+ CONF_EOF
300
+ fi
301
+ cat > "$UNITS/lanes-oom-guard.service" <<'UNIT'
302
+ [Unit]
303
+ Description=lanes: keep tmux servers, agents and container inits out of the OOM killer's first picks
304
+
305
+ [Service]
306
+ Type=oneshot
307
+ ExecStart=/usr/local/sbin/lanes-oom-guard
308
+ UNIT
309
+ cat > "$UNITS/lanes-oom-guard.timer" <<'UNIT'
310
+ [Unit]
311
+ Description=lanes: run lanes-oom-guard every 60 s
312
+
313
+ [Timer]
314
+ OnBootSec=60
315
+ OnUnitActiveSec=60
316
+ AccuracySec=5
317
+
318
+ [Install]
319
+ WantedBy=timers.target
320
+ UNIT
321
+ systemctl enable --now lanes-oom-guard.timer # `enable` reloads the manager itself: the new units are seen
322
+ systemctl start lanes-oom-guard.service # first pass now, so the verification below shows it
323
+ cat <<VERIFY
324
+
325
+ Applied. Verify:
326
+ sudo sh $0
327
+ memory.low reads 4294967296 / 4294967296 / 3221225472; tmux servers and inits at -901, agents and zellij at -501
328
+ systemctl list-timers lanes-oom-guard.timer; journalctl -u lanes-oom-guard -n 5
329
+ next run within 60 s; the journal says "N protected, M inherited marks cleared" only when something changed
330
+ as $user: lanes invariant check
331
+ no lane changed
332
+ Undo: sudo sh $0 --undo
333
+ VERIFY
334
+ }
335
+
336
+ undo() {
337
+ units | while read -r unit val dir; do
338
+ [ -e "$PREV/$unit.MemoryLow" ] || { echo "$unit: nothing recorded, left alone"; continue; }
339
+ was=$(first_line "$PREV/$unit.MemoryLow")
340
+ systemctl set-property "$unit" "MemoryLow=$was" </dev/null
341
+ rm -f "$PREV/$unit.MemoryLow"
342
+ echo "$unit: MemoryLow=$was restored"
343
+ done
344
+ systemctl disable --now lanes-oom-guard.timer 2>/dev/null || :
345
+ # Marks outlive the timer (and keep being inherited) unless they are cleared; ours are recognisable, so this is exact.
346
+ run_guard reset || :
347
+ rm -f "$GUARD" "$UNITS/lanes-oom-guard.service" "$UNITS/lanes-oom-guard.timer"
348
+ systemctl daemon-reload
349
+ echo "guard removed. $CONF is kept (your agent list); delete it by hand if you like."
350
+ }
351
+
352
+ case $mode in
353
+ '') status;;
354
+ --apply) apply;;
355
+ --undo) undo;;
356
+ esac
@@ -0,0 +1,32 @@
1
+ #!/bin/sh
2
+ # Optional server admin setup: grant Tailscale Serve to the workspace account.
3
+ # sudo sh sudo/one-time-root.sh [--user ACCOUNT] [--ssh-keepalive]
4
+ set -eu
5
+ user=${SUDO_USER:-} keepalive=0
6
+ while [ "$#" -gt 0 ]; do
7
+ case "$1" in
8
+ --user) user=${2:?--user ACCOUNT}; shift ;;
9
+ --ssh-keepalive) keepalive=1 ;;
10
+ -h|--help) sed -n '2,3s/^# //p' "$0"; exit 0 ;;
11
+ *) echo "unknown option: $1" >&2; exit 2 ;;
12
+ esac
13
+ shift
14
+ done
15
+ [ "$(id -u)" = 0 ] || { echo 'run with sudo' >&2; exit 1; }
16
+ case "$user" in ''|root|*[!A-Za-z0-9._-]*) echo 'specify the unprivileged workspace account with --user' >&2; exit 2 ;; esac
17
+ id "$user" >/dev/null
18
+ tailscale set --operator="$user"
19
+ if [ "$keepalive" = 1 ]; then
20
+ conf=/etc/ssh/sshd_config.d/10-lanes.conf
21
+ if [ ! -e "$conf" ]; then
22
+ printf '# lanes: reap dead interactive clients\nClientAliveInterval 15\nClientAliveCountMax 3\n' > "$conf"
23
+ if sshd -t; then
24
+ systemctl reload ssh 2>/dev/null || systemctl reload sshd
25
+ else
26
+ rm -f "$conf"
27
+ echo 'sshd rejected the keepalive snippet; removed it.' >&2
28
+ exit 1
29
+ fi
30
+ fi
31
+ fi
32
+ printf 'Next, as %s: sh ~/.local/share/lanes/current/install/enable-phone.sh --local\n' "$user"
@@ -0,0 +1,7 @@
1
+ [Unit]
2
+ Description=lanes: recreate any missing device view session (agents are never touched)
3
+
4
+ [Service]
5
+ Type=oneshot
6
+ ExecStart="@REPO@/bin/lanes" ensure
7
+ Slice=lanes.slice
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=lanes ensure every minute
3
+
4
+ [Timer]
5
+ OnBootSec=45s
6
+ OnUnitActiveSec=1min
7
+ AccuracySec=5s
8
+
9
+ [Install]
10
+ WantedBy=timers.target
@@ -0,0 +1,7 @@
1
+ [Unit]
2
+ Description=lanes housekeeping (zmx logs never rotate; stale dynamic resolve files)
3
+
4
+ [Service]
5
+ Type=oneshot
6
+ ExecStart="@REPO@/bin/lanes" gc
7
+ Slice=lanes.slice
@@ -0,0 +1,9 @@
1
+ [Unit]
2
+ Description=lanes housekeeping daily
3
+
4
+ [Timer]
5
+ OnBootSec=10min
6
+ OnUnitActiveSec=1d
7
+
8
+ [Install]
9
+ WantedBy=timers.target
@@ -0,0 +1,8 @@
1
+ [Unit]
2
+ Description=lanes: keep build processes the preferred OOM victims in the workspace containers (raise only, /proc writes)
3
+
4
+ [Service]
5
+ Type=oneshot
6
+ Slice=lanes.slice
7
+ Nice=10
8
+ ExecStart="@REPO@/bin/lanes" guard --once
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=lanes: run the OOM guard every minute (new builds start all the time; a raise costs a few /proc reads)
3
+
4
+ [Timer]
5
+ OnBootSec=90s
6
+ OnUnitActiveSec=60s
7
+ AccuracySec=10s
8
+
9
+ [Install]
10
+ WantedBy=timers.target
@@ -0,0 +1,23 @@
1
+ [Unit]
2
+ Description=lanes: responsive phone frontend on loopback
3
+ Requires=lanes-web.service
4
+ After=lanes-web.service
5
+
6
+ [Service]
7
+ ExecStart="@CADDY@" run --config "@REPO@/web/Caddyfile" --adapter caddyfile
8
+ ExecReload=/bin/kill -USR1 $MAINPID
9
+ RuntimeDirectory=lanes-mobile
10
+ Environment=XDG_CONFIG_HOME=%t/lanes-mobile/config
11
+ Environment=XDG_DATA_HOME=%t/lanes-mobile/data
12
+ Restart=on-failure
13
+ RestartSec=3
14
+ NoNewPrivileges=true
15
+ PrivateTmp=true
16
+ ProtectSystem=strict
17
+ ProtectHome=read-only
18
+ MemoryHigh=128M
19
+ MemoryMax=256M
20
+ Slice=lanes.slice
21
+
22
+ [Install]
23
+ WantedBy=default.target
@@ -0,0 +1,15 @@
1
+ [Unit]
2
+ Description=lanes status daemon (observes lanes, never touches them)
3
+
4
+ [Service]
5
+ ExecStart=/usr/bin/python3 -S -E "@REPO@/libexec/statusd.py"
6
+ Restart=always
7
+ RestartSec=2
8
+ Nice=5
9
+ MemoryLow=96M
10
+ MemoryHigh=192M
11
+ MemoryMax=384M
12
+ Slice=lanes.slice
13
+
14
+ [Install]
15
+ WantedBy=default.target
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=lanes registry sync (read-only: rebuild registry.json from the repos' terminal registries)
3
+
4
+ [Service]
5
+ Type=oneshot
6
+ ExecStart="@REPO@/bin/lanes" sync
7
+ # A momentarily invalid registry keeps the previous lanes; a non-zero exit here is informational.
8
+ SuccessExitStatus=1
9
+ Nice=5
10
+ Slice=lanes.slice
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=lanes registry sync every 2 minutes
3
+
4
+ [Timer]
5
+ OnBootSec=30s
6
+ OnUnitActiveSec=2min
7
+ AccuracySec=10s
8
+
9
+ [Install]
10
+ WantedBy=timers.target
@@ -0,0 +1,11 @@
1
+ [Unit]
2
+ Description=lanes: remove dead temp trees from the containers' RAM-backed /tmp (dry-run until flags/tmpreap.armed exists)
3
+
4
+ [Service]
5
+ Type=oneshot
6
+ ExecStart="@REPO@/bin/lanes" tmp reap
7
+ # A oneshot has no start timeout by default: a run stuck on a thrashing host must not block the next hour's.
8
+ TimeoutStartSec=30min
9
+ Nice=19
10
+ IOSchedulingClass=idle
11
+ Slice=lanes.slice
@@ -0,0 +1,13 @@
1
+ [Unit]
2
+ Description=lanes tmp reap hourly
3
+
4
+ [Timer]
5
+ # Not enabled by install/host-install.sh: `systemctl --user enable --now lanes-tmpreap.timer`, by hand, after the first
6
+ # real `lanes tmp plan` / `apply`. OnActiveSec, not OnBootSec: a boot-relative time is already in the past on a
7
+ # running host, so enabling would start a walk at once.
8
+ OnActiveSec=1h
9
+ OnUnitActiveSec=1h
10
+ AccuracySec=1min
11
+
12
+ [Install]
13
+ WantedBy=timers.target
@@ -0,0 +1,13 @@
1
+ [Unit]
2
+ Description=lanes: zellij web server on loopback only (tailnet exposure is a separate, user-run Tailscale Serve step)
3
+
4
+ [Service]
5
+ ExecStart="@SHARE@/bin/zellij-0.45.1" --config-dir "@REPO@/zellij" --config "@REPO@/zellij/config.kdl" web --start --ip 127.0.0.1 --port 8082
6
+ Restart=always
7
+ RestartSec=5
8
+ MemoryHigh=512M
9
+ MemoryMax=1G
10
+ Slice=lanes.slice
11
+
12
+ [Install]
13
+ WantedBy=default.target
@@ -0,0 +1,11 @@
1
+ [Unit]
2
+ Description=lanes: the harness (services and view sessions). Tiny, but it must be scheduled while the host thrashes
3
+
4
+ [Slice]
5
+ # Weight against its siblings under the user manager (needs the cpu controller delegated to user@; ignored otherwise).
6
+ CPUWeight=10000
7
+ # Protection from reclaim for the harness's ~1 GiB working set. It only becomes effective once root has given the
8
+ # ancestors (user.slice -> user-<uid>.slice -> user@<uid>.service) a MemoryLow: sudo/host-guard.sh --apply. This host
9
+ # mounts cgroup2 WITHOUT memory_recursiveprot, so the leaves below ask explicitly as well (scopes: bin/lanes
10
+ # create_session; statusd: its unit file).
11
+ MemoryLow=2G
@@ -0,0 +1,37 @@
1
+ {
2
+ admin off
3
+ auto_https off
4
+ }
5
+
6
+ # The existing Zellij server owns authentication and both WebSocket channels.
7
+ # This loopback-only frontend adds mobile assets without rebuilding the pinned binary.
8
+ http://:8083 {
9
+ bind 127.0.0.1
10
+ root * "{$LANES_REPO}/web"
11
+ header {
12
+ X-Content-Type-Options nosniff
13
+ X-Frame-Options DENY
14
+ Referrer-Policy strict-origin-when-cross-origin
15
+ Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ws: wss:; img-src 'self' data:"
16
+ }
17
+ handle_path /lanes-assets/* {
18
+ header Cache-Control "no-cache"
19
+ file_server
20
+ }
21
+ @backend path /assets/* /ws/* /command/* /info/* /session /session-list
22
+ handle @backend {
23
+ reverse_proxy 127.0.0.1:8082
24
+ }
25
+ @page {
26
+ method GET HEAD
27
+ path_regexp session ^/([A-Za-z0-9_.-]+)?$
28
+ }
29
+ handle @page {
30
+ rewrite * /index.html
31
+ header Cache-Control "no-store"
32
+ file_server
33
+ }
34
+ handle {
35
+ reverse_proxy 127.0.0.1:8082
36
+ }
37
+ }
@@ -0,0 +1,35 @@
1
+ # Phone frontend
2
+
3
+ The Lanes shell enhances the pinned Zellij 0.45.1 web client on phones. It uses that client's existing
4
+ `window.__zjMobileUi`, `window.__zjSendControl`, `window.__zjSoftKbdCapture`, and `window.term` hooks.
5
+ There is no separate auth system or command API. Workspace names are rendered as text, and navigation
6
+ uses Zellij's authenticated `FocusPane` messages. Desktop browsers keep the upstream interface.
7
+
8
+ The phone keyboard opens through a native tap on an input field. The bottom **Type here** field contains
9
+ Zellij's original capture input in its closed shadow root, preserving its keyboard/IME handling. The client
10
+ blocks programmatic focus on text editors and keeps `__zjSoftKbdEnabled` false, so upstream touch handlers,
11
+ two-finger gestures, server `SetSoftKeyboard` messages and modal autofocus cannot summon it. Search and
12
+ login inputs still focus normally when tapped. Keep this policy in place when upgrading the upstream client.
13
+
14
+ `lanes-mobile.service` runs a separate, loopback-only Caddy on port 8083 with its admin API disabled.
15
+ It does not change the system Caddy. Tailscale Serve forwards private HTTPS on 8443 to this frontend;
16
+ the original Zellij service stays on 8082. Caddy must already be installed on the host.
17
+ Run `sh install/enable-phone.sh --host lanes` from an installed client, or `--local` on the server.
18
+ The [installation guide](../docs/INSTALL.md) covers Caddy, Tailscale operator permission and the selected SSH key.
19
+ Service registration supplies `LANES_REPO` so Caddy serves the actual checkout from any installation path.
20
+
21
+ `index.html` keeps the upstream asset integrity hashes. When upgrading Zellij, compare the HTML and
22
+ window hooks against the new version before changing the pinned binary. The mobile JS and CSS have
23
+ `no-cache` headers, and entry pages use `no-store`, so reopening the app picks up changes without
24
+ clearing login cookies. There is no service worker caching terminal data or credentials.
25
+
26
+ Layout uses the visual viewport and safe-area insets, with independent handling for portrait, landscape,
27
+ keyboard-open and Home Screen views. The default terminal font is 16 px; the user's text-size choice
28
+ is stored locally. Minimum touch targets are 44 px (workspace cards are 74 px). The lane picker in
29
+ `libexec/sidebar.py` uses three terminal rows per lane on mobile. `tests/mobile_view.py` verifies
30
+ the touch-to-lane path with isolated holders; sidebar unit tests cover the complete touch target.
31
+
32
+ Validate on Safari/WebKit at 440 × 763 (iPhone 17 Pro Max with browser chrome), 440 × 956 (Home Screen),
33
+ 838 × 390 (landscape Safari), and smaller phone widths. Check login, workspace search and switching,
34
+ lane selection, pane navigation, font controls, rotation and the visible viewport while the keyboard opens.
35
+ Use disposable view sessions and revoke any temporary test token afterward.
Binary file
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
2
+ <rect width="192" height="192" rx="42" fill="#101318"/>
3
+ <g fill="#b4e3bf"><rect x="44" y="44" width="104" height="24" rx="8"/><rect x="44" y="84" width="80" height="24" rx="8"/><rect x="44" y="124" width="56" height="24" rx="8"/></g>
4
+ </svg>