@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.
- package/package.json +1 -1
- package/payload/lanes/README.md +7 -5
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +1091 -76
- package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
- package/payload/lanes/container/hooks/grok-hooks.json +12 -0
- package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
- package/payload/lanes/container/lane +55 -8
- package/payload/lanes/container/lane-mcp +120 -22
- package/payload/lanes/container/lane-signal +86 -23
- package/payload/lanes/container/lane_core.py +524 -36
- package/payload/lanes/container/throttle-pulse +456 -0
- package/payload/lanes/docs/ARCHITECTURE.md +357 -34
- package/payload/lanes/docs/DESIGN.md +13 -3
- package/payload/lanes/docs/INSTALL.md +47 -10
- package/payload/lanes/docs/RUNBOOK.md +761 -12
- package/payload/lanes/docs/USERGUIDE.md +421 -46
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/host-install.sh +39 -5
- package/payload/lanes/libexec/agent_state.py +604 -12
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/economy_poll.py +6 -2
- package/payload/lanes/libexec/harness_grok.py +742 -0
- package/payload/lanes/libexec/lanes_lib.py +143 -20
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +5 -0
- package/payload/lanes/libexec/notify.py +65 -5
- package/payload/lanes/libexec/sidebar.py +626 -25
- package/payload/lanes/libexec/statusd.py +530 -32
- package/payload/lanes/libexec/tokens.py +42 -9
- package/payload/lanes/libexec/update.py +171 -26
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +31 -3
- package/payload/lanes/site/site.example.conf +4 -0
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +86 -8
- package/payload/lanes/web/browser.js +2 -4
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.js +3 -2
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- 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}]}
|
|
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`) >
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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>`
|
|
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
|
|
332
|
-
reads the two projections the host writes into `~/.lz` and the container's own tmux,
|
|
333
|
-
container: a repository's `.vscode/terminals.json`, a tmux session, the lane's hook
|
|
334
|
-
the boards through the host's ordinary readers (`lanes sync`,
|
|
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.
|
|
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
|
|
443
|
-
|
|
444
|
-
readers and the hints of the
|
|
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
|
|
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.
|
|
630
|
-
|
|
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),
|
|
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
|
|
884
|
-
|
|
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
|