@vibelord/lanes 1.0.0 → 1.0.1

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 (34) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +56 -1
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +281 -104
  5. package/payload/lanes/client/lanes-launch +44 -2
  6. package/payload/lanes/container/env.sh +8 -0
  7. package/payload/lanes/container/lane +19 -2
  8. package/payload/lanes/docs/ARCHITECTURE.md +86 -1
  9. package/payload/lanes/docs/DESIGN.md +72 -0
  10. package/payload/lanes/docs/INSTALL.md +39 -0
  11. package/payload/lanes/docs/RUNBOOK.md +94 -1
  12. package/payload/lanes/holders/ssh.sh +18 -0
  13. package/payload/lanes/install/enable-phone.sh +15 -6
  14. package/payload/lanes/install/enable-web.sh +19 -6
  15. package/payload/lanes/libexec/agent_state.py +214 -0
  16. package/payload/lanes/libexec/connection.py +178 -0
  17. package/payload/lanes/libexec/cursor_sync.py +139 -6
  18. package/payload/lanes/libexec/device_sync.py +134 -0
  19. package/payload/lanes/libexec/lanes_lib.py +158 -5
  20. package/payload/lanes/libexec/registrations.py +148 -0
  21. package/payload/lanes/libexec/session_actions.py +47 -15
  22. package/payload/lanes/libexec/sidebar.py +372 -61
  23. package/payload/lanes/libexec/statusd.py +126 -33
  24. package/payload/lanes/libexec/tokens.py +279 -0
  25. package/payload/lanes/libexec/tokens.sh +11 -0
  26. package/payload/lanes/libexec/view.sh +9 -8
  27. package/payload/lanes/web/README.md +15 -0
  28. package/payload/lanes/web/browser.css +22 -0
  29. package/payload/lanes/web/browser.js +73 -0
  30. package/payload/lanes/web/mobile.css +65 -33
  31. package/payload/lanes/web/mobile.js +23 -5
  32. package/payload/lanes/workspaces.example/README.md +9 -3
  33. package/payload/lanes/zellij/config.kdl +61 -3
  34. package/payload/manifest.json +6 -3
@@ -67,6 +67,8 @@ log() {
67
67
  # Run ssh as an async child so HUP/TERM (window closed, logout) end it immediately; a foreground child
68
68
  # would defer the trap until ssh returned. Keep the terminal on fd 9: dash gives async jobs /dev/null.
69
69
  child=
70
+ dog=
71
+ dog_ready=
70
72
  reader=
71
73
  holding=
72
74
  now=
@@ -76,6 +78,7 @@ stop() {
76
78
  trap - HUP TERM INT
77
79
  exec 2>/dev/null # bash reports every child it sees die of a signal: noise in a terminal that stays open
78
80
  if [ -n "$child" ]; then kill -TERM "$child" 2>/dev/null || :; fi
81
+ cancel_watchdog
79
82
  if [ -n "$reader" ]; then kill -TERM "$reader" 2>/dev/null || :; fi
80
83
  end_helper
81
84
  log 'stopped: window closed, or a signal'
@@ -89,17 +92,56 @@ end_helper() {
89
92
  # INT too: in a plain terminal Ctrl-C is the way out, now that nothing else ends the client.
90
93
  trap stop HUP TERM INT
91
94
  # With $limit set, a watchdog ends a child that the server leaves hanging (macOS has no timeout(1)).
95
+ cancel_watchdog() {
96
+ if [ -n "$dog" ]; then
97
+ # A very fast probe may finish before the forked shell has installed
98
+ # its trap. Wait for that handshake before sending cancellation.
99
+ while [ ! -s "$dog_ready" ] && kill -0 "$dog" 2>/dev/null; do sleep .01; done
100
+ kill -TERM "$dog" 2>/dev/null || :
101
+ wait "$dog" 2>/dev/null || :
102
+ dog=
103
+ fi
104
+ if [ -n "$dog_ready" ]; then rm -f "$dog_ready"; dog_ready=; fi
105
+ }
92
106
  run() {
93
107
  exec 9<&0
94
108
  "$@" <&9 9<&- &
95
109
  child=$!
96
110
  exec 9<&-
97
111
  dog=
98
- if [ -n "$limit" ]; then ( sleep "$limit"; kill -TERM "$child" ) >/dev/null 2>&1 & dog=$!; fi
112
+ dog_ready=
113
+ if [ -n "$limit" ]; then
114
+ if ! dog_ready=$(mktemp "${TMPDIR:-/tmp}/lanes-watchdog.XXXXXXXX"); then
115
+ kill -TERM "$child" 2>/dev/null || :
116
+ wait "$child" 2>/dev/null || :
117
+ child=; rc=1; return "$rc"
118
+ fi
119
+ (
120
+ # A foreground sleep can defer the shell's TERM handler, and
121
+ # killing only that shell leaves its sleep behind. An async
122
+ # timer lets wait handle cancellation and reap the owned child.
123
+ timer=; cancelled=
124
+ # The timer has no cleanup. KILL also covers its fork-to-exec
125
+ # window, before sleep has acquired its default signal handlers.
126
+ trap 'cancelled=1; [ -z "$timer" ] || kill -KILL "$timer" 2>/dev/null || :' HUP TERM INT
127
+ sleep "$limit" &
128
+ timer=$!
129
+ printf 'ready\n' > "$dog_ready"
130
+ # TERM can arrive between spawning the timer and assigning $!.
131
+ if [ -n "$cancelled" ]; then kill -KILL "$timer" 2>/dev/null || :; fi
132
+ wait "$timer" 2>/dev/null || :
133
+ if [ -n "$cancelled" ]; then
134
+ wait "$timer" 2>/dev/null || :
135
+ else
136
+ kill -TERM "$child" 2>/dev/null || :
137
+ fi
138
+ ) >/dev/null 2>&1 &
139
+ dog=$!
140
+ fi
99
141
  rc=0
100
142
  wait "$child" || rc=$?
101
143
  child=
102
- if [ -n "$dog" ]; then kill -TERM "$dog" 2>/dev/null || :; wait "$dog" 2>/dev/null || :; fi
144
+ cancel_watchdog
103
145
  return "$rc"
104
146
  }
105
147
  # hold <seconds> [jitter]: wait, but Enter continues at once. No raw mode, so there is no terminal state to restore
@@ -8,6 +8,14 @@
8
8
  # * tmux is NOT affected: it honours TMUX_TMPDIR only, so lane sockets stay in /tmp/tmux-<uid>.
9
9
  # * Nothing is created here: no directory (or a read-only one) = no change. Opt out per shell or for good with
10
10
  # LANES_TMP_OPTOUT=1 (any value, even empty, counts). LANES_TMP_DIR names another directory.
11
+ #
12
+ # Lane identity is NOT set here (this file is shared by every shell in the container). A tmux lane's shell gets it
13
+ # ONLY from the `-e` flags of its create recipe (`lanes_lib.canonical_recipe`, `tmux new -e …`): LANE_ID
14
+ # (<ws>--<slug>), LANE_WS (<ws>), THROTTLE_TRACE and ANTHROPIC_CUSTOM_HEADERS ('x-throttle-trace: lanes/<ws>/<id>';
15
+ # trace charset [A-Za-z0-9._-/], at most 256). Never export them from a profile or a client: the client that starts
16
+ # a shared board socket's tmux server copies its env into the server's GLOBAL env, which every later session there
17
+ # without its own -e inherits (DEF-1422) — so `lane open` strips them. A running lane gains its identity only when it
18
+ # is next created by a recipe carrying -e; a legacy recipe without -e gives none (`lanes cursor-sync --identity`).
11
19
  if [ -z "${LANES_TMP_OPTOUT+set}" ] && [ -d "${LANES_TMP_DIR:-/home/coder/.tmp}" ] \
12
20
  && [ -w "${LANES_TMP_DIR:-/home/coder/.tmp}" ] && [ -x "${LANES_TMP_DIR:-/home/coder/.tmp}" ]; then
13
21
  TMPDIR=${LANES_TMP_DIR:-/home/coder/.tmp}
@@ -148,8 +148,22 @@ def registry_entry(lane):
148
148
  return lanes[lane]
149
149
 
150
150
 
151
+ IDENTITY = ('LANE_ID', 'LANE_WS', 'THROTTLE_TRACE') # + a lanes/ ANTHROPIC_CUSTOM_HEADERS (identity_free)
152
+
153
+
154
+ def identity_free(environ, drop=('TMUX',)):
155
+ """`environ` without any lane identity (lanes/docs/ARCHITECTURE.md "Lane identity"). Whatever tmux client STARTS
156
+ a socket's server hands its whole environment to the server's GLOBAL environment, and every later session on
157
+ that socket created without its own `-e` inherits it (MEASURED, tmux 3.4: DEF-1422). Board sockets are shared by
158
+ many lanes, so a lane identity in the env of anything that may start one would be stamped on unrelated lanes.
159
+ Identity therefore reaches a lane's shell ONLY through `-e` in its create recipe (lanes_lib.canonical_recipe).
160
+ An operator's own ANTHROPIC_CUSTOM_HEADERS that is not a lane trace is theirs and stays."""
161
+ return {k: v for k, v in environ.items() if k not in drop and k not in IDENTITY
162
+ and not (k == 'ANTHROPIC_CUSTOM_HEADERS' and v.startswith('x-throttle-trace: lanes/'))}
163
+
164
+
151
165
  def tmux_cmd(socket, *args):
152
- env = {k: v for k, v in os.environ.items() if k != 'TMUX'}
166
+ env = identity_free(os.environ)
153
167
  return subprocess.run(['tmux', '-L', socket, *args], capture_output=True, text=True, timeout=15, env=env)
154
168
 
155
169
 
@@ -159,7 +173,10 @@ def cmd_open(args):
159
173
  entry = registry_entry(args[0])
160
174
  if os.environ.get('TMUX') and '--force' not in args:
161
175
  sys.exit(f"lane open: already inside tmux — refusing to nest. The lane is: {json.dumps(entry.get('tmux') or entry.get('zmx'))}")
162
- env = {k: v for k, v in os.environ.items() if k not in ('TMUX', 'TMUX_PANE')}
176
+ # Nothing `lane open` execs carries a lane identity — neither this lane's nor one inherited from ANOTHER lane (an
177
+ # editor terminal started inside a lane): the recipe may be the first client on a shared board socket and would
178
+ # hand it to every later session there (DEF-1422). The canonical recipe's own `-e` flags give the identity.
179
+ env = identity_free(os.environ, drop=('TMUX', 'TMUX_PANE'))
163
180
  if entry.get('holder') == 'zmx':
164
181
  name = entry['zmx']['name']
165
182
  if alive(name):
@@ -8,15 +8,72 @@ terminal. Installing or closing a view does not end a lane.
8
8
 
9
9
  1. The deployment's `catalog.json` orders workspaces and selects their manifests.
10
10
  2. Each manifest identifies container names and repository `.vscode/terminals.json` paths.
11
- 3. `lanes sync` reads those files through the container's mounted home and emits the host registry,
11
+ 3. `lanes sync` reads those files through the container's mounted home, includes the site's optional
12
+ `registered-sessions.json`, and emits the host registry,
12
13
  exact socket/session resolve files, and the container's `.lz/registry.json` projection.
13
14
  4. The status daemon observes tmux sessions and publishes status/health snapshots. Failed or timed-out
14
15
  probes mean unknown; they do not prove a missing session. A bad terminal registry retains the last good board.
15
16
 
17
+ Beside the six-word `state` (never changed by it), each live tmux lane, declared or unregistered, may carry
18
+ an additive `agent{}` key: `{harness, status, name, session_id?, pid?, since, source}` with `status` one of
19
+ `working | needs_input | needs_approval | done | errored | shell | idle`. An absent key means no signal names
20
+ an agent there. `statusd` builds it with `agent_state.merge` from signals the harnesses already emit, read once
21
+ per container per cycle through `/proc/<container init pid>/root` without spawning anything. Precedence: a
22
+ hook sidecar (planned, not read yet) > a Claude `~/.claude/sessions/<pid>.json` naming the pane
23
+ `<session>:@<win>.%<pane>` (live pids only, checked in the container's pid namespace, recycled pids rejected
24
+ by `procStart`) > the Codex pane title (`[ ! ] Action Required` → `needs_approval`, braille spinner →
25
+ `working`, a task title → `idle`) > the window bell (an idle title plus a bell is `done`; an approval title
26
+ wins over the bell). A Codex title over a bare-shell foreground whose shell has no child process
27
+ (`pane_has_child`, read from `/proc/<pid>/task/*/children`) is `shell`: the harness ended and tmux kept its
28
+ title. Unknown liveness leaves that key unset and never demotes. The tmux columns this needs
29
+ (`window_bell_flag`, `window_id`, `pane_id`, `pane_title`) are appended to the listing format, the free-text
30
+ title last so a tab inside it cannot shift another column.
31
+ 5. A lane's `source` is the container's spelling of its `terminals.json`. Every host-side reader of that file
32
+ maps it to the container's home volume first (`statusd.registry_source_paths`, deduplicated by that host path:
33
+ one repository cloned in two containers is two files): the status daemon's hot-sync
34
+ starts one `lanes sync` per edit (at most one per 10 s), `lanes doctor` reports "registry up to date" from the
35
+ same host-side mtimes, and promoting an unregistered session writes the binding through the volume.
36
+
16
37
  `lanes cursor-sync` is a separate, explicit writer. Its default is a diff; `--write` takes backups and changes
17
38
  editor commands to `lane open <id>`. The original create recipe and exact tmux binding remain in the registry's
18
39
  `lanes` block. Automatic sync never commits or pushes those edits. `cursor-commit --push` is an opt-in workflow.
19
40
 
41
+ Lane identity is an environment contract: `LANE_ID` (`<ws>--<slug>`), `LANE_WS` (`<ws>`), and the Throttle trace
42
+ `lanes/<ws>/<id>` as `THROTTLE_TRACE` and as `ANTHROPIC_CUSTOM_HEADERS='x-throttle-trace: lanes/<ws>/<id>'` (Claude
43
+ Code sends it on every request; the proxy's trace charset is `[A-Za-z0-9._-/]`, at most 256 characters, and a
44
+ trace outside it is never emitted). A tmux lane gets it in exactly one way: the `-e` flags of
45
+ `lanes_lib.canonical_recipe()` — `tmux -L <socket> new -ADs <session> -c <folder> -e … <cmd>` — which set the
46
+ SESSION environment of that one lane on a real create (`-A` ignores them on an attach). Never through `tmux
47
+ set-environment` and never through a client's environment, because of a tmux rule MEASURED on tmux 3.4 (the
48
+ container's version) with a throwaway socket: the client that STARTS a socket's server hands its whole environment
49
+ to the server's GLOBAL environment (`show-environment -g`), and every later session on that socket inherits it
50
+ unless its own `-e` overrides each key — a session created by a client carrying `LANE_ID=b` but no `-e` still got
51
+ the server starter's `LANE_ID=a`, and one created with `-e LANE_ID=c` alone kept `a`'s `THROTTLE_TRACE`. Board
52
+ sockets are shared (9 of the 10 bw-tools fixture lanes are on one socket), so `lane open` execs nothing — attach or
53
+ recipe — that carries a lane identity, this lane's or one inherited from another lane's editor terminal (DEF-1422);
54
+ a recipe run by it may start the server with an identity-free global environment only. An attach cannot change a
55
+ running session's env, so a running lane gains its identity only when it is next CREATED by a recipe carrying `-e`.
56
+ A legacy recipe without `-e` gives its lane no identity. `lanes cursor-sync --canonicalize` (dry-run unless
57
+ `--write`) gives every tmux lane whose recorded `create` cannot create it — null, attach-only (`attach-session`
58
+ without `new`), or naming an absolute `.sh`/`.py`/… script missing in the container — the canonical recipe with
59
+ `bash -l`; `--identity` gives a WORKING recipe of the plain shape `[exec] tmux -L <socket> new -ADs <session> [-c
60
+ <dir>] <command…>` the same `-e` flags, re-quoting the command's words one for one so tmux receives the same argv
61
+ (any expansion, glob, operator or other shape is left alone and stays identity-less). Both keep the old value as
62
+ `createPrevious`; a recipe whose paths cannot be checked (outside the home volume) is never counted as broken.
63
+ zmx lanes get `LANE_ID` (only) from `lane-run`, unchanged.
64
+
65
+ The trace is also a FAIRNESS key, not only an attribution key: the proxy uses `x-throttle-trace` when present and
66
+ synthesizes `cc-session/<session>[/<agent>]` only when it is absent (`src/proxy.rs`, `synthesize_flow_key`), and the
67
+ pool's parking queue is round-robin across trace keys at slot handoff (`src/pressure.rs`, `acquire_fair`). A lane
68
+ stamped `lanes/<ws>/<id>` therefore puts its main session, every subagent and any workflow fan-out started in it
69
+ into ONE fairness bucket where today each Claude session/agent is its own. `max_in_flight_per_trace` is configured
70
+ and reported but no enforcement site exists in `src/` (MEASURED by search; `pressure.rs` documents fairness as "not a
71
+ hard cap"). Whether a lane should be one bucket is an open decision for the plan owner before any live enablement
72
+ (DEF-1423); this tree does not change the header shape. Unit-proven only (`test_lib_probe` `LaneOpenIdentity`,
73
+ `CursorSyncCanonicalize`, `CursorSyncIdentity`, stopping at the exec boundary); the disposable-lane
74
+ `tests/cursor_lane.py` exercises the installed `/home/coder/.lz/bin/lane`, so it proves this only after `lanes
75
+ install-container`.
76
+
20
77
  ## Views and panes
21
78
 
22
79
  `lanes attach DEVICE` creates or reattaches the device's Zellij view. Layouts have one tab per workspace,
@@ -39,6 +96,19 @@ manifest `discoveryRoots` extends those roots. A discovered assignment remains s
39
96
  a session no single board can claim is listed by EVERY owner of its socket rather than dropped, and ambiguity is
40
97
  logged rather than resolved by guess.
41
98
 
99
+ **+** beside an unregistered tmux session opens **Add to workspace**. It offers workspaces using the session's
100
+ container, with the current workspace first. Registration verifies the observed session ID/creation time and
101
+ saves its existing lane ID, name and exact binding in the version-1 `registered-sessions.json` site file.
102
+ It publishes the registered lane immediately without signaling viewers or processes. Sync, registration and
103
+ explicit close share a bounded registry lock; close also consults saved registrations if projection publication
104
+ failed. Stale observations cannot put a registered session back in Unregistered.
105
+
106
+ These registrations have no create recipe and do not change repository terminal lists. Repository declarations
107
+ win if they later declare the same binding. Unknown versions or malformed registration files refuse mutation;
108
+ registrations for removed workspaces are retained on disk and ignored until the workspace returns. Removing a
109
+ record and syncing unregisters it, including when a repository registry is temporarily invalid. Keep this site
110
+ file in operator backups; deleting derived state does not delete registrations.
111
+
42
112
  Explicit **New +** starts a detached tmux shell inside the workspace container on its dedicated
43
113
  `lanes-shells-<workspace>` socket. Reopening only attaches. Explicit **×** closes an unregistered session after
44
114
  rechecking its session ID and creation time and refusing registered lanes; affected viewers become empty.
@@ -77,6 +147,21 @@ The phone shell uses the pinned client's window hooks for workspaces, panes, inp
77
147
  swipe, refresh or server message cannot summon the keyboard. Native taps on input fields still work.
78
148
  Safe-area insets and the visual viewport handle the Home indicator, rotation and keyboard.
79
149
 
150
+ `web/mobile.js` chooses one of three modes once at load, by capability (`laneShellMode`), and records it on
151
+ `<html data-lanes-shell>`. A **phone** (mobile user agent, or a touch screen at most 600 px wide or 500 px tall)
152
+ gets the shell above unchanged. A **tablet** (any other touch screen) gets the same shell with its dock in the top
153
+ bar and the workspace sheet as a left panel. It also asks the pinned client for its mobile UI, because iPad Safari
154
+ reports a desktop user agent. That UI opens in single pane on every load; `web/browser.js` then sends the client's
155
+ own full-view preference once, so a desktop layout's sidebar shows beside the lanes. A **desktop** browser (fine
156
+ pointer, any width) keeps the plain client. Only a **Lanes** chip is added; it toggles an overlay bar with
157
+ workspace and lane pickers. The bar reads the `MobileState` that Zellij sends to every web client and sends the
158
+ phone picker's `FocusPane`. It overlays the terminal, so the terminal's columns and rows do not change, and a
159
+ click on the chip leaves the keyboard focus in the terminal. The tablet and desktop code and styles live in
160
+ `web/browser.js` and `web/browser.css`, which `mobile.js` appends only in those two modes, so a phone never
161
+ downloads them and the phone's `mobile.js`/`mobile.css` budget (spec §8) stays with the phone. `lanes web <device> [--desktop|--mobile]`
162
+ prepares a browser view, enables the services through `install/enable-web.sh` and prints the private URL; without
163
+ a flag a known device keeps its layout.
164
+
80
165
  ## Installed files and services
81
166
 
82
167
  `install/services.py` renders the service templates against the actual checkout and installed binary paths.
@@ -0,0 +1,72 @@
1
+ # Lanes design system
2
+
3
+ The operator-facing legend for the Lanes cockpit. The tables below are generated from `libexec/tokens.py`
4
+ (`python3 libexec/tokens.py --write`; `--check` fails on drift); the design of record is
5
+ `docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md` at the repository root. Prose, modes, row anatomy and
6
+ mockups land with the docs task of the cockpit v2 plan.
7
+
8
+ <!-- BEGIN lanes tokens (generated by libexec/tokens.py --md; do not edit by hand) -->
9
+ | Role | Dark | Light | ANSI-16 | CSS variable |
10
+ |---|---|---|---|---|
11
+ | surface | `#101318` | `#f3f5f2` | `—` | `--lanes-surface` |
12
+ | surface-raised | `#191e25` | `#ffffff` | `—` | `--lanes-surface-raised` |
13
+ | line | `#2c333d` | `#d5ded6` | `—` | `--lanes-line` |
14
+ | accent-wash | `#1a2721` | `#e5f0e6` | `—` | `--lanes-accent-wash` |
15
+ | accent-line | `#4b6955` | `#acc9b4` | `—` | `--lanes-accent-line` |
16
+ | press | `#26332c` | `#d8e9db` | `—` | `--lanes-press` |
17
+ | chip | `#ffffff0f` | `#edf3ec` | `—` | `--lanes-chip` |
18
+ | ink | `#edf1f5` | `#17241b` | `39` | `--lanes-ink` |
19
+ | ink-dim | `#9ca7b5` | `#56616d` | `90` | `--lanes-ink-dim` |
20
+ | ink-faint | `#6b7683` | `#7f8a96` | `90` | `--lanes-ink-faint` |
21
+ | accent | `#b4e3bf` | `#2f6b45` | `1;39` | `--lanes-accent` |
22
+ | on-accent | `#101318` | `#ffffff` | `—` | `--lanes-on-accent` |
23
+ | on-accent-urgent | `#7a1a12` | `#ffd3cc` | `—` | `--lanes-on-accent-urgent` |
24
+ | tone-urgent | `#f28b7d` | `#b7322a` | `31` | `--lanes-tone-urgent` |
25
+ | tone-attention | `#e5b84a` | `#8a5a00` | `33` | `--lanes-tone-attention` |
26
+ | tone-working | `#7fb3f2` | `#2456a8` | `34` | `--lanes-tone-working` |
27
+ | tone-done | `#6fd39a` | `#1b7142` | `32` | `--lanes-tone-done` |
28
+ | scrim | `#000000a6` | `#1a222b80` | `—` | `--lanes-scrim` |
29
+ | shadow | `0 -16px 80px #0007` | `0 -16px 80px #0003` | `—` | `--lanes-shadow` |
30
+
31
+ | Glyph | Codepoint | ASCII | Tone | Meaning |
32
+ |---|---|---|---|---|
33
+ | `◆` | U+25C6 | `!` | tone-urgent | agent `needs_approval` |
34
+ | `◇` | U+25C7 | `?` | tone-attention | agent `needs_input` |
35
+ | `▸` | U+25B8 | `>` | tone-working | agent `working` |
36
+ | `✓` | U+2713 | `v` | tone-done | agent `done` |
37
+ | `✗` | U+2717 | `X` | tone-urgent | agent `errored` |
38
+ | `_` | U+005F | `_` | ink-dim | agent `shell` |
39
+ | `·` | U+00B7 | `.` | ink-dim | agent `idle` |
40
+ | `●` | U+25CF | `*` | tone-done | stream `active` |
41
+ | `○` | U+25CB | `o` | ink-dim | stream `quiet` |
42
+ | `✗` | U+2717 | `x` | tone-urgent | stream `missing` |
43
+ | `–` | U+2013 | `-` | ink-dim | stream `absent` |
44
+ | `!` | U+0021 | `!` | tone-urgent | stream `dead` |
45
+ | `·` | U+00B7 | `.` | ink-dim | stream `helper` |
46
+ | `?` | U+003F | `?` | ink-dim | stream `unknown` |
47
+ | `▌` | U+258C | `\|` | accent | mark `cursor` |
48
+ | `›` | U+203A | `=` | accent | mark `viewing` |
49
+ | `²` | U+00B2 | `2` | accent | mark `viewport-b` |
50
+ | `³` | U+00B3 | `3` | accent | mark `viewport-c` |
51
+ | `·` | U+00B7 | `.` | ink-dim | mark `viewer` |
52
+ | `…` | U+2026 | `~` | ink | mark `truncation` |
53
+ | `×` | U+00D7 | `x` | tone-urgent | mark `close` |
54
+ | `+` | U+002B | `+` | tone-done | mark `new` |
55
+ | `─` | U+2500 | `-` | ink-faint | mark `rule` |
56
+ | `⋮` | U+22EE | `:` | ink-dim | mark `more` |
57
+ | `⚠` | U+26A0 | `!` | tone-attention | mark `warning` |
58
+ | `↑` | U+2191 | `^` | ink-dim | mark `upload` |
59
+ | `↓` | U+2193 | `v` | tone-done | mark `armed` |
60
+ | `⏎` | U+23CE | `Enter` | ink-dim | mark `enter` |
61
+ | `█` | U+2588 | `_` | tone-working | mark `caret` |
62
+ | `?` | U+003F | `?` | ink-dim | mark `stale` |
63
+ | `›` | U+203A | `>` | ink | mark `lead` |
64
+ | `·` | U+00B7 | `-` | ink-dim | mark `sep` |
65
+ | `‘` | U+2018 | `'` | ink | mark `quote-open` |
66
+ | `’` | U+2019 | `'` | ink | mark `quote-close` |
67
+ | `✓` | U+2713 | `OK` | tone-done | mark `ok` |
68
+ | `✗` | U+2717 | `X` | tone-urgent | mark `fail` |
69
+ | `⚠` | U+26A0 | `!` | tone-attention | economy `starving` |
70
+ | `↓` | U+2193 | `v` | tone-attention | economy `downgraded` |
71
+ | `$` | U+0024 | `$` | ink-dim | economy `paid` |
72
+ <!-- END lanes tokens -->
@@ -155,6 +155,45 @@ Connect Tailscale on the phone, open the printed URL in Safari, paste only the t
155
155
  `sh install/enable-web.sh` starts only the loopback services and prints no token. The frontend and services
156
156
  run on the workspace host; a Mac does not need to stay awake for the phone to work.
157
157
 
158
+ ### Laptop and tablet browser
159
+
160
+ The same private frontend serves every browser and chooses its controls once, when the page loads:
161
+
162
+ - **Laptop or desktop** (mouse or trackpad): the plain Zellij web client, unchanged, plus a small **Lanes**
163
+ chip in the top-right corner. The chip opens a thin overlay bar with a workspace picker and a lane picker;
164
+ the terminal keeps its size and all of its keys.
165
+ - **Tablet** (touch screen wider than 600 px): the phone controls arranged for the larger screen — Workspaces,
166
+ Lanes, Panes and **Type here** in the top bar, and the workspace list as a left panel. The terminal shows the
167
+ whole tab, so a desktop view's lane sidebar sits beside its lanes (**More → Single pane** shows one pane).
168
+ - **Phone**: the phone shell described above.
169
+
170
+ Give each browser its own view. On the server, one command prepares it, enables the loopback web services and
171
+ prints its private URL:
172
+
173
+ ```sh
174
+ lanes web ipad --desktop # desktop sidebar layout (a new device's default); --mobile: phone lane pickers
175
+ lanes web ipad --desktop --token # the same, then a fresh login token in this terminal
176
+ lanes web ipad --print-only # shows the exact prepare call and URL; changes nothing
177
+ ```
178
+
179
+ From an installed client, `sh install/enable-phone.sh --host lanes --desktop ipad` does the same over the
180
+ selected SSH key; without a name, `--desktop` uses the device `browser`. The server needs this version of Lanes
181
+ for `--desktop`. A device keeps the layout it was created with: `lanes web <device>` without a flag reuses it
182
+ (so `lanes web phone` reuses the phone's view), and an explicit request for the other layout is refused; choose
183
+ another device name. `lanes web` reads the node's Tailscale name. Tailscale Serve itself is set up once
184
+ by `enable-phone.sh`, as described above.
185
+
186
+ On a network with a captive portal (hotel, train or aircraft Wi-Fi), connect in this order:
187
+
188
+ 1. Complete the portal's sign-in page in the browser. Tailscale cannot connect until the portal lets traffic out.
189
+ 2. Connect Tailscale on the laptop or tablet and wait until it reports connected.
190
+ 3. Open the printed `https://<server>.<tailnet>.ts.net:8443/<device>` URL.
191
+ 4. Paste only the token value into the sign-in screen. Existing tokens remain valid; revoke one as shown above.
192
+
193
+ There is no public fallback by design: views are reachable only through the tailnet. On a host without a
194
+ Tailscale name, `lanes web` prints the loopback URL `http://127.0.0.1:8083/<device>` and says that it opens only
195
+ on the host itself.
196
+
158
197
  ## 5. Updates, optional operations and removal
159
198
 
160
199
  A server installed with `npx @vibelord/lanes --server` runs from the staged tree `~/.local/share/lanes/pkg/<version>/`; updating is `npx @vibelord/lanes@latest --server`, and the previous versions a service may still run from are kept.
@@ -30,6 +30,16 @@ other ghost: the grammar governs what may be DECLARED, never what may be SEEN. (
30
30
  on such a row saying why it cannot be declared; no surface draws it yet.) A session on a socket SEVERAL boards
31
31
  declare, whose name claims no board and whose directory is under nobody's roots, is listed by EVERY owner of
32
32
  that socket rather than dropped — one id, one row, several sidebars.
33
+ Agent glyphs (column 2, beside the cursor bar; from `status.json`'s additive `agent{}`, never from `state`): `◆`
34
+ approval prompt blocking · `✗` errored · `◇` turn finished, waiting for your prompt · `▸` working · `✓` done, until
35
+ you open the lane from this device (`clients/<device>.seen.json`) · `_` harness ended into a shell · `·` harness up,
36
+ no turn · blank = a plain shell. With an agent the stream glyph after the age is dimmed (`▸` + `○` is a long tool
37
+ call, not a stuck lane); a stale lane's agent glyph is dimmed too. Sections: **Needs you** (◆, ✗, ◇ — oldest first),
38
+ **Working** (oldest first), **Done** (newest first), then the workspace's other lanes under its own header — only
39
+ when an agent section exists; a filter flattens them. Header: `◆1 ◇2 ▸6 ✓1` (zeros omitted; `N lanes` without
40
+ agents; dimmed while the footer warns that status is old). Every row ends with an age: the agent's `since`, else
41
+ the last output (`45s`, ` 3m`, ` 2h`, ` 4d`). `x` / `×` asks ` › close ‘name’? y/n` first; `y` closes, any other
42
+ key cancels. Design of record: `docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md` §3–§4.
33
43
  A `·` after a lane's name (up to three) = other sessions VIEW that lane: any session GROUPED onto it, a Throttle
34
44
  Studio browser attach (`view-<studio>-<ch>`) included — counted in the lane's `attached`/`viewers` in `status.json`,
35
45
  never listed as unregistered (`statusd.viewer_of`; a ghost carries its viewers too, so a browser on an undeclared
@@ -52,6 +62,19 @@ process running under the survivor, which returns next cycle as a new `👻` (DE
52
62
  have no close control. `+` creates a shell from the keyboard. New and Close are explicit user actions; discovery,
53
63
  reattachment, and reconnects never create or close a session.
54
64
 
65
+ To keep an unregistered tmux session in a workspace's regular lane list, click/tap **+** beside it (or select it
66
+ and press `a`), then choose a workspace. Only workspaces using its container are offered. **Cancel** or Esc leaves
67
+ it unregistered. This preserves its name, running programs, scrollback and active views. It appears immediately
68
+ and survives sync and reconnects. Registered lanes have no close control. Legacy Docker shells cannot be registered.
69
+
70
+ From the server CLI, use `lanes ls --unregistered` to find the ID, then
71
+ `lanes register <lane-id> [workspace-id]`; omitting the workspace keeps the current assignment.
72
+ The site's version-1 `registered-sessions.json` is durable configuration: back it up with the catalog.
73
+ To undo registration, remove its record from `sessions` and run `lanes sync`; the terminal keeps running.
74
+ If publication fails after saving, `lanes sync` finishes it. Invalid/newer file versions are refused without
75
+ overwriting the file. These registrations have no restart recipe: an ended session stays absent until the operator
76
+ starts it. Add a repository terminal declaration when editor integration and a saved create recipe are wanted.
77
+
55
78
  Old plain Docker shells recovered during the transition are labelled **Legacy**. They remain tracked,
56
79
  including their existing running jobs. A still-connected viewer continues working; after leaving it, the old
57
80
  Docker connection has no normal reattach operation. Selecting that entry explains its status, and × ends the
@@ -70,6 +93,15 @@ systemctl --user status lanes-statusd lanes-sync.timer lanes-ensure.timer lanes-
70
93
  - **A view looks wrong / stuck:** `lanes rebuild mac`. Views are disposable; agents are untouched; the client reconnects.
71
94
  - **A lane was added/renamed/removed in a repo's `.vscode/terminals.json`:** nothing to do. `lanes-sync.timer`
72
95
  re-reads registries every 2 minutes and the sidebar follows. The zellij layout is never edited live.
96
+ - **Give a registered lane a different display name:** sidebar `R`, or `lanes rename <lane-id> <name>`. It writes
97
+ `repositories[i].displayNames` in the board's `workspaces.<board>.json` (a `.bak-<UTC>` copy is kept on the first
98
+ write) and syncs; the lane id, its resolve file and Cursor's `lane open <id>` stay the same. Renaming back to the
99
+ terminal's own title removes the override. A session added through Add to workspace / `lanes register` is renamed
100
+ in its `registered-sessions.json` record instead (same id, same binding); an unregistered session in its
101
+ `user-sessions/<id>.json`.
102
+ - **Promote (`P`) vs Add to workspace (`a`):** `P` declares an UNREGISTERED tmux session in the board's single
103
+ repository `terminals.json` (it then gets a repository lane id, `<ws>--<slug>`); `a` keeps the session's id. `P` is
104
+ refused on any lane that is already registered — a repository lane or an added session.
73
105
  - **A newer layout added a tab (e.g. `Throttle`):** `lanes upgrade-session` adds it to the live sessions without a
74
106
  rebuild and leaves you on the tab you were on.
75
107
  - **A new workspace:** add it to `~/.config/lanes/workspaces/catalog.json` + a manifest, `lanes sync`, then
@@ -236,7 +268,8 @@ Cursor terminals need a reopen. Never `rm -rf /tmp/*` in a workspace container;
236
268
  ## Cursor / VS Code terminals = the same lanes
237
269
 
238
270
  Cursor uses the Terminals Manager extension, which opens what `<folder>/.vscode/terminals.json` lists. Those files
239
- stay the source of truth for WHICH lanes a board has (the lanes registry is built from them). `lanes cursor-sync`
271
+ stay the source of truth for editor-defined lanes; sidebar registrations supplement the Lanes view without
272
+ changing the editor's terminal list. `lanes cursor-sync`
240
273
  only normalizes HOW a terminal reaches its lane:
241
274
 
242
275
  - every `command` becomes `exec /home/coder/.lz/bin/lane open <lane-id>` — attach; if the lane is missing, run its
@@ -247,6 +280,16 @@ only normalizes HOW a terminal reaches its lane:
247
280
  `lanes cursor-sync <ws> --write` applies (backup under `~/.local/state/lanes/cursor-sync-backups/`). The repo then
248
281
  shows an ordinary git diff to review and commit. Boards that keep their registry outside a repository
249
282
  are additionally projected into the file Cursor reads (`"cursor"` in `workspaces/*.json`).
283
+ - `lanes cursor-sync <ws> --canonicalize` (dry run; add `--write` to apply) replaces every tmux lane's recorded
284
+ create recipe that cannot create the lane — none, attach-only, or a script missing in the container — with the
285
+ canonical `tmux new -ADs … -e LANE_ID=… -e LANE_WS=… bash -l` recipe (old value kept as `createPrevious`).
286
+ `--identity` adds the same `-e` flags to a working plain `tmux -L S new -ADs N [-c D] CMD` recipe, keeping CMD's
287
+ argv; any other working recipe is left alone and its lane carries no identity. These `-e` flags are the ONLY way a
288
+ tmux lane gets `LANE_ID`/the trace: `lane open` deliberately exports none (DEF-1422 — the first client on a shared
289
+ board socket would otherwise hand its identity to every later lane there). Both change the file only: a RUNNING
290
+ lane keeps its environment and gains the identity when it is next created. Before enabling it on a live board,
291
+ read DEF-1423: a `lanes/<ws>/<id>` trace makes the whole lane — main session, subagents, fan-out — one proxy
292
+ fairness bucket.
250
293
  - Size policy "the lanes view wins": lanes-view tmux clients run with `TERM=xterm-lanesview` (a user-level terminfo
251
294
  alias of xterm-256color). While such a client shows a lane, every other client of that lane (Cursor terminals,
252
295
  ad-hoc attaches) is in tmux `ignore-size`; `lanes-statusd` reconciles every few seconds and gives editors the size
@@ -365,6 +408,23 @@ a real client before attaching; visible views accept 20 columns / 4 rows for the
365
408
  Revoke a named token with `~/.local/share/lanes/bin/zellij-0.45.1 web --revoke-token NAME`.
366
409
  An SSH-only phone client can run `lanes attach phone --mobile` instead.
367
410
 
411
+ Laptop and tablet browsers: see the [installation guide](INSTALL.md#laptop-and-tablet-browser). Troubleshooting:
412
+
413
+ - **A tablet landed on the lane-picker layout** (the device was made for a phone, for example by
414
+ `enable-phone.sh`'s default): give the tablet its own desktop-layout view with `lanes web <device> --desktop`
415
+ under a new device name. The old view stays as it is; a running view is never rebuilt into the other layout,
416
+ and an explicit `--desktop` against it is refused instead. Without a flag, `lanes web <device>` reuses
417
+ whatever layout that device already has. A tablet shows the whole tab (sidebar beside the lanes): the pinned
418
+ client opens its mobile UI in single pane on every load, and `web/browser.js` asks once per load for the full
419
+ view with the client's own preference. **More → Single pane** still switches to one pane for that load.
420
+ - **A laptop shows no Lanes chip**: the chip appears after sign-in, when the first workspace state arrives. A
421
+ reload after an upgrade picks up the new `mobile.js` and `browser.js` (both served with `no-cache`; `mobile.js`
422
+ appends `browser.js`/`browser.css` only on a tablet or desktop, so a phone's network panel never lists them).
423
+ The chip keeps the keyboard focus in the terminal; if typing stops after using a picker, Escape in the bar or a
424
+ click in the terminal returns it.
425
+ - **`lanes web` printed a `127.0.0.1` URL**: the host has no Tailscale name. Connect Tailscale on the host;
426
+ there is no public fallback.
427
+
368
428
  ## Safety rules baked into the code (do not remove)
369
429
 
370
430
  - tmux is always called with `-N` (`attach-session`, `new-session` and `start-server` on a missing socket start
@@ -383,3 +443,36 @@ An SSH-only phone client can run `lanes attach phone --mobile` instead.
383
443
  `tests/smoke_real.py` (read-only, real lanes), `tests/latency.py` (from a client). All use disposable lanes except
384
444
  the explicitly read-only smoke test; every one refuses to start on a busy host (exit 75) and removes its lanes and
385
445
  sessions when it is killed with SIGTERM / SIGHUP.
446
+
447
+ ## Workspace catalog propagation
448
+
449
+ Register and rename workspaces in the host site catalog. Device-local copies are
450
+ not a second authority. `lanes sync` publishes the registry and reconciles all
451
+ known live devices; `lanes upgrade-session mac anarchy phone` performs only the
452
+ view reconciliation. Device metadata chooses desktop or mobile layout. Missing
453
+ sessions are recreated by `lanes ensure` with that same catalog.
454
+ For an existing session with no attached client, additions wait until a client
455
+ connects and the next ensure tick. Zellij 0.45.1 otherwise acknowledges a new
456
+ background tab without spawning its panes. Names and ordering can still sync.
457
+
458
+ Reconciliation identifies managed tabs by the workspace IDs in their launched
459
+ sidebar/view commands, then adds or renames tabs and orders them using Zellij's
460
+ stable tab IDs. It never closes tabs, restarts agents or changes client focus.
461
+ An unknown session/pane response or an ambiguous identity refuses changes for that
462
+ device and reports an error. Another device can still be updated. An unmanaged
463
+ name collision is preserved for explicit inspection; it is never adopted by title.
464
+ Per-device file locks serialize a sync with the ensure timer.
465
+ SSH split tabs match the host, absolute helper and declared pane ID together.
466
+ Different pane IDs may share one helper; overlapping endpoint identities refuse
467
+ before tab mutations and require an unambiguous catalog assignment.
468
+
469
+ For a connection wait, the Syncing screen owns and drains terminal input until
470
+ the attach client produces output. Terminal modes and sizes are restored when a
471
+ viewer ends; TERM/HUP stop only that attach process group. The persistent lane
472
+ continues independently. Connection diagnostics remain in the view's `.err` file.
473
+ Existing views pick up the connection helper on their next attach; restart only
474
+ the relevant view panes when activating a changed supervisor, never the lane.
475
+
476
+ Catalog fields are additive to site schema 1. Back up the catalog and running
477
+ checkout before updating the server. Restore those files to roll back; do not
478
+ rebuild device sessions as a rollback shortcut. Previously added tabs remain open.
@@ -0,0 +1,18 @@
1
+ #!/bin/sh
2
+ # Reconnecting viewer for an existing persistent session on another host.
3
+ # The remote command is an attach-only helper: disconnecting never stops its agent.
4
+ set -u
5
+ host=${1:-} command=${2:-} pane=${3:-}
6
+ # View sessions can be created by a clean server environment with no TERM. ssh
7
+ # then starts the remote tmux client without a terminal type and the helper
8
+ # exits immediately with “open terminal failed”. Keep the viewer attach-only,
9
+ # but give the remote terminal a conservative type when the caller omitted it.
10
+ export TERM=${TERM:-xterm-256color}
11
+ case "$host" in ''|-*|*@*@*|*[!A-Za-z0-9_.@-]*) echo 'invalid SSH host' >&2; exit 64 ;; esac
12
+ case "$command" in /*) ;; *) echo 'SSH command must be absolute' >&2; exit 64 ;; esac
13
+ case "$command" in *[!A-Za-z0-9_./-]*|*/../*|*/..) echo 'invalid SSH command' >&2; exit 64 ;; esac
14
+ case "$pane" in ''|-*|*[!A-Za-z0-9_.-]*) echo 'invalid pane' >&2; exit 64 ;; esac
15
+ repo=$(cd "$(dirname "$0")/.." && pwd)
16
+ exec python3 "$repo/libexec/connection.py" --title "$pane on $host" --retry -- \
17
+ ssh -tt -o BatchMode=yes -o ConnectTimeout=8 -o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
18
+ "$host" "exec $command $pane"
@@ -2,20 +2,27 @@
2
2
  # Key-authenticated client entry point and local server setup. Tokens stay in the caller's terminal.
3
3
  set -eu
4
4
  repo=$(cd "$(dirname "$0")/.." && pwd)
5
- host= device=phone local=0 named=0
5
+ host= device= local=0 named=0 layout=
6
6
  while [ "$#" -gt 0 ]; do
7
7
  case "$1" in
8
8
  --host) host=${2:?--host SSH_ALIAS}; shift ;;
9
9
  --local) local=1 ;;
10
+ --desktop|--mobile)
11
+ [ -z "$layout" ] || [ "$layout" = "$1" ] || { echo '--desktop and --mobile are exclusive' >&2; exit 2; }
12
+ layout=$1 ;;
10
13
  -h|--help)
11
- echo 'usage: enable-phone.sh [--host SSH_ALIAS | --local] [device]'
14
+ echo 'usage: enable-phone.sh [--host SSH_ALIAS | --local] [--desktop|--mobile] [device]'
12
15
  echo 'Client: --host lanes uses the SSH key from ./install.sh. Server: --local.'
16
+ echo 'Mobile (phone lane pickers, device phone) is the default; --desktop serves the desktop sidebar layout'
17
+ echo '(device browser) for a laptop or tablet browser. A running view of the other layout is refused.'
13
18
  exit 0 ;;
14
19
  -*) echo "unknown option: $1" >&2; exit 2 ;;
15
20
  *) [ "$named" = 0 ] || { echo 'only one device name is allowed' >&2; exit 2; }; device=$1; named=1 ;;
16
21
  esac
17
22
  shift
18
23
  done
24
+ # Mobile stays the default (and sends no flag to an older server); --desktop names its own default device.
25
+ if [ "$layout" = --desktop ]; then device=${device:-browser} suffix=' --desktop'; else layout=--mobile device=${device:-phone} suffix=; fi
19
26
  case "$device" in ''|-*|*[!A-Za-z0-9_.-]*) echo 'invalid device name' >&2; exit 2 ;; esac
20
27
  case "$host" in -*|*[!A-Za-z0-9_.@:-]*) echo 'invalid SSH host' >&2; exit 2 ;; esac
21
28
  [ "$local" = 0 ] || [ -z "$host" ] || { echo '--local and --host are mutually exclusive' >&2; exit 2; }
@@ -23,10 +30,10 @@ if [ "$local" = 0 ] && [ "$(uname -s)" = Darwin ] && [ -z "$host" ]; then host=l
23
30
  [ -t 0 ] && [ -t 1 ] || { echo 'Run enable-phone.sh in your own terminal; it displays a private login token.' >&2; exit 64; }
24
31
  if [ -n "$host" ]; then
25
32
  exec ssh -t -o BatchMode=yes -o PreferredAuthentications=publickey -o ForwardAgent=no "$host" \
26
- "exec sh \"\$HOME/.local/share/lanes/current/install/enable-phone.sh\" --local '$device'"
33
+ "exec sh \"\$HOME/.local/share/lanes/current/install/enable-phone.sh\" --local '$device'$suffix"
27
34
  fi
28
35
  command -v tailscale >/dev/null 2>&1 || { echo 'Install and connect Tailscale on the server first.' >&2; exit 1; }
29
- sh "$repo/install/enable-web.sh" "$device"
36
+ sh "$repo/install/enable-web.sh" "$device" "$layout"
30
37
  if ! tailscale serve --bg --https=8443 http://127.0.0.1:8083; then
31
38
  echo 'The server admin must allow this account to manage Tailscale Serve:' >&2
32
39
  printf ' sudo tailscale set --operator=%s\n' "$(id -un)" >&2
@@ -35,9 +42,11 @@ if ! tailscale serve --bg --https=8443 http://127.0.0.1:8083; then
35
42
  fi
36
43
  dns=$(tailscale status --json | python3 -c 'import json,sys; print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))')
37
44
  [ -n "$dns" ] || { echo 'This node needs Tailscale MagicDNS and HTTPS enabled.' >&2; exit 1; }
38
- printf '\nOn your phone, connect Tailscale and open:\n\n https://%s:8443/%s\n\n' "$dns" "$device"
45
+ if [ -n "$suffix" ]; then where='In a browser on your laptop or tablet'; else where='On your phone'; fi
46
+ printf '\n%s, connect Tailscale and open:\n\n https://%s:8443/%s\n\n' "$where" "$dns" "$device"
39
47
  printf 'The last line is NAME: TOKEN. Paste only TOKEN into the sign-in screen.\n'
40
- printf 'In Safari, Share > Add to Home Screen installs the app.\n\n'
48
+ [ -n "$suffix" ] || printf 'In Safari, Share > Add to Home Screen installs the app.\n'
49
+ printf '\n'
41
50
  # Zellij 0.45.1 requires --create-token by itself; --token-name cannot accompany it.
42
51
  exec "${LANES_SHARE:-$HOME/.local/share/lanes}/bin/zellij-0.45.1" \
43
52
  --config-dir "$repo/zellij" --config "$repo/zellij/config.kdl" web --create-token
@@ -1,16 +1,29 @@
1
1
  #!/bin/sh
2
2
  # Enable loopback web services as the installed workspace user. Does not print a token.
3
+ # The view is mobile (the phone's lane pickers, default device `phone`) unless --desktop asks for the desktop
4
+ # sidebar layout (default device `browser`); a running view of the other layout is refused, never rebuilt.
3
5
  set -eu
4
6
  repo=$(cd "$(dirname "$0")/.." && pwd)
5
- device=${1:-phone}
6
- [ "$#" -le 1 ] || { echo 'usage: enable-web.sh [device]' >&2; exit 2; }
7
- case "$device" in ''|-*|*[!A-Za-z0-9_.-]*) echo 'invalid device name' >&2; exit 2 ;; esac
7
+ usage='usage: enable-web.sh [device] [--desktop|--mobile]'
8
+ device= layout=
9
+ while [ "$#" -gt 0 ]; do
10
+ case "$1" in
11
+ --desktop|--mobile)
12
+ [ -z "$layout" ] || [ "$layout" = "$1" ] || { echo '--desktop and --mobile are exclusive' >&2; exit 2; }
13
+ layout=$1 ;;
14
+ -*) echo "$usage" >&2; exit 2 ;;
15
+ *) [ -z "$device" ] || { echo "$usage" >&2; exit 2; }; device=$1 ;;
16
+ esac
17
+ shift
18
+ done
19
+ if [ "$layout" = --desktop ]; then device=${device:-browser} suffix=' --desktop'; else layout=--mobile device=${device:-phone} suffix=; fi
20
+ case "$device" in -*|*[!A-Za-z0-9_.-]*) echo 'invalid device name' >&2; exit 2 ;; esac
8
21
  command -v caddy >/dev/null 2>&1 || { echo 'Install Caddy first (see docs/INSTALL.md).' >&2; exit 1; }
9
22
  export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}
10
- "$repo/bin/lanes" prepare "$device" --mobile
23
+ "$repo/bin/lanes" prepare "$device" "$layout"
11
24
  python3 "$repo/install/services.py"
12
25
  systemctl --user daemon-reload
13
26
  systemctl --user enable --now lanes-web.service lanes-mobile.service
14
27
  systemctl --user is-active --quiet lanes-web.service lanes-mobile.service
15
- printf '\nPhone frontend: 127.0.0.1:8083; Zellij: 127.0.0.1:8082 (loopback only).\n'
16
- printf 'For private HTTPS and a login token, run in your terminal:\n sh "%s/install/enable-phone.sh" --local %s\n' "$repo" "$device"
28
+ printf '\nWeb frontend: 127.0.0.1:8083; Zellij: 127.0.0.1:8082 (loopback only).\n'
29
+ printf 'For private HTTPS and a login token, run in your terminal:\n sh "%s/install/enable-phone.sh" --local %s%s\n' "$repo" "$device" "$suffix"