@vibelord/lanes 1.2.0 → 1.5.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 (46) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +7 -5
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +1091 -76
  5. package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
  6. package/payload/lanes/container/hooks/grok-hooks.json +12 -0
  7. package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
  8. package/payload/lanes/container/lane +55 -8
  9. package/payload/lanes/container/lane-mcp +120 -22
  10. package/payload/lanes/container/lane-signal +86 -23
  11. package/payload/lanes/container/lane_core.py +524 -36
  12. package/payload/lanes/container/throttle-pulse +456 -0
  13. package/payload/lanes/docs/ARCHITECTURE.md +357 -34
  14. package/payload/lanes/docs/DESIGN.md +13 -3
  15. package/payload/lanes/docs/INSTALL.md +47 -10
  16. package/payload/lanes/docs/RUNBOOK.md +761 -12
  17. package/payload/lanes/docs/USERGUIDE.md +421 -46
  18. package/payload/lanes/holders.lock +5 -0
  19. package/payload/lanes/install/enable-docs.sh +78 -0
  20. package/payload/lanes/install/host-install.sh +39 -5
  21. package/payload/lanes/libexec/agent_state.py +604 -12
  22. package/payload/lanes/libexec/docs_store.py +995 -0
  23. package/payload/lanes/libexec/docsd.py +2010 -0
  24. package/payload/lanes/libexec/economy_poll.py +6 -2
  25. package/payload/lanes/libexec/harness_grok.py +742 -0
  26. package/payload/lanes/libexec/lanes_lib.py +143 -20
  27. package/payload/lanes/libexec/mdlite.py +575 -0
  28. package/payload/lanes/libexec/members.py +5 -0
  29. package/payload/lanes/libexec/notify.py +65 -5
  30. package/payload/lanes/libexec/sidebar.py +626 -25
  31. package/payload/lanes/libexec/statusd.py +530 -32
  32. package/payload/lanes/libexec/tokens.py +42 -9
  33. package/payload/lanes/libexec/update.py +171 -26
  34. package/payload/lanes/libexec/uploads.py +538 -0
  35. package/payload/lanes/libexec/users.py +31 -3
  36. package/payload/lanes/site/site.example.conf +4 -0
  37. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  38. package/payload/lanes/web/Caddyfile +24 -1
  39. package/payload/lanes/web/README.md +86 -8
  40. package/payload/lanes/web/browser.js +2 -4
  41. package/payload/lanes/web/docs.css +52 -0
  42. package/payload/lanes/web/docs.js +36 -0
  43. package/payload/lanes/web/mobile.js +3 -2
  44. package/payload/lanes/web/upload.css +17 -0
  45. package/payload/lanes/web/upload.js +84 -0
  46. package/payload/manifest.json +5 -3
@@ -36,7 +36,8 @@ for operations, the [runbook](RUNBOOK.md).
36
36
  Each workspace container that has the lane contract (`~/.lz/bin/lane`) also gets its own slice of the
37
37
  observation as `~/.lz/status.json` (LNS-32, `statusd.write_container_status`), because nothing in a container
38
38
  can read the host's `status.json`: `{v: 1, at, lanes{<id>: {state, agent?, attached?, viewers?, activity?, size?,
39
- stale?}}, unregistered[{id, name, workspace, boards, tmux, identity}]}`. `lanes` holds the container's declared
39
+ stale?, docs?}}, unregistered[{id, name, workspace, boards, tmux, identity}]}` (`docs`, the host row's `{n, at}`
40
+ verbatim, since plan W10 W7). `lanes` holds the container's declared
40
41
  and registered lanes (the ids its `.lz/registry.json` holds) and its unregistered sessions; a lane no cycle has
41
42
  observed yet is `unknown`; the host-side details (cwd, pid, tty, command, probe outcome) stay on the host; a
42
43
  session listed under several boards is one `unregistered` row naming them all. zmx lanes are not in it — `lane
@@ -54,20 +55,96 @@ for operations, the [runbook](RUNBOOK.md).
54
55
  per container per cycle through `/proc/<container init pid>/root` without spawning anything. Precedence: a
55
56
  hook sidecar > a Claude `~/.claude/sessions/<pid>.json` naming the pane
56
57
  `<session>:@<win>.%<pane>` (live pids only, checked in the container's pid namespace, recycled pids rejected
57
- by `procStart`) > the Codex pane title (`[ ! ] Action Required` → `needs_approval`, braille spinner →
58
- `working`, a task title → `idle`) > the window bell (an idle title plus a bell is `done`; an approval title
59
- wins over the bell). A Codex title over a bare-shell foreground whose shell has no child process
60
- (`pane_has_child`, read from `/proc/<pid>/task/*/children`) is `shell`: the harness ended and tmux kept its
61
- title. Unknown liveness leaves that key unset and never demotes. The tmux columns this needs
62
- (`window_bell_flag`, `window_id`, `pane_id`, `pane_title`) are appended to the listing format, the free-text
63
- title last so a tab inside it cannot shift another column.
58
+ by `procStart`) > a Kimi Code session file > the Grok Build observer (`harness_grok`: its session file, else its
59
+ title read with the blink memory) > the Grok Build title on a pane observed as `grok` that no observer claimed
60
+ (`harness_grok.parse_grok_title`, `source: grok-title`) > the Codex pane title, only where Codex can be (`[ ! ]
61
+ Action Required` → `needs_approval`, braille spinner → `working`, a task title → `idle`) > the observed harness
62
+ itself (`source: process`). The window bell applies throughout: an idle title, a finished Kimi turn or a completed
63
+ Grok turn plus a bell is `done` (an approval title wins over the bell; a question Kimi still asks stays
64
+ `needs_input`; `harness_grok` applies its own rule, so a cancelled or interrupted Grok turn stays `needs_input`). A
65
+ Codex title over a bare-shell foreground whose shell has no child process (`pane_has_child`, read from
66
+ `/proc/<pid>/task/*/children`) is `shell`: the harness ended and tmux kept its title. Unknown liveness leaves that
67
+ key unset and never demotes. The tmux columns this needs (`window_bell_flag`, `window_id`, `pane_id`,
68
+ `pane_title`) are appended to the listing format, the free-text title last so a tab inside it cannot shift another
69
+ column.
70
+
71
+ **The harness is observed (W12, Lanes 1.5.0; spec
72
+ `docs/superpowers/specs/2026-10-15-lanes-harness-observers-design.md`; DM-LANES-STATUS, DM-LANES-CONTAINER-STATUS).**
73
+ `agent.harness` is one of `claude | codex | kimi | grok | grok-cli | shell | unknown` (`shell` is the walk's word for a
74
+ pane and never reaches a published `agent{}`), an OBSERVED fact read from
75
+ the pane's process tree, not a side effect of which title parser matched: `agent_state.harness_walk` reads
76
+ `/proc/<pane pid>/task/*/children` (the reads `pane_has_child` already makes) and each process's `comm` in the
77
+ container's /proc, breadth first from the pane's root down four levels and at most 64 processes, and the first
78
+ process whose comm is `claude`, `codex`, `kimi`/`kimi-code` or `grok` — or whose `/proc/<pid>/exe` holds
79
+ `/claude/versions/`, native Claude Code before 2.1.299 running with its version (`2.1.295`) as comm (DEF-1993) —
80
+ names it. Codex runs as `bash` → `node` → `codex`, so it is found one level down; two `grok` binaries are told
81
+ apart by `/proc/<pid>/exe`: `…/downloads/grok-linux-<arch>` is Grok Build (the rule of
82
+ `harness_grok.harness_of_exe`; the container walks of `lane-signal` and `lane_core` match
83
+ `/downloads/grok-linux-x86_64`, the one arch measured), anything else grok-cli, an unreadable link `unknown`. Only
84
+ shells is `shell`; anything else, a cut tree or an unreadable read is `unknown`. `statusd.HARNESS_CACHE`
85
+ (`agent_state.HarnessCache`) keeps the answer per pane pid with the harness process's comm, and walks again when
86
+ the pane's own children changed or that process exited or now runs under another comm (so a native Claude that
87
+ exits below an unchanged wrapper is walked again); `shell` and `unknown` are never kept. When it names a harness,
88
+ the observed harness is the published `harness` whatever signal decided the status (a Claude-installed hook firing
89
+ inside another harness says `claude`). The Codex title (`parse_codex_title`) is read only where Codex can be: on a
90
+ pane observed as Codex, on an `unknown` pane only its spinner and `[ ! ] Action Required` shapes, on a bare shell
91
+ or an unobserved pane also its `<task> | <dir>` idle shape — the former "any title of two or more words is Codex idle" rule
92
+ published every Kimi lane as `codex / idle` (DEF-1970). The last rung is the observed harness itself: nothing above
93
+ claimed the pane, so it is that harness, `idle` (`done` with the bell), `source: process`. The values are
94
+ additive: the `agent` object keeps its keys.
95
+
96
+ **Kimi Code (observed, no hooks needed).** For the panes whose walk found `kimi`, `agent_state.kimi_sessions`
97
+ lists `~/.kimi-code/sessions/wd_*/session_<uuid>/` through the container's root with every directory opened
98
+ `O_NOFOLLOW` (a link planted there is refused, never followed), stats `state.json` and `agents/main/wire.jsonl`
99
+ (a session neither file of which moved within an hour is not read), reads `state.json` (at most 256 KiB:
100
+ `cwd`, `updatedAt`, `lastTurnReason`, `title`, `id`) and the last 64 KiB of the wire log (whole lines only), and
101
+ joins a pane's kimi pid to the session whose `cwd` equals `/proc/<pid>/cwd` — the newest when several are
102
+ fresh. Two kimi panes in one cwd cannot be told apart: neither is claimed and both fall back to hooks, title and
103
+ bell (DEF-1971). `agent_state.kimi_status` maps the log: `agent.turn.started`/`turn.prompt` and, inside a turn,
104
+ `context.append_loop_event` (tool calls among them) and `llm.request` → `working`; an `interaction.request`
105
+ of kind `approval` with no `interaction.resolved` → `needs_approval` (a `question` → `needs_input`);
106
+ `turn.ended` completed/cancelled and `agent.turn.ended{outcome: done}` → `needs_input` (`done` with the bell),
107
+ `turn.ended` failed → `errored`; with no such event in the window `state.json`'s `lastTurnReason` decides. A
108
+ session whose newest content is more than an hour old makes no claim. The verdict has `source:
109
+ kimi-session-file`, the session uuid as `session_id`, the kimi pid as `pid`, the deciding wire event (or
110
+ `state.json`) as `event`, and the session title (cut at 120 characters) as `name`.
111
+
112
+ **Grok Build and grok-cli** (W12, spec §4–§5; `libexec/harness_grok.py`). One observer contract:
113
+ `agent_state.grok_sessions(root, panes, grok_home='.grok-build', *, home='/home/coder', titles=None)` forwards its
114
+ arguments to `harness_grok.grok_sessions`, a module looked up at the call (none shipped, no observer), and keeps
115
+ the verdicts whose status is a vocabulary word or the observer's `unknown`. `statusd.agent_signals` passes the tmux
116
+ rows ({pane key: row}) of every pane its walk observed as `grok`, as `shell` (the observer decides by the title
117
+ whether that is a Grok Build pane whose harness ended), and as `unknown` with a harness pid (a `grok` whose exe
118
+ could not be read: Grok Build only when `active_sessions.json` names its pid), and `statusd.GROK_TITLES` as `titles`
119
+ — one dict per container root, kept between cycles like `HARNESS_CACHE`, pruned to the panes passed each cycle and
120
+ dropped with the container. The observer returns the same per-pane verdict a Claude session file gives `merge`
121
+ (`harness: grok`, `source: grok-session-file | grok-title`), read through the container root like the other
122
+ signals, with no spawn and no write. Lanes runs Grok Build with `GROK_HOME=~/.grok-build` (its installer would
123
+ otherwise replace grok-cli's `~/.grok/bin/grok`). The pane's process tree (depth ≤ 4) names the grok pid;
124
+ `$GROK_HOME/active_sessions.json` (`{session_id, pid, cwd, opened_at}`, the TUI's own pid) joins it to a session —
125
+ dead pids, pids no longer `grok` and pids that started after `opened_at` are ignored, two live entries for one pid
126
+ fall back to the title — and the last 64 KiB of `sessions/<URL-encoded cwd>/<session id>/events.jsonl`, whole lines
127
+ only, give the state: `phase_changed{permission_prompt}` / `permission_requested` → `needs_approval`;
128
+ `turn_started`, the other phases, `permission_resolved` and in-turn events → `working`; `turn_ended` `completed` →
129
+ `needs_input` (`done` with the bell), `error` → `errored`, `cancelled | interrupted` → `needs_input`; no turn yet →
130
+ `idle`. `summary.json` `generated_title` is the name. Otherwise the title decides (`⚠ Action Required …` →
131
+ `needs_approval`, braille spinner → `working`, `grok` / `<name> - grok` → `idle`); the prefix blinks on an
132
+ unfocused pane, so a busy title right after a prefixed sample — the previous cycle's, from `titles` — stays
133
+ `needs_approval` (DEF-1982). A Grok title over a tree read in full with no grok in it is `shell`. Every path below
134
+ the container root is walked `O_NOFOLLOW` and every file opened non-blocking and bounded: a symlinked `GROK_HOME`,
135
+ session directory or log is refused. Leader mode (a `leader*.sock` socket, or a session for the pane's cwd that
136
+ only a grok outside every pane holds) is out of scope: the observer reports status `unknown` with a `note`, and
137
+ `merge` publishes no `agent{}` for that pane (nothing below it guesses) unless a hook sidecar or a Claude session
138
+ file outranks it (DEF-1983). grok-cli writes nothing a host can read (no title, no bell, no pid in its database):
139
+ it reaches `agent{}` only through the hook sidecar (DEF-1980). The Grok Build file shapes come from its source and
140
+ user guide; no logged-in session was measured yet (DEF-1981).
64
141
 
65
142
  What is published is not `merge`'s word itself but its debounced form, `agent_state.settle`, with three more
66
143
  additive keys (`raw_status`, `raw_since`, `stable_since`): see [Attention queue](#attention-queue).
67
144
 
68
145
  The hook sidecar is `~/.lz/state/hooks/<lane id>.json` in the container, written by `~/.lz/bin/lane-signal`
69
- (Python 3 stdlib, installed by `lanes install-container`) when an opt-in Claude Code or Codex hook fires:
70
- `{v: 1, lane, harness, status, since, at, event, pid, start, session}` — `since` when the status began (kept
146
+ (Python 3 stdlib, installed by `lanes install-container`) when an opt-in hook fires (Claude Code, Codex, Kimi
147
+ Code, Grok Build, grok-cli): `{v: 1, lane, harness, status, since, at, event, pid, start, session}` — `since` when the status began (kept
71
148
  while the same status is re-signalled), `at` the last event, `pid`/`start` the agent process and its
72
149
  starttime — plus `name`, the agent's own note when it reported one (`lanes_signal`, LNS-35: kept while the status
73
150
  repeats, dropped when it changes). A report (`event: lanes_signal`) stands against the same agent's hooks — the
@@ -84,8 +161,65 @@ for operations, the [runbook](RUNBOOK.md).
84
161
  container `lane ls` read (DEF-1572). A zmx lane's sidecar is therefore named by its zmx session, not by the
85
162
  `<ws>--z-<name>` id statusd publishes, and does not reach that lane's `agent{}` yet — the zmx lane's `agent` key
86
163
  stays lane-run's record (DEF-1573). A published sidecar adds `event` to `agent{}` and has `source: hook`. Hooks are
87
- never installed implicitly: `lanes hooks install --agent claude|codex --container <name>` prints the merge and
88
- writes only with `--write` (RUNBOOK "Installing agent hooks").
164
+ never installed implicitly: `lanes hooks install --agent claude|codex|kimi|grok|grok-cli --container <name>`
165
+ prints the merge and writes only with `--write` (RUNBOOK "Installing agent hooks" and "Harnesses").
166
+
167
+ **The container side** (W12 Track C). `lane-signal`'s default harness is `--harness`, else `LANE_HARNESS`, else
168
+ the parent processes' (`comm` in the same set, a `grok` split by its exe, a native Claude Code file by its exe),
169
+ else `unknown` — never `codex`; `lane-mcp` names the client's harness from its MCP client name (`claude`, `codex`,
170
+ `kimi-code`/`kimi`, `grok-shell-*` → `grok`, `grok-cli*` → `grok-cli`), else the parent rule. The per-harness
171
+ operator steps are RUNBOOK "Harnesses"; what a person sees, the user guide's "Harnesses".
172
+
173
+ **Fleet and Throttle facts** (W11, spec `docs/superpowers/specs/2026-10-11-lanes-fleet-dashboard-design.md` §2;
174
+ DM-LANES-THROTTLE-PULSE, DM-LANES-FLEET). Two additive `status.json` keys feed the sidebar's fleet block and
175
+ `lanes doctor`, which read nothing else for it: `throttle`, the Throttle daemon's facts, and `fleet`, the host's.
176
+
177
+ *The pulse.* The daemon's facts come the way the hooks' do, from a writer inside the container: the daemon binds
178
+ its container's `127.0.0.1` (DEF-1525), so the host cannot poll it. `~/.lz/bin/throttle-pulse` (a contract file,
179
+ Python 3 stdlib) runs as a loop only in the daemon's container — site.conf `LANES_THROTTLE_CONTAINER`, else the
180
+ container of the registry's `tools--throttle-monitor` tab — where `lanes install-container` starts it, once, as
181
+ `tmux -L lanes-pulse` session `pulse`. Every 10 s it GETs the open `/pools/{claude,codex,kimi}/ready` and
182
+ `/metrics`, and the guarded `/pools/{claude,codex,kimi}/status` only with the bearer the operator adopted into
183
+ `~/.lz/throttle-token` (`lanes throttle adopt-token`; the control port has no loopback exemption, DEF-1930), and
184
+ writes `~/.lz/state/throttle.json` by rename when a value changed or 60 s passed — never anything else, never a
185
+ POST (RUNBOOK "Throttle pulse"). The kimi pool (W12) rides the daemon's generic pool routes (`scoped_pool_state`):
186
+ a daemon with no Kimi account reports it with nothing configured, and since Kimi has no paid overflow lane its
187
+ `overflow` is always null (`MONEY_POOLS` is claude and codex). The sidebar draws one provider row per pool —
188
+ `◆ anthropic`, `◆ openai`, `◆ moonshot` — so the full block is a rule and six rows from 25 pane rows
189
+ (`FLEET_FULL`), and the three-row form shows the pool with the smallest usable share, a pool with nothing
190
+ configured never (DEF-2002). There is no Grok row: Throttle has no xAI pool.
191
+
192
+ *`throttle`.* On every cycle statusd copies that file into `status.json`. It looks the daemon's container up again
193
+ on a registry reload and every 300 s, never per cycle, and makes ONE stat per cycle through `/proc/<init>/root` with
194
+ every directory opened `O_NOFOLLOW`, a read only when (pid, mtime, inode, size) moved, through `read_regular_at` (a
195
+ regular file of at most 64 KiB, never a link or FIFO). Only the spec's keys of their own types are copied
196
+ (`statusd.pulse_projection`: counts, finite numbers, booleans, a one-word phase, a printable error — never an escape
197
+ sequence). The copy is `stale: true` when its `at` is more than 90 s old (`PULSE_STALE_S`: the pulse's 60 s
198
+ heartbeat on an int-truncated `at` and a 10 s loop is routinely read 60–84 s late, DEF-1941; `lanes doctor` and the
199
+ sidebar use the same 90 s) or the file is absent or refused, keeping the last values. The one spawn this adds is
200
+ `statusd.rearm_pulse`, between two cycles in `main()`: when the pulse file is older than 90 s (or not there) while
201
+ the daemon's container runs and the host is not `critical`, at most once per 600 s, one bounded (10 s)
202
+ `docker exec -d -u 1000 -e HOME=/home/coder <c> tmux -L lanes-pulse new -d -s pulse
203
+ /home/coder/.lz/bin/throttle-pulse --loop` (byte for byte the argv `lanes install-container` starts it with,
204
+ DEF-1940), journalled with its outcome — and nothing when `~/.lz/bin/throttle-pulse` is not installed there.
205
+
206
+ *`fleet`.* Every `FLEET_INTERVAL` (10 s) the cycle asks `lanes_lib.compute_health(sample=True)`, which reads —
207
+ from plain files, no subprocess, and nothing at all on a `critical` host — the aggregate `cpu` line of
208
+ `/proc/stat`, `MemTotal`/`MemAvailable` from the `/proc/meminfo` it reads anyway, the inotify watch CEILING
209
+ (`/proc/sys/fs/inotify/max_user_watches`; the watches in use are never counted: that walks every process's
210
+ fdinfo), and per recorded container (`containers.json`) its cgroup `memory.current` and `cpu.stat` `usage_usec`.
211
+ Those counters are cumulative: `lanes_lib.fleet_facts` turns this sample and the previous one — kept in statusd's
212
+ context, never in a global — into rates (`/proc/stat` busy ÷ total jiffies; per container `usage_usec` delta ÷
213
+ interval ÷ logical cpus × 100, null for a container whose init pid changed or whose counter went back) and
214
+ publishes `fleet` with OpenTelemetry names as the keys verbatim: `host.name` (`/etc/hostname`, read once per
215
+ daemon start, only a DNS host name), `system.cpu.utilization`, `system.cpu.logical.count`,
216
+ `system.cpu.load_average.1m`, `system.memory.utilization`, `system.memory.usage{used, total}`,
217
+ `system.linux.memory.available`, `system.pressure{cpu.some.avg10, memory.some.avg10, memory.full.avg10,
218
+ io.some.avg10}`, `system.inotify.max_user_watches`, and `containers` — the three biggest by memory
219
+ (`container.name`, `container.memory.usage.total`, `container.cpu.percent`). The first sample has no rate (null,
220
+ never a since-boot average). Every figure is quantized (fractions and percents to 1 %, bytes to 0.1 GiB, the load
221
+ to 0.1) and the sample's own `at` is left out of the rewrite comparison, so a sample that moved nothing visible
222
+ does not rewrite the file; a due sample that was not taken (critical) keeps the last facts with `stale: true`.
89
223
  5. A lane's `source` is the container's spelling of its `terminals.json`. Every host-side reader of that file
90
224
  maps it to the container's home volume first (`statusd.registry_source_paths`, deduplicated by that host path:
91
225
  one repository cloned in two containers is two files): the status daemon's hot-sync
@@ -286,6 +420,11 @@ while it ran); `status.json` and `resolve.dynamic/` are projections.
286
420
  answered (a container whose listing timed out or failed is absent; its `zmx` groups are then the previous ones
287
421
  republished). It sits outside the change comparison, so the file is still rewritten only on change or the heartbeat;
288
422
  the co-located Studio Host prunes a lane it started only on such a listing (docs/SHARING.md, design §8.10 C1).
423
+ The docs cockpit (plan W10 W7) adds two keys, both absent when there is nothing to say: a lane row's `docs: {n, at}`
424
+ while `docs/counts.json` counts documents for that lane (statusd stats that file once per cycle and reads it only when
425
+ it moved), and the document's `web_origin`, `https://<name>:8443`, the name the enrolment service announces (site.conf's
426
+ `LANES_SERVER_NAME`, else this node's MagicDNS name), which statusd looks up between two cycles at most every 300 s
427
+ and never again under pressure once it has one. The sidebar builds its OSC 8 links to `/lanes-docs/` on it.
289
428
  The pre-persistence Docker-shell tracker (`legacy-sessions/`, `legacy_sessions.py`, `lanes verify-legacy`) was
290
429
  retired by plan Task 19: statusd no longer reads that directory (a record still there is inert — kept on disk,
291
430
  never observed, never closed), and a `legacy` row from an older status.json is refused by close. See
@@ -328,10 +467,11 @@ shim, so `EVAL_CLAUDE_REAL_BIN=<checkout>/.bw-tools/bin/claude eval-claude` comp
328
467
 
329
468
  `~/.lz/bin/lane-mcp` is the Agent API (the cockpit v3 plan's Track M, LNS-32…36). It is an MCP server inside each
330
469
  workspace container: the agent in a lane uses it to see the lanes of its container and, within guardrails, to change
331
- their declarations, type into a sibling lane and report its own status. It is the container's half of Lanes. It
332
- reads the two projections the host writes into `~/.lz` and the container's own tmux, and it writes only inside the
333
- container: a repository's `.vscode/terminals.json`, a tmux session, the lane's hook sidecar. What it changes reaches
334
- the boards through the host's ordinary readers (`lanes sync`, `lanes-statusd`); nothing on the host listens to it.
470
+ their declarations, type into a sibling lane, report its own status and publish a file to the docs page. It is the
471
+ container's half of Lanes. It reads the two projections the host writes into `~/.lz` and the container's own tmux,
472
+ and it writes only inside the container: a repository's `.vscode/terminals.json`, a tmux session, the lane's hook
473
+ sidecar, a publish request. What it changes reaches the boards through the host's ordinary readers (`lanes sync`,
474
+ `lanes-statusd`, `lanes-docsd`'s pick-up); nothing on the host listens to it.
335
475
  **[OPERATOR-GATED]** in a live container: `lanes install-container <container>` ships it and adds it to Claude Code
336
476
  and Codex (the plan's operator gate 3), and new agent sessions load it. How an agent should use it:
337
477
  [`skills/lanes-agent-tools`](../../skills/lanes-agent-tools/SKILL.md). Every argument and refusal, for people:
@@ -344,7 +484,7 @@ and Codex (the plan's operator gate 3), and new agent sessions load it. How an a
344
484
  | --- | --- | --- | --- | --- |
345
485
  | `lanes_whoami` | read | `$LANE_ID`, else the agent's own tmux pane, looked up in `~/.lz/registry.json` and `~/.lz/status.json` | — | now |
346
486
  | `lanes_list` | read | `~/.lz/registry.json` and `~/.lz/status.json` only, no tmux | — | now; the observation is fresh while `status.fresh` (renewed within 60 s) |
347
- | `lanes_status` | read | the two projections, one `has-session`, the lane's hook sidecar | — | now (`alive: null` when tmux does not answer in 5 s) |
487
+ | `lanes_status` | read | the two projections, one `has-session`, the lane's hook sidecar, its newest publish receipt (`published`) | — | now (`alive: null` when tmux does not answer in 5 s) |
348
488
  | `lanes_capture` | read | one `capture-pane` of the lane | — | now |
349
489
  | `lanes_register` | write | a repository's `terminals.json`: one terminal appended | `lanes sync`, started by `lanes-statusd`'s hot sync | 15 s |
350
490
  | `lanes_unregister` | write | the same file: one terminal removed; its session keeps running | `lanes sync` | 15 s |
@@ -352,11 +492,13 @@ and Codex (the plan's operator gate 3), and new agent sessions load it. How an a
352
492
  | `lanes_new` | exec | the same file: one terminal appended; and a new tmux session running `command` | `lanes sync` | 15 s (the session runs at once) |
353
493
  | `lanes_send` | exec | keystrokes into a lane's active pane | the program in the lane's active pane | 0 s (its Enter half a second after the text) |
354
494
  | `lanes_signal` | write | the agent's own hook sidecar, through `lane-signal` | `lanes-statusd` | 5 s |
495
+ | `lanes_publish` | write | a publish request `~/.lz/state/publish/<lane>/<id>.json` naming a file of the container (the file itself is read by the host) | `lanes-docsd` | 5 s (its pick-up cycle; the receipt comes back beside the request) |
355
496
 
356
497
  "Seen within" is the `expected_within_s` each write answers (`test_docs.AgentApi` reads it from `lane-mcp` and holds
357
498
  this table to it). A terminals.json edit: `lanes-statusd` checks every cycle (3 s, `LANES_STATUS_INTERVAL`) whether a
358
499
  terminals.json its registry names has changed (`statusd.hot_sync`) and starts one `lanes sync` at most every 10 s;
359
- the `lanes-sync.timer` sync every 2 minutes catches anything else. A sidecar is read on the next statusd cycle.
500
+ the `lanes-sync.timer` sync every 2 minutes catches anything else. A sidecar is read on the next statusd cycle. A
501
+ publish request is taken by the docs service's own pick-up cycle (5 s, "Docs service" below).
360
502
  "Effect" is the tool gateway's classification (`read`, `write`, `exec`: arbitrary command execution). `lanes_new` runs
361
503
  its `command`, and `lanes_send` types a line that a shell lane runs, so both are `exec`; the gateway recipe below
362
504
  carries the same classes.
@@ -380,7 +522,7 @@ way: only when missing, `<file>.bak-<UTC>` first, re-read before the write, and
380
522
  something else is never replaced.
381
523
 
382
524
  The write tools (LNS-35) are `lanes_register`, `lanes_unregister`, `lanes_rename`, `lanes_new`, `lanes_send` and
383
- `lanes_signal`. The first four change a lane's DECLARATION, a repository's terminals.json in the container, and
525
+ `lanes_signal`; plan W10 W4 (LNS-105) appended `lanes_publish`. The first four change a lane's DECLARATION, a repository's terminals.json in the container, and
384
526
  never the host: `lanes-statusd`'s hot-sync notices the changed file and starts one `lanes sync`, which publishes it
385
527
  (about 15 s). To answer with the id that sync will give, the container carries ports of the host's rules —
386
528
  `slug`, `shown_key`, `lane_trace`, `canonical_recipe`, the id grammar — each pinned equal to its `lanes_lib`
@@ -400,7 +542,12 @@ reads the text and its CR as one chunk, which an agent's prompt takes as a paste
400
542
  target's agent is `working` or `needs_approval` (the host's observation or the lane's own sidecar) unless forced.
401
543
  `lanes_signal` runs `lane-signal` with `--agent-pid` (the server's parent, the agent), `--note` (the sidecar's
402
544
  `name`, which `agent_state.read_sidecar_dict` already publishes as the agent's task line) and the agent's
403
- `CLAUDE_CODE_SESSION_ID` when its environment carries one, so the report and the agent's hooks name the same agent. Each write answers what changed, what
545
+ `CLAUDE_CODE_SESSION_ID` when its environment carries one, so the report and the agent's hooks name the same agent.
546
+ `lanes_publish` (and the `lane publish` verb, the same `lane_core.publish`) resolves the file inside the container's
547
+ home, hashes it once and writes the request; it never copies the file. The host trusts nothing in the request: the
548
+ docs service resolves and hashes the file again and refuses a hash that differs. The tool reads its answer back as
549
+ `lanes_status`'s `published` (`lane_core.latest_receipt`), from the receipt in the container, never through
550
+ `status.json`. Each write answers what changed, what
404
551
  picks it up and when; each refusal a machine code and what a person can do instead. On the host,
405
552
  `session_actions.unregister_declared` runs the same `lane_core` editor (loaded once per process by
406
553
  `lanes_lib.lane_core()`) through the container's home volume with `root=` set, so a symbolic link there is refused
@@ -420,7 +567,8 @@ No tool exists for these. An agent that needs one asks a person; a refusal's `hu
420
567
  - **Answering an approval** prompt. `lanes_send` refuses a lane whose agent waits for an approval
421
568
  (`agent_needs_approval`); `force` is for an answer the agent was asked to type.
422
569
  - **Opening or attaching** a lane, or changing what a view shows.
423
- - **Uploads**.
570
+ - **Uploads**: a person's file into a lane (the Docs page, the desktop drop). The reverse, an agent's own file onto
571
+ the docs page, is `lanes_publish`.
424
572
  - The host's **`lanes sync`, `lanes gc`, `lanes ensure`** and every other host verb. The container cannot run them;
425
573
  its changes reach the host only as files the host reads.
426
574
  - **Renaming a tmux session** (`rename-session`). `lanes_rename` changes the name the boards show, never the id or the
@@ -439,9 +587,10 @@ the result and refusal shapes below. `lane-mcp` names it `WIRE = 1` (its `start`
439
587
  MCP protocol versions 2025-06-18, 2025-03-26 and 2024-11-05: `initialize` answers the client's version when it is one
440
588
  of these, else 2025-06-18. The data-model row is DM-LANES-LANE-MCP in `docs/master-datamodel.md`.
441
589
 
442
- - **Frozen in v1:** the ten tool names and their order; every input property's name, type and meaning; which
443
- arguments are required; the closed schemas (`additionalProperties: false`); the read-only annotations of the four
444
- readers and the hints of the six writers; the result shapes below.
590
+ - **Frozen in v1:** the eleven tool names and their order (`lanes_publish`, plan W10 W4, appended after the other
591
+ ten); every input property's name, type and meaning; which arguments are required; the closed schemas
592
+ (`additionalProperties: false`); the read-only annotations of the four readers and the hints of the seven writers;
593
+ the result shapes below.
445
594
  - **Additive only within v1:** a new tool appended after the others; a new optional argument whose default keeps
446
595
  today's behaviour; a new key in a JSON result; a new refusal code. A reader ignores keys it does not know and treats
447
596
  an unknown `refused` code as a refusal. A description may be clarified; the golden then changes visibly.
@@ -451,7 +600,11 @@ of these, else 2025-06-18. The data-model row is DM-LANES-LANE-MCP in `docs/mast
451
600
  - **Results** are one `text` content block. The readers answer JSON (`lanes_whoami`, `lanes_list`, `lanes_status`)
452
601
  or plain text (`lanes_capture`); a reader that cannot answer is `isError: true` with a sentence. The writers always
453
602
  answer one JSON object. On success it holds what changed (`changed`), what picks the change up (`picked_up_by`) and
454
- how soon (`expected_within_s`); `lanes_signal` adds `stands_until`. On a refusal it is `isError: true` with the
603
+ how soon (`expected_within_s`); `lanes_signal` adds `stands_until`; `lanes_publish` the request's `id`, the file's
604
+ container `path`, `kind`, `bytes`, `sha256` and where the request and its receipt are (`request`, `receipt`).
605
+ `lanes_status` carries `published` (`{id, url, at, outcome, request, reason?}`, the newest receipt) when the lane
606
+ has one, and `docs` (`{n, at}`) when the docs page holds documents for it — both additive, absent otherwise. On a
607
+ refusal it is `isError: true` with the
455
608
  machine code (`refused`), the reason (`why`) and what a person can do instead (`human`). A refusal changed nothing
456
609
  unless it carries `changed`: `tmux_failed` after `lanes_new` declared its lane, or after `lanes_send` typed its text
457
610
  but not its Enter. There is no `outputSchema` or `structuredContent`, so every protocol version reads the same thing.
@@ -471,7 +624,9 @@ The refusal codes (`refused`), each with the `human` sentence `lane-mcp`'s `HUMA
471
624
  - which file: `no_source`, `ambiguous_source`, `ambiguous_workspace`, `source_missing`, `source_invalid`,
472
625
  `source_unreadable`, `source_link`, `no_lanes_block`, `not_declared_here`, `unknown_prefix`;
473
626
  - the edit: `edit_conflict`, `edit_refused`, `last_terminal`, `duplicate_name`, `duplicate_binding`, `slug_collision`;
474
- - a value: `invalid_name`, `invalid_session`, `invalid_cwd`, `invalid_command`, `invalid_text`, `invalid_note`;
627
+ - a value: `invalid_name`, `invalid_session`, `invalid_cwd`, `invalid_command`, `invalid_text`, `invalid_note`,
628
+ `invalid_title`, `invalid_for`, `invalid_kind`;
629
+ - the file to publish: `outside_home`, `not_a_file`, `too_large`;
475
630
  - the target's agent: `agent_working`, `agent_needs_approval`;
476
631
  - tmux and the sidecar: `tmux_unanswered`, `tmux_failed`, `signal_not_written`.
477
632
 
@@ -513,7 +668,8 @@ at a pinned commit, never by hand:
513
668
  {"name": "lanes_rename", "description": "<harvested>", "effect": "write", "state": "planned", "description_sha256": "<harvested>"},
514
669
  {"name": "lanes_new", "description": "<harvested>", "effect": "exec", "state": "planned", "description_sha256": "<harvested>"},
515
670
  {"name": "lanes_send", "description": "<harvested>", "effect": "exec", "state": "planned", "description_sha256": "<harvested>"},
516
- {"name": "lanes_signal", "description": "<harvested>", "effect": "write", "state": "planned", "description_sha256": "<harvested>"}
671
+ {"name": "lanes_signal", "description": "<harvested>", "effect": "write", "state": "planned", "description_sha256": "<harvested>"},
672
+ {"name": "lanes_publish", "description": "<harvested>", "effect": "write", "state": "planned", "description_sha256": "<harvested>"}
517
673
  ]
518
674
  }
519
675
  ```
@@ -561,6 +717,32 @@ workspace's `uploads/` folder without overwriting files; an optional paste place
561
717
  selected lane. Active transfers have an idle timeout, not a short total-duration limit. Credentials, `.git`
562
718
  metadata and disallowed folders cannot be uploaded through the helper.
563
719
 
720
+ **Uploads converge on the docs store** (docs cockpit W10 task W5, LNS-106). Both ways in — `lanes upload` over SSH and
721
+ the Docs page's `POST /up` (the phone and laptop drop area) — go through `libexec/uploads.py`: the file lands in
722
+ `<folder>/uploads/` exactly as before (first lane folder, else the workspace's working directory; `-1` on a
723
+ collision; the repository's `.git/info/exclude` line) and each chunk read from the upload is written there AND handed
724
+ to `docs_store.intake` in the same pass (a `Tee`), so the store's sha256 receipt is of exactly the bytes that arrived,
725
+ never of a file an agent could rewrite first. The store keeps a copy — a hard link would share an inode with a file
726
+ the container may write — for the uploader's login (`source` `ssh` or `web`, kind by extension), and the Docs page and
727
+ the sidebar's count show it. The folder is the container's, written by the host as the agents' own uid, so the walk
728
+ never follows a link (directories opened component by component, files `O_EXCL|O_NOFOLLOW`, the tar stream unpacked
729
+ member by member instead of `tarfile.extractall`) and no git process runs in the repository (its config's
730
+ `core.fsmonitor` would be a command the container chose). An `age` switch (`LANES_DOCS_AGE_RECIPIENT`, default off)
731
+ encrypts the store copy only; the docs service decrypts on the fly when it holds an identity, else answers 409.
732
+
733
+ **Docs store** (docs cockpit W10 task W1, LNS-102; read by the docs service, W3, and written by both upload paths
734
+ through `libexec/uploads.py`, W5, and by the publish pick-up, W4 — `docsd.Pickup`, `source` `publish`):
735
+ `libexec/docs_store.py` keeps documents per person in host state, under
736
+ `docs/<login, percent-encoded as for profiles>/<workspace>/<lane>/<id>/`: the file itself, `meta.json` and
737
+ `receipt.json`, both with the sha256 the host computed while streaming the bytes in (a claimed hash is never taken).
738
+ A byte cap refuses an oversize document and leaves nothing behind. Ids are base36 seconds plus six random
739
+ characters; a name keeps only its basename's letters, digits, `.`, `_` and `-`, and gains `-1`, `-2` within a lane as
740
+ an upload does in `uploads/`. Nothing is written or read through a symbolic link. A document is visible to the person
741
+ whose store holds it, to every member when it names nobody, otherwise to the logins it names; `docs/counts.json`
742
+ counts each lane's documents and the append-only `docs.log` records each intake and removal without content, titles
743
+ or names. The docs service, the publish verb, receipted uploads and the sidebar's count build on it (plan
744
+ `docs/superpowers/plans/2026-10-08-lanes-docs-cockpit.md`).
745
+
564
746
  **Quick edit** (`Ctrl-Alt-e`, cockpit v3 E1–E3) is the opposite direction and moves no file at all. The floating
565
747
  **Edit** pane runs `lanes edit --focused` on the server: it resolves the focused tmux lane as the preview does, finds
566
748
  the lane's git checkout and, for a linked worktree, its main checkout (`lanes_lib.repo_roots`, walking up to `.git`, a
@@ -626,8 +808,26 @@ DM-LANES-INSTALL-HANDOVER).
626
808
 
627
809
  Tailscale Serve terminates private HTTPS on 8443 and forwards to the dedicated Caddy user service on loopback
628
810
  8083. Caddy serves `web/` and forwards native assets, authentication and WebSockets to Zellij on loopback 8082.
629
- Zellij's existing token/cookie checks remain authoritative; the mobile layer adds no second login system. The one
630
- other route, `/enrol/*`, goes to the enrolment service on loopback 8184 (see Access).
811
+ Zellij's existing token/cookie checks remain authoritative; the mobile layer adds no second login system. Two other
812
+ routes go to services of their own, each bound to the tailnet login Serve injects and never to Zellij's cookie:
813
+ `/enrol/*` to the enrolment service on loopback 8184 (see Access) and `/lanes-docs/*` to the docs service on loopback
814
+ 8185 (Docs service, below). Caddy's route list, in the order it is declared:
815
+
816
+ | Path | Goes to | Headers |
817
+ | --- | --- | --- |
818
+ | `/lanes-docs/*` (prefix stripped) | `lanes-docsd` 127.0.0.1:8185, keeping Serve's `X-Forwarded-For` | docsd's own, per route (the site-wide set skips it: `header @notdocs`) |
819
+ | `/lanes-assets/vendor/*` (prefix stripped) | `$LANES_SHARE/vendor` files: the pinned Mermaid `install/enable-docs.sh` installs (`Cache-Control: public, max-age=31536000, immutable`; a page names the versioned file, DEF-1870) | site-wide |
820
+ | `/lanes-assets/*` (prefix stripped) | `web/` files (`no-cache`) | site-wide |
821
+ | `/enrol/*` (prefix stripped) | `lanes-enrold` 127.0.0.1:8184, keeping Serve's `X-Forwarded-For` | site-wide |
822
+ | `/assets/*`, `/ws/*`, `/command/*`, `/info/*`, `/session`, `/session-list` | Zellij web 127.0.0.1:8082 | site-wide |
823
+ | `GET /<device>` (one path segment, or `/`) | `web/index.html` (`no-store`) | site-wide |
824
+ | anything else | Zellij web 127.0.0.1:8082 | site-wide |
825
+
826
+ A view session is served at `/<device>`, so `bin/lanes` `device_name` refuses the service roots as device names
827
+ (`enrol`, `lanes-docs`, `lanes-assets`, casefolded). Caddy sorts the routes before it runs them: a single-path `handle`
828
+ goes ahead of another only when that one has a single path too (DEF-1872), so the vendor route stays with the
829
+ single-path routes above the named-matcher ones; its order is proven on a model of that sort
830
+ (`test_docs_assets.VendorRoute`), not on a running Caddy (DEF-1871, open until the RUNBOOK's operator check).
631
831
 
632
832
  The phone shell uses the pinned client's window hooks for workspaces, panes, input and sizing. A visible
633
833
  **Type here** field holds the original terminal input, as a text field. Programmatic input focus is blocked on
@@ -665,6 +865,93 @@ reads the enrolment service's `GET /site` on the same origin (`/enrol/site`) and
665
865
  enables the services through `install/enable-web.sh` and prints the private URL; without a flag a known device
666
866
  keeps its layout.
667
867
 
868
+ **Docs cockpit (plan W10, LNS-102…110).** Agents put content where people open it, and people hand files to agents,
869
+ on one login-bound page per server, `/lanes-docs/`: `lane publish` / `lanes_publish` in the container write a
870
+ request that `lanes-docsd`'s own 5 s pick-up reads through `/proc/<pid>/root` (publish pick-up, below); both upload
871
+ paths land a file in the workspace's `uploads/` and the store in one pass ("Desktop and uploads"); the per-login store
872
+ `libexec/docs_store.py` keeps every document with its sha256 receipt; `libexec/mdlite.py` renders Markdown and the
873
+ pinned Mermaid draws its fences (docs rendering, below); `libexec/docsd.py` serves it all to the tailnet login Serve
874
+ injects, only after the operator pinned the Serve header check (docs service, below); `lanes-statusd` carries each
875
+ lane's count and the web origin into `status.json`, from which the sidebar's ` ▤ docs N` strip, its OSC 8 links and
876
+ the `d` key build ("Views and panes"), and the phone's **More → Docs** and the laptop bar's **Docs** link it from the
877
+ shell (above). `lanes doctor` reports the unit, the pin, the store, the Mermaid pin and the `age` switch. What
878
+ `lanes docs push <id>` would hand to the data service's publication surface is not built (it prints the receipt and
879
+ exits 2). Operations, limits and the operator's live checklist: RUNBOOK "Docs service"; the user's view: the user
880
+ guide's [Docs](USERGUIDE.md#docs).
881
+
882
+ **Docs rendering (W10 W2, LNS-103).** Markdown an agent publishes from a lane is turned into an
883
+ HTML fragment by `libexec/mdlite.py` `render(text)`, which the docs cockpit's `lanes-docsd` (W3) embeds: standard
884
+ library only, deterministic, at most 1 MiB of UTF-8 (`TooLarge` past it), every algorithm linear in its input. Agent
885
+ content is treated as hostile: every text node and attribute value is escaped exactly once, raw HTML stays text, a
886
+ link keeps its URL only for `http://`, `https://`, `mailto:`, `/lanes-docs/` or `#` (the web ones open in a new tab
887
+ with `rel="noopener noreferrer"`) and only without a backslash or a `.`/`..` path segment (`%2e` spellings included,
888
+ which a browser resolves before it requests; on this site no `%2f`/`%5c` either: security review 2026-10-08), an image only for a document's own bytes
889
+ (`/lanes-docs/d/<id>/raw` or `/artifact`) or a base64 `data:image/png|jpeg|gif|webp`; any other destination is dropped
890
+ and its text kept. A `mermaid` fence becomes `<pre class="mermaid">` for the pinned Mermaid (W6). The subset and the
891
+ output contract are DM-LANES-DOCS-RENDER in the master data-model ledger.
892
+
893
+ **Docs service (W10 W3, LNS-104).** `libexec/docsd.py` (`lanes-docsd.service`, loopback 127.0.0.1:8185, reached as
894
+ `/lanes-docs/*`) serves the docs store to the person it belongs to. A request is answered only when it came through
895
+ Tailscale Serve (its `X-Forwarded-For` names a tailnet address), carries Serve's identity headers (a tagged device has
896
+ none), names a valid login that owns a member record in an allowed domain, and the operator has pinned the Serve
897
+ header check (`lanes enrol mode request`), and — on every route since the security review of 2026-10-08 — its
898
+ `Sec-Fetch-Site` is `same-origin` or `none`, or it is a GET/HEAD top-level navigation (`Sec-Fetch-Mode: navigate`,
899
+ `Sec-Fetch-Dest: document`: a link followed from any site); a sandboxed artifact's `fetch()` and subresource loads are
900
+ `cross-site` and not navigations, another `*.ts.net` host's are `same-site`, and `POST /up` takes `same-origin` or
901
+ `none` only; otherwise 403. It uses enrold's own checks
902
+ (`through_serve`, `caller_of`, `member_logins`, `read_mode`, the domain rule), never Zellij's cookie, and listens on
903
+ loopback only (DEF-1645 stays the open risk; the pin gate is the mitigation). A person sees their own store, what
904
+ names them in `for`, and — for a document that names nobody — what lies in a workspace their member record grants; any
905
+ other document is a 404 like a missing one. Pages: the index (workspace → lane → newest first, with counts; filtered
906
+ by `?ws=<ws>&lane=<lane>`, the sidebar's deep link), a document page by kind (Markdown rendered by mdlite; an HTML or
907
+ SVG artifact only linked, never embedded; a file's facts and its download), the raw bytes as an attachment with
908
+ `X-Lanes-Sha256`, the artifact as a top-level document under `Content-Security-Policy: sandbox allow-scripts` (an
909
+ opaque origin: its scripts run but cannot read this site), the receipt, and `docs.css`/`docs.js` from `web/`. Every
910
+ reference a page emits is relative, because Caddy strips `/lanes-docs`. Pages carry `default-src 'none'; style-src
911
+ 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; base-uri
912
+ 'none'` (W5 added `connect-src 'self'` for the drop area's own request), and the site-wide Caddy header set skips
913
+ `/lanes-docs/*` (`header @notdocs`), so these are the only policies a document gets. Per process it bounds what one
914
+ login can make it do: 300 requests a minute, two Markdown renders at once (a rendered document is kept in an 8 MiB
915
+ cache by its sha256, so a second view renders nothing), two 32 MiB buffers at once shared by uploads and decryptions
916
+ and at most one of them per login, and a deadline on each upload body; refusals are coalesced in `docs.log` and the
917
+ log rotates at 8 MiB (RUNBOOK "Docs service", Limits). `POST /up` takes a multipart form
918
+ from the Docs page (32 MiB, 30 uploads per login in 10 minutes, two at once, one per login) into a
919
+ lane of one of the person's workspaces through `uploads.receive` — the workspace's `uploads/` and `docs_store.intake`,
920
+ both or neither — and answers each file's receipt with the in-container path it landed at. A refused body is read away
921
+ before the answer (at most 64 MiB, then a short linger), so the client reads the refusal instead of a reset
922
+ (DEF-1854). The index loads the drop area's `upload.css`/`upload.js` (their own files: `docs.js` had no room). RUNBOOK
923
+ "Docs service" has the unit, the pin gate and the wire contract (DM-LANES-DOCS-WIRE). A member record's `workspaces`
924
+ grants the boards it lists; a record that lists none grants every workspace the host serves, as nowhere else in Lanes
925
+ narrows by that list (DEF-1852).
926
+
927
+ **Publish pick-up (W10 W4, LNS-105).** An agent publishes from a lane with `lane publish <file>` or `lanes_publish`:
928
+ the container writes a request, `~/.lz/state/publish/<lane>/<id>.json` (`{v, path, title, kind, claimed_sha256,
929
+ mime, bytes, for}`, DM-LANES-PUBLISH-REQUEST), and nothing else. The docs service runs its own pick-up cycle every
930
+ 5 s on a thread beside the HTTP server (`docsd.Pickup`; statusd's tick is not involved). For each container
931
+ `containers.json` names whose init pid is alive — and whose `/proc/<pid>/root` is not the host's own root, so a
932
+ recycled pid never turns the host's home into a container's, and, where the account can see the home volume
933
+ `containers.json` records, whose `/home/coder` is that volume (another container that got the pid is skipped) — it
934
+ surveys `~/.lz/state/publish/` and each lane directory with `os.scandir` (opened by `lanes_lib.open_real_dir`, a link
935
+ is never followed), reading at most 4,096 entries of a container per cycle: a container that holds more is refused for
936
+ the cycle and said in the journal once an hour, so what one container plants never costs the service its memory or
937
+ the other containers their cycle. It claims a request by renaming it to `.<id>.taking` (a directory planted at that
938
+ name, or at the receipt's, is removed or moved aside; a request that still cannot be claimed is skipped for the next),
939
+ reads it with `read_regular_at` (no link, no FIFO, at most 16 KiB) and checks it. The file is
940
+ resolved inside the container (`container_realpath`: an absolute link restarts at the container's root) to a path
941
+ under `/home/coder`, its directories are opened without following a link and the file `O_NOFOLLOW|O_NONBLOCK`, a
942
+ regular file of at most 32 MiB; a link swapped in meanwhile, a FIFO, a device or a larger file is refused, so a
943
+ container can never make the host read outside its home. The bytes stream into `docs_store.intake` (`source`
944
+ `publish`, `by` the lane id) through a reader that hashes them and refuses, inside intake, a sha256 other than the
945
+ claimed one, so nothing is stored. The answer goes back beside the request as `<id>.receipt.json` (`{v, id,
946
+ request, sha256, bytes, url, at, outcome, reason?}`, DM-LANES-PUBLISH-RECEIPT), written by rename like statusd's
947
+ `~/.lz/status.json`; the claim is removed and the lane keeps its 16 newest receipts. The document is for the request's
948
+ `for` (a member login), else for the member whose profile last opened the lane, else for every member whose record
949
+ grants its workspace; a request whose `for` names no member, or that has nobody to go to, is answered
950
+ `not-delivered` either way, so a container cannot learn from the reason whether a login is a member (docs.log keeps
951
+ the precise word). One `published` push per lane per minute goes through `notify` (its config and desk-presence
952
+ rule; kind and size only, never a title). At most 16 requests per container per cycle and 30 per container per 10
953
+ minutes are taken; more are refused `rate-limited`.
954
+
668
955
  ## Installed files and services
669
956
 
670
957
  `install/services.py` renders the service templates against the installed binary paths and the `current` link.
@@ -714,7 +1001,9 @@ release untouched; `services.py --install` is the commit, and its refusals (the
714
1001
  symlink, a path no unit can hold — each raised before its first write as a `services.Refusal`) exit 3. After it,
715
1002
  `libexec/update.py post-commit` runs from the new tree, with its own `bin/lanes`: daemon-reload, `lanes sync`, the
716
1003
  statusd restart, a `try-restart` of each optional unit (`update.TRY_RESTART`), `install-container` for each container
717
- `containers.json` records with a home volume (read after the sync), `lanes reload-views`,
1004
+ `containers.json` records with a home volume (read after the sync), then for the Throttle daemon's container when sync
1005
+ did not record it (`update.daemon_container`; skipped with a warning when sync or docker said it is gone or has no home
1006
+ volume, `update.daemon_gone`, so a stale name never leaves every update `partial`), `lanes reload-views`,
718
1007
  `lanes ensure --if-layout-changed` — each step in its own session, bounded (`STEP_TIMEOUT`, then its process group is
719
1008
  stopped), its output in `update/last.log`, its exit status in `update/last.json`, and run whatever the one before
720
1009
  returned — `lanes sync`'s exit 1 (a board with an error) being a warning that leaves the result `ok`
@@ -805,7 +1094,7 @@ only the full server install enables it, and an update (`--update-only`) enables
805
1094
  an update has moved past. A tree is staged (`lanes_lib.staged_tree`) when it sits in the default `…/pkg/<version>`
806
1095
  root or carries the npm wrapper's `.lanes-package.json` stamp, so a `$LANES_PKG_ROOT` of any name is followed through
807
1096
  the link too (DEF-1757); only the link under the home counts for such a root. `tree_version` and `tree_stamp` read a
808
- tree's `VERSION` and that stamp. Every long-running process — statusd, enrold, the economy poller, the notifier, the
1097
+ tree's `VERSION` and that stamp. Every long-running process — statusd, enrold, docsd, the economy poller, the notifier, the
809
1098
  Studio Host enable step and each sidebar — writes the resolved tree it started from to
810
1099
  `~/.local/state/lanes/run/<name>.tree` (`lanes_lib.record_tree`), so what still runs an old tree is a file
811
1100
  comparison, not a process scan. `lanes version` makes it: each record against the resolved target of the `current`
@@ -824,7 +1113,9 @@ layout's commands (`libexec/sidebar`, `libexec/view.sh`, `holders/ssh.sh`) resol
824
1113
  unescaped as `services.unit_value` escaped it) resolves to it.
825
1114
  No service requires the checkout to be named `~/lanes`. Re-run the server installer after moving it.
826
1115
 
827
- Core units are the status observer and sync, ensure and garbage-collection timers. The web units are opt-in.
1116
+ Core units are the status observer and sync, ensure and garbage-collection timers. The web units are opt-in. The
1117
+ enrolment and docs services (`lanes-enrold`, `lanes-docsd`) and `lanes-update.timer` are enabled by every full server
1118
+ install; an update (`--update-only`) enables nothing and only restarts what runs (DEF-1900 for `lanes-docsd`).
828
1119
  The co-located Throttle Studio Host (`lanes-studio-host.service` and its enrolment retry timer) is opt-in too:
829
1120
  `install/enable-studio-host.sh` enables it, and its binary is selected by `lanes studio-host install` from an
830
1121
  `opt-in` line of `holders.lock` or a local build, sha256-verified either way (`libexec/studio_host.py`).
@@ -836,6 +1127,37 @@ The package uses Python's standard library and POSIX shell. Host binaries are ve
836
1127
  in `holders.lock`; Zellij's web assets are served by that binary and integrity-pinned in `web/index.html`.
837
1128
  Deployment profiles, workspace catalogs and runtime state remain distinct from shared source code.
838
1129
 
1130
+ **What the docs cockpit installs (plan W10, W1–W8).** Nothing is vendored into the tree: Mermaid is downloaded and
1131
+ checked against `holders.lock`'s pin, and every other piece is the standard library, POSIX shell or a file Caddy
1132
+ serves:
1133
+
1134
+ | Piece | Where | Installed or run by |
1135
+ | --- | --- | --- |
1136
+ | `lanes-docsd.service` (`libexec/docsd.py`: the HTTP service on 127.0.0.1:8185 and its publish pick-up thread) | `~/.config/systemd/user/`, `lanes.slice` | `services.py` renders it; every full server install enables and restarts it after `lanes-enrold`; an update only restarts it when it runs (DEF-1900) |
1137
+ | `libexec/docs_store.py`, `libexec/mdlite.py`, `libexec/uploads.py` | the installed tree | the server install; no unit of their own (docsd, `lanes upload`, statusd and `lanes doctor` import them) |
1138
+ | `web/docs.css`, `web/docs.js`, `web/upload.css`, `web/upload.js` | the installed tree; docsd serves them at `/lanes-docs/docs.css` and so on | the server install |
1139
+ | The Caddy routes `/lanes-docs/*` and `/lanes-assets/vendor/*`, and `header @notdocs` | `web/Caddyfile`, run by `lanes-mobile.service` | the server install, which `try-restart`s `lanes-mobile` so Caddy reads the new file |
1140
+ | Mermaid 11.17.2, `holders.lock`'s opt-in `mermaid` line (DM-LANES-DOCS-VENDOR) | `~/.local/share/lanes/vendor/mermaid-11.17.2.min.js` and the link `mermaid.min.js` | `install/enable-docs.sh`: every full server install and every update (never fatal), and by hand |
1141
+ | The docs store, `docs/counts.json`, `docs.log` (DM-LANES-DOCS-STORE, -RECEIPT, -COUNTS, -LOG) | `~/.local/state/lanes/docs/`, `~/.local/state/lanes/docs.log` | written by docsd (uploads, publishes, reads and refusals) and `lanes upload`; read by statusd (counts) and `lanes doctor` |
1142
+ | `lane publish` and `lane-mcp`'s `lanes_publish` | each container's `~/.lz/bin/` (`lane`, `lane_core.py`, `lane-mcp`) | `lanes install-container <name>`, which every update runs again for each container `containers.json` records |
1143
+ | Publish requests and their receipts (DM-LANES-PUBLISH-REQUEST, -RECEIPT) | each container's `~/.lz/state/publish/<lane id>/` | the request by `lane publish` / `lanes_publish`, the receipt by docsd's pick-up |
1144
+ | `LANES_DOCS_PORT`, `LANES_DOCS_DOMAINS`, `LANES_DOCS_AGE_RECIPIENT`, `LANES_DOCS_AGE_IDENTITY` | lanes-docsd's drop-in (`systemctl --user edit lanes-docsd.service`) | the operator; `host-install.sh` `check_docs_age` refuses the recipient where no `age` binary is on PATH |
1145
+
1146
+ **What the fleet block installs (plan W11, 1.4.0; spec `docs/superpowers/specs/2026-10-11-lanes-fleet-dashboard-design.md`).**
1147
+ No collector and no new unit: one container-side file, two additive `status.json` keys, one opt-in token file and one
1148
+ site fact. Routes of truth: the pulse file is the daemon's facts as the pulse last read them (DM-LANES-THROTTLE-PULSE);
1149
+ `status.json`'s `throttle` is statusd's copy of it with `stale`, its `fleet` statusd's own host reading (DM-LANES-FLEET);
1150
+ the sidebar and `lanes doctor` read `status.json` only.
1151
+
1152
+ | Piece | Where | Installed or run by |
1153
+ | --- | --- | --- |
1154
+ | `throttle-pulse` (a contract file, `bin/lanes` `CONTRACT_FILES`; stdlib Python; GET only, 3 s timeout, 1 MiB cap, no redirect) | each container's `~/.lz/bin/`; its loop runs only in the daemon's container, on the tmux socket `lanes-pulse` | `lanes install-container <name>` installs it everywhere and starts the loop in the container `LANES_THROTTLE_CONTAINER` in the server's `~/.config/lanes/site.conf` names, else the Throttle tab's; every update runs install-container again for each container `containers.json` records and for that daemon container (the same site.conf resolution); statusd re-arms a stopped loop at most once per 10 min, never while the host is critical |
1155
+ | The pulse file (DM-LANES-THROTTLE-PULSE) | the daemon container's `~/.lz/state/throttle.json` | the pulse, by rename, when a value changed or 60 s passed; read by statusd through `/proc/<init>/root` (one stat per cycle, ≤ 64 KiB, no link) |
1156
+ | `throttle`, `fleet` in `status.json` (DM-LANES-STATUS, DM-LANES-FLEET) | `~/.local/state/lanes/status.json` | statusd: `throttle` copied with `stale` (pulse `at` > 90 s old, or the file absent or refused); `fleet` every 10 s from `/proc` and the containers' cgroups, skipped while the host is critical |
1157
+ | `~/.lz/throttle-token` (opt-in, 0600) | the daemon container's home | the operator only: `lanes throttle adopt-token <container>` (the container's local Throttle client token, never an Anthropic credential); read by the pulse for the guarded `/pools/<pool>/status` |
1158
+ | `LANES_FLEET_DASHBOARD_URL` | the server's `~/.config/lanes/site.conf` | the operator only, once an authenticated Grafana exists; https only (`bin/lanes` `fleet_dashboard_url`); no code path writes it; `lanes doctor` shows it, unset, or why it is ignored |
1159
+ | `lanes doctor`'s `throttle pulse:`, `throttle pools:` (1.5.0), `fleet:`, `fleet dashboard:` lines, and its harness mix `lanes: claude N · codex N · …` (1.5.0) | — | `bin/lanes` `fleet_doctor` and `harness_line`, from `status.json`, the registry's Throttle tab and site.conf (it never asks the daemon, starts the pulse or runs docker); a `⚠` at worst — a pulse that is absent, down or more than 90 s old by `at` (statusd's bound, DEF-1941), a configured pool with no usable account, a `fleet` that is stale or absent, a dashboard URL that is refused — never a `✗` |
1160
+
839
1161
  ## Access
840
1162
 
841
1163
  Clients log in to one Unix account over SSH (tailnet, or the `lanes-public` fallback). Who may do so is the
@@ -880,8 +1202,9 @@ owner's look on that device instead of spreading it into the new owner's profile
880
1202
  decides whenever one exists, so a claim never overrides one), and it, `lanes keys adopt --login` and `lanes keys
881
1203
  owner` fold the devices they name; `lanes user list|show` read the store. The sidebar of a device with an owner
882
1204
  (cockpit v3 R3) resolves that owner exactly as `lanes attach` does (`users.login_for`, at start and whenever the device
883
- file changes — a claim, a hand-over) and adds the profile's mtime to its reload stamp, the one extra stat per tick an
884
- owned device pays: on a change it re-reads the profile, merges its opened-lane marks into its own (the later per lane)
1205
+ file changes — a claim, a hand-over) and adds the profile's `(st_ino, st_mtime_ns, st_size)` to its reload stamp, the
1206
+ one extra stat per tick an owned device pays (`users.update` replaces the file by rename, so every write has a new
1207
+ inode and two writes inside one timestamp tick are both seen, DEF-1972): on a change it re-reads the profile, merges its opened-lane marks into its own (the later per lane)
885
1208
  and paints its look over the device file's (`layered_look`: `--device-only` keys → profile → unmarked device keys →
886
1209
  environment → defaults).
887
1210
  Opening a lane there (`open_lane`, or `lanes next|prev`) writes the device's own `clients/<device>.seen.json` as before