@vibelord/lanes 1.3.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -195,7 +195,7 @@ lanes keys revoke <device> remove one machine lanes keys adopt [--dry-run]
195
195
  lanes invite <name> --email ADDR one-time join code (own terminal) lanes invite list | revoke <id>
196
196
  lanes enrol list open access requests lanes enrol approve <id> | deny <id> decide one
197
197
  lanes offboard <device> --yes key, device, view (own terminal) lanes token revoke <view> sign a browser out
198
- lanes onboard <login> --container <name> [--workspace <id> --repo <path>] [--write] a user's container, dry run first
198
+ lanes onboard <login> --container <name> [--workspace <id> --repo <path>] [--step kimi-notify|grok-build] [--write] a user's container, dry run first
199
199
  systemctl --user status lanes-statusd lanes-sync.timer lanes-ensure.timer lanes-web lanes-enrold
200
200
  lanes update --status the last update and the last check (update/last.json) lanes update --now check and install now
201
201
  lanes update --rollback the release before again, the one left marked bad systemctl --user status lanes-update.timer
@@ -378,7 +378,16 @@ lanes studio-host re-key --yes after the owner revoked the Host: a new mac
378
378
  stderr. Writes are atomic and only on a change; the file stays 0600 and `~/.ssh` 0700. A non-default sshd
379
379
  `AuthorizedKeysFile` does not read this block.
380
380
 
381
- - **A view looks wrong / stuck:** `lanes rebuild mac`. Views are disposable; agents are untouched; the client reconnects.
381
+ - **A desktop stops accepting keys while SSH stays connected:** reconnect that window's display first (close and
382
+ reopen the Lanes window, or terminate only its verified SSH display child so `lanes-launch` retries). Do not
383
+ restart holders or containers. `lanes attach` now supervises its Zellij client with `libexec/attach.py`: output
384
+ waiting to reach the terminal is limited to 1 MiB and an oldest pending chunk age of 5 s. Either bound ends only
385
+ that client (TERM, then KILL after 1 s if needed), exits 1, and lets `lanes-launch` reconnect to the existing view.
386
+ Idle terminals have no deadline; input, resizing and normal exit codes pass through. A client that cannot drain
387
+ its terminal cannot be rescued by SSH keepalives alone (DEF-1965). This guard covers desktop SSH attachments;
388
+ Zellij's browser transport does not use it. Already attached desktops pick it up on their next attachment.
389
+ - **A view's layout looks wrong:** `lanes rebuild mac`. Views are disposable; agents are untouched; the client
390
+ reconnects. Rebuilding also ends that view's Host tab shell, so prefer a display reconnect for a frozen client.
382
391
  - **A lane was added/renamed/removed in a repo's `.vscode/terminals.json`:** nothing to do. `lanes-sync.timer`
383
392
  re-reads registries every 2 minutes and the sidebar follows. The zellij layout is never edited live. A sync that
384
393
  finds nothing new leaves `registry.json` (and its `generated_at`) untouched and only records the time in
@@ -493,7 +502,7 @@ lanes studio-host re-key --yes after the owner revoked the Host: a new mac
493
502
  and phone) > the legend ` ⏎ open · / find · x close · ? keys` (phone: ` Swipe to scroll ·
494
503
  Tap to open`). The last
495
504
  message lasts 3 s (`MESSAGE_S`, measured on the 1 s tick: `Sidebar.expire_message`) or until the next action begins
496
- (`Sidebar.begin_action`: open, `x`/`×`, `R`, `P`, `a`/`+`, `u`, `z`, `p`, `h`, `H`, `o`, `/`, `U`, `+`, `r`, `?`, `Tab`, a chord) — never just until
505
+ (`Sidebar.begin_action`: open, `x`/`×`, `R`, `P`, `a`/`+`, `u`, `z`, `p`, `h`, `H`, `o`, `i`, `/`, `U`, `+`, `r`, `?`, `Tab`, a chord) — never just until
497
506
  a key: a cursor move, typing in a field, `Esc`, a focus report, the theme's OSC 11 answer, a wheel tick or a mouse
498
507
  release leaves it (DEF-1822; DEF-1815 before it kept it from the focus report only). `?` shows
499
508
  the key sheet (` lanes <current> · view <tree>` first — cockpit v3 V2 — then `sidebar.KEY_SHEET`, 18 lines since R5's
@@ -932,6 +941,7 @@ only normalizes HOW a terminal reaches its lane:
932
941
 
933
942
  ## Installing agent hooks (opt-in, per container, dry run first)
934
943
 
944
+ This section is Claude Code's and Codex's; Kimi Code, Grok Build and grok-cli (1.5.0) are in [Harnesses](#harnesses) below.
935
945
  Without hooks the sidebar's agent glyph comes from Claude's session files and Codex's pane title. Hooks make it
936
946
  event-driven: Claude Code or Codex runs `~/.lz/bin/lane-signal <status>` on a turn event, which writes
937
947
  `~/.lz/state/hooks/<lane id>.json` in the container, and `lanes-statusd` prefers that over the other signals while
@@ -993,6 +1003,242 @@ next tool call: after a denied prompt, or an approved call that failed (`Permiss
993
1003
  write nothing). A lane whose agent crashed loses its sidecar at once (dead pid); one
994
1004
  whose agent is alive but silent keeps the last word for up to an hour.
995
1005
 
1006
+ ## Harnesses
1007
+
1008
+ Since 1.5.0 (W12, spec `docs/superpowers/specs/2026-10-15-lanes-harness-observers-design.md`) Lanes reads Kimi Code,
1009
+ Grok Build and grok-cli lanes as well as Claude Code and Codex ones; what each status means for the person at the
1010
+ board is the user guide's [Harnesses](USERGUIDE.md#harnesses). This section is the operator's: what to install per
1011
+ container, how statusd decides, and how to check each harness. Claude Code's and Codex's hooks are in "Installing
1012
+ agent hooks" above.
1013
+
1014
+ **Per container, once, in order.** Each `hooks install` prints its
1015
+ diff first and writes only with `--write`; each changes only its own entries; `--write` refuses a container whose home
1016
+ this account cannot write for uid 1000, as for Claude and Codex. Every file is kept as `<file>.bak-<UTC>` before it
1017
+ changes, a symbolic link below the container home refuses the file, and in a TOML file only Lanes' own marked block
1018
+ (`# >>> lanes <name> >>>` … `# <<< lanes <name> <<<`) is ever rewritten: to undo, delete the block.
1019
+
1020
+ 1. `lanes install-container <name>` — ships 1.5.0's contract: `lane-signal` (its harness, when no `--harness` or
1021
+ `LANE_HARNESS` names one, read from the parent processes, never assumed `codex`), `lane-mcp` and its editors, and
1022
+ the hook definitions in `~/.lz/hooks/` (`kimi-hooks.toml`, `grok-hooks.json`, `grok-cli-hooks.json`). Unless
1023
+ `--no-mcp`, it also runs `~/.lz/bin/lane mcp install`, which adds the Lanes MCP server for each harness installed
1024
+ there, beside Claude's and Codex's entries (each subsection below names its file).
1025
+ 2. The hooks of each harness the container has, each with its dry run first: `lanes hooks install --agent kimi
1026
+ --container <name>` ([Kimi Code](#kimi-code)), `--agent grok` ([Grok Build](#grok-build), after Grok Build is
1027
+ installed) and `--agent grok-cli` ([grok-cli](#grok-cli)).
1028
+ 3. Optional, the operator's call per container: `lanes onboard <login> --container <name> --step kimi-notify --write`
1029
+ (Kimi Code's bell) and `--step grok-build --write` (installs Grok Build).
1030
+ 4. New agent sessions read the hooks and the MCP entry; a running session keeps what it started with (inferred from
1031
+ how the harnesses read their configuration at start, not measured).
1032
+
1033
+ `lanes onboard` runs step 2 by itself for each harness whose home is in the container (`hooks-kimi`, `hooks-grok`,
1034
+ `hooks-grok-cli`; `skipped` otherwise); `kimi-notify` and `grok-build` run only when named with `--step`
1035
+ (`grok-build` right after the board, before the contract, so the same run writes Grok Build's MCP entry and hooks).
1036
+
1037
+ What each event writes (`agent{}` vocabulary):
1038
+
1039
+ | Harness | Event | status |
1040
+ |---|---|---|
1041
+ | Kimi Code | `UserPromptSubmit`, `TurnStarted`, `PreToolUse`, `PostToolUse`, `PermissionResult` | `working` |
1042
+ | Kimi Code | `PermissionRequest` | `needs_approval` |
1043
+ | Kimi Code | `Stop`, `Interrupt` | `needs_input` |
1044
+ | Kimi Code | `StopFailure` / `SessionStart` / `SessionEnd` | `errored` / `idle` / `shell` |
1045
+ | Grok Build | `UserPromptSubmit`, `PreToolUse`, `PostToolUse` | `working` |
1046
+ | Grok Build | `Notification` (`permission_prompt`) | `needs_approval` |
1047
+ | Grok Build | `Stop`, `StopCancelled`, `Notification` (`idle_prompt`) | `needs_input` |
1048
+ | Grok Build | `StopFailure` / `SessionStart` / `SessionEnd` | `errored` / `idle` / `shell` |
1049
+ | grok-cli | `UserPromptSubmit`, `PreToolUse`, `PostToolUse` | `working` |
1050
+ | grok-cli | `Stop` / `StopFailure` / `SessionEnd` | `needs_input` / `errored` / `shell` |
1051
+
1052
+ Verified event names (2026-10-15, this host): Kimi Code 2.1.1's `HOOK_EVENT_TYPES` and its strict `[[hooks]]` schema
1053
+ (`event`, `matcher?`, `command`, `timeout?` 1–600) in its binary; grok-cli 1.1.7's hook events, its exact-string
1054
+ matcher, `sh -c` with its own environment, and its MCP server entry (`{id, label, enabled, transport, command, args,
1055
+ env}`, client name `grok-cli-<id>`) in the source its binary carries; Grok Build 1.0.52's events from its source and
1056
+ user guide (`docs/superpowers/specs/evidence/2026-10-15-grok-build-measurement.md` §3). Not yet proven on a live,
1057
+ logged-in session: that each harness fires these hooks in a lane as the source says.
1058
+
1059
+ ### The observed harness
1060
+
1061
+ Which harness runs in a lane needs nothing installed: statusd OBSERVES it instead of guessing it from the pane title.
1062
+ It walks the pane's process tree in the container's `/proc` (`/proc/<pid>/task/*/children` and the `comm` of each
1063
+ process, breadth first from the pane's root, at most four levels and 64 processes, no spawn) and publishes
1064
+ `agent.harness` as `claude`, `codex`, `kimi`, `grok` (Grok Build), `grok-cli`, `shell` or `unknown`. The first process
1065
+ whose `comm` is `claude`, `codex`, `kimi`/`kimi-code` or `grok`, or whose executable holds `/claude/versions/`, names
1066
+ it: native Claude Code before 2.1.299 runs with its version as `comm` (`2.1.295`), so its executable names it
1067
+ (DEF-1993; the same rule as `lane-signal`'s and `lane_core`'s). A `grok` is Grok Build when its executable is
1068
+ `…/downloads/grok-linux-<arch>` (`readlink /proc/<pid>/exe`), any other is grok-cli; one whose executable cannot be read
1069
+ is `unknown`. Only shells is `shell`; anything else, a cut tree or an unreadable read is `unknown`. The answer is kept
1070
+ per pane pid (`statusd.HARNESS_CACHE`) and walked again when the pane's own children change or the harness process it
1071
+ found exited or now runs under another `comm`; `shell` and `unknown` are never kept.
1072
+
1073
+ | Harness | Process | Observed without hooks | `source` |
1074
+ |---|---|---|---|
1075
+ | Claude Code | `claude` (or a native file `…/claude/versions/<version>`) | `~/.claude/sessions/<pid>.json` | `claude-session-file` |
1076
+ | Codex | `codex` (under the npm wrapper `node`) | the pane title and the bell | `title` |
1077
+ | Kimi Code | `kimi-code` (or `kimi`) | `~/.kimi-code/sessions/…/state.json` + `agents/main/wire.jsonl` | `kimi-session-file` |
1078
+ | Grok Build | `grok`, exe `$GROK_HOME/downloads/grok-linux-<arch>` | `$GROK_HOME/active_sessions.json` + the session's `events.jsonl` and `summary.json`, else its pane title | `grok-session-file`, `grok-title` |
1079
+ | grok-cli | `grok`, any other exe | nothing: no title, no bell, no pid in its database | — (hooks only) |
1080
+ | any of them, nothing else claiming | — | the process itself: `idle`, `done` with the bell | `process` |
1081
+
1082
+ The order statusd takes them in: a hook sidecar > a Claude session file > a Kimi session file > the Grok Build
1083
+ observer (its session file, else its title) > the Grok Build title on a pane observed as `grok` > the Codex title (only
1084
+ where Codex can be) > the observed harness itself; the window bell turns an idle or finished turn into `done`. When
1085
+ the walk names a harness, that is the published `harness` whatever signal decided the status. Check it:
1086
+
1087
+ ```sh
1088
+ lanes doctor | grep ' lanes: ' # lanes: claude 50 · codex 20 · kimi 3 · grok 0 · grok-cli 0 · shell 0
1089
+ jq '[.lanes[].agent.harness // empty] | group_by(.) | map({(.[0]): length}) | add' ~/.local/state/lanes/status.json
1090
+ ```
1091
+
1092
+ To see what statusd decided for one lane:
1093
+
1094
+ ```bash
1095
+ python3 -c 'import json,sys; a=json.load(open(sys.argv[1]))["lanes"][sys.argv[2]].get("agent"); print(a)' \
1096
+ ~/.local/state/lanes/status.json <lane id>
1097
+ ```
1098
+
1099
+ `harness` wrong: look at the pane's tree the way statusd does, read-only — with `P=/proc/$(cat
1100
+ ~/.local/state/lanes/containers/<name>.pid)/root/proc` and the lane's `pid` from status.json (the pane's root
1101
+ process), `cat $P/<pid>/task/*/children` lists its children, `cat $P/<child>/comm` names each and `readlink
1102
+ $P/<child>/exe` its file. A harness more than four levels below the pane is `unknown`; so is a pane whose tree holds
1103
+ anything but shells and no harness. Before 1.5.0 a Kimi lane read `codex / idle`: the title rule took any title of two
1104
+ or more words for Codex's (DEF-1970).
1105
+
1106
+ ### Kimi Code
1107
+
1108
+ **Observed.** statusd reads the newest session in `~/.kimi-code/sessions/wd_*/` whose `state.json` `cwd` equals the
1109
+ kimi process's working directory (`/proc/<pid>/cwd`). Expect `source: process` instead of `kimi-session-file` when two
1110
+ kimi lanes run in the same directory (they cannot be told apart, DEF-1971 — give each its own worktree, or install the
1111
+ hooks: each lane's sidecar outranks the session file), when the session has written nothing for an hour, or when
1112
+ `~/.kimi-code` or a session directory is a symlink (refused, never followed). Kimi rings no bell by default, so its
1113
+ finished turn shows `needs_input`, not `done`, until `kimi-notify` (below).
1114
+
1115
+ **Hooks** (installed when the container has `~/.kimi-code/`): `lanes hooks install --agent kimi --container <name>`,
1116
+ read the diff, then add `--write`. It writes one marker-delimited `hooks` block of `[[hooks]]` into the container's
1117
+ `~/.kimi-code/config.toml` (from `~/.lz/hooks/kimi-hooks.toml`: PreToolUse, PostToolUse, PermissionRequest,
1118
+ PermissionResult, UserPromptSubmit, TurnStarted, Stop, StopFailure, Interrupt, SessionStart, SessionEnd →
1119
+ `~/.lz/bin/lane-signal … --harness kimi`); running it again replaces that block and nothing else, and the rest of the
1120
+ file is never rewritten. Another writer's `[[hooks]]` stay — Throttle's session observer and its other entries
1121
+ (`throttle setup --install-clients`: provider `throttle-kimi`) included, even when its writer put them inside the
1122
+ block: they are moved just below it. A `hooks` that is not `[[hooks]]` tables refuses. Undo: delete the lines between
1123
+ the markers. **The `.kimi-code` no-touch doctrine** (`docs/plans/workspace-session-registry-native-resume.md`,
1124
+ `src/provider/kimi_device.rs`) is about the Kimi CLI's device identity — its `~/.kimi-code/device_id` slot, which no
1125
+ Lanes or Throttle path reads or writes — not about this block, which is ours (spec §3, recorded).
1126
+
1127
+ **Bell, MCP, trace.** `lanes onboard`'s `kimi-notify` step sets `notification_condition = "always"` in
1128
+ `~/.kimi-code/tui.toml`, in place in Kimi's own `[notifications]` table (its old value kept in a comment), so its bell
1129
+ reaches tmux and the observer marks a finished turn `done`. `lane mcp install` writes `mcpServers.lanes` into
1130
+ `~/.kimi-code/mcp.json` (the Claude shape; the environment passes through). Every canonical recipe that sets
1131
+ `ANTHROPIC_CUSTOM_HEADERS` also sets `KIMI_CODE_CUSTOM_HEADERS='x-throttle-trace: lanes/<ws>/<id>'`: Kimi Code
1132
+ blanks the Anthropic variable.
1133
+
1134
+ ### Grok Build
1135
+
1136
+ **Where it lives.** Lanes expects Grok Build (xAI's official `grok`, 1.0.52 measured) under
1137
+ **`GROK_HOME=~/.grok-build`**, never the default `~/.grok`: that directory belongs to grok-cli (superagent-ai) in our
1138
+ containers, and Grok Build's installer replaces grok-cli's `~/.grok/bin/grok` with a symlink to its own binary. Every
1139
+ lane recipe exports `GROK_HOME=/home/coder/.grok-build`. Install it with `lanes onboard <login> --container <name>
1140
+ --step grok-build --write`: that runs, in the container as uid 1000, the stable-channel recipe
1141
+ (`https://x.ai/cli/stable`, the `.zst` artifact when `zstd` is there, else the plain one; `downloads/grok-linux-x86_64`,
1142
+ the relative link `bin/grok`, `[cli] installer = "internal"`). It edits no shell rc file and links nothing into
1143
+ `~/.local/bin`. The channel publishes no checksum: the receipt's `warn` row carries the download's size and sha256,
1144
+ marked unverified, for the operator to check (DEF-1997). Both CLIs show `grok` as the pane's command; `readlink
1145
+ /proc/<pid>/exe` tells them apart — `~/.grok-build/downloads/grok-linux-x86_64` is Grok Build, `~/.grok/bin/grok` is
1146
+ grok-cli.
1147
+
1148
+ **Observed** (no hook installed; `libexec/harness_grok.py`, ARCHITECTURE "Grok Build and grok-cli"):
1149
+
1150
+ | Signal | Read from | Gives |
1151
+ |---|---|---|
1152
+ | pid → session | `$GROK_HOME/active_sessions.json` (`session_id`, `pid`, `cwd`, `opened_at`) | the session of the pane's grok |
1153
+ | state | the last 64 KiB of `$GROK_HOME/sessions/<URL-encoded cwd>/<session id>/events.jsonl` | `working`, `needs_approval`, `needs_input` (`done` with the bell), `errored`, `idle` |
1154
+ | name | that session's `summary.json` `generated_title` | the lane's task line |
1155
+ | fallback | the pane title (`⚠ Action Required - …`, a braille spinner, `grok`, `<name> - grok`) | the same words, `source: grok-title` |
1156
+
1157
+ statusd hands the observer every pane its walk found running Grok Build, every `shell` pane (a Grok-shaped title over
1158
+ a tree with no grok left is that harness ended: `grok` / `shell`) and every `unknown` pane whose `grok` could not be
1159
+ told apart by its executable (Grok Build only when `active_sessions.json` names its pid), with one title memory per
1160
+ container kept between cycles (`statusd.GROK_TITLES`): the `⚠ Action Required` prefix blinks while the pane is
1161
+ unfocused, so a busy title right after a prefixed one still reads `needs_approval` (DEF-1982).
1162
+
1163
+ **Hooks:** `lanes hooks install --agent grok --container <name>`, then `--write`. It writes
1164
+ `~/.grok-build/hooks/lanes.json` (the whole file is Lanes'; Claude Code's JSON shape, which Grok Build reads from every
1165
+ `$GROK_HOME/hooks/*.json`: `Notification` as two groups, `permission_prompt` → `needs_approval` and `idle_prompt` →
1166
+ `needs_input`, then `Stop`, `StopFailure`, `StopCancelled`, `SessionStart`, `SessionEnd`, `UserPromptSubmit`,
1167
+ `PreToolUse`, `PostToolUse` → lane-signal `--harness grok`) and two blocks in `~/.grok-build/config.toml`. `hooks` sets
1168
+ `[compat.claude] hooks = false, mcps = false` — without it Grok Build also runs the hooks in `~/.claude/settings.json`,
1169
+ which say `--harness claude`, and registers lane-mcp a second time — and `[ui.notifications] condition = "always",
1170
+ method = "bel", events = [turn_complete, approval_required, task_complete, agent_error]`, so the window bell is
1171
+ reliable inside tmux. `trace` sets `[model."grok-4.6"]` and `[model."grok-4.5"]` `env_http_headers = {
1172
+ "x-throttle-trace" = "LANES_TRACE" }` (quoted ids: a bare `[model.grok-4.6]` is the table `grok-4` → `6`, DEF-1995):
1173
+ Grok Build's `[models] extra_headers` is global, one trace for every lane, so recipes export `LANES_TRACE` instead. A
1174
+ `[compat.claude]` or `[ui.notifications]` table of the person's refuses the install: set the keys there by hand. A
1175
+ model they already configure is left out of `trace` and named. The MCP entry is the `mcp` block `lane mcp install`
1176
+ writes (`[mcp_servers.lanes]` with `command` only: Grok Build starts a stdio server with its own environment plus
1177
+ `GROK_SESSION_ID`, the session a `lanes_signal` report and every hook then name), a file edit rather than `grok mcp
1178
+ add`; `mcps = false` comes with the hooks block.
1179
+
1180
+ What needs hooks: the sustained-idle backstop (`Notification` `idle_prompt`), why a turn stopped (`StopFailure`,
1181
+ `StopCancelled`) and a reliable bell. Not handled: leader mode (`[cli] use_leader = true`; DEF-1983, accepted) — the
1182
+ observer answers `unknown` with a note, and statusd then publishes no agent state for the lane until a hook speaks for
1183
+ it.
1184
+
1185
+ To confirm a Grok Build lane is joined, in its container:
1186
+
1187
+ 1. `cat ~/.grok-build/active_sessions.json` — an entry whose `pid` is the pane's grok (`tmux display -p
1188
+ '#{pane_pid}'`, then its child named `grok`). No entry while the lane sits at the welcome screen before a session
1189
+ opens; then only the title speaks.
1190
+ 2. `ls ~/.grok-build/sessions/` — the cwd's directory (the cwd URL-encoded, e.g. `%2Fhome%2Fcoder%2Fsrc%2Fbw-tools`)
1191
+ holding a directory named by that `session_id` with `events.jsonl` and `summary.json`.
1192
+ 3. `tail -n 3 ~/.grok-build/sessions/<dir>/<session id>/events.jsonl` — the last `type` names the state (`turn_ended`
1193
+ with its `outcome`, a `phase_changed`, a `permission_requested`).
1194
+
1195
+ The observer refuses a `GROK_HOME`, session directory or `events.jsonl` that is a symlink, and ignores a stale entry
1196
+ (dead pid, a pid that no longer runs grok, or one that started after the entry was opened). The file shapes come from
1197
+ Grok Build's source and user guide; no logged-in session has been measured yet, so a first live lane is worth checking
1198
+ against the three steps above (DEF-1981). Do not pipe `grok doctor --json` into `head`: 1.0.52 aborts on the closed
1199
+ pipe and leaves a `core` file in the cwd.
1200
+
1201
+ ### grok-cli
1202
+
1203
+ grok-cli has no observable signal at all — no title, no bell, no pid in its database — so a grok-cli lane shows a
1204
+ state only through its hooks, and never `needs_approval`: it has no approval event (DEF-1980). Hooks (installed when
1205
+ the container has `~/.grok/`): `lanes hooks install --agent grok-cli --container <name>`, then `--write`, merges the
1206
+ lane-signal groups into the `hooks` of `~/.grok/user-settings.json` (UserPromptSubmit, PreToolUse, PostToolUse, Stop,
1207
+ StopFailure, SessionEnd; no matchers: grok-cli compares a matcher to the tool name exactly). Only that member changes;
1208
+ the file holds grok-cli's `apiKey`, and a dry run's diff prints any key, token or secret value as `…`. Its MCP entry is
1209
+ the `lanes` item of `mcp.servers` in the same file. grok-cli starts MCP servers with only HOME, LOGNAME, PATH, SHELL,
1210
+ TERM and USER, so `lane-mcp` reads `LANE_ID`, `LANE_WS`, `TMUX` and `TMUX_PANE` from grok-cli's own
1211
+ `/proc/<pid>/environ`, and only when that parent process is a harness (DEF-1992). grok-cli sends no custom headers: its
1212
+ requests carry no trace.
1213
+
1214
+ ### Throttle-side notes
1215
+
1216
+ A trace only matters for a harness attached to a Throttle pool; a running lane gets the recipe's trace variables when
1217
+ it is next created.
1218
+
1219
+ **The fleet's Kimi row after the update.** The 1.5.0 pulse reports the kimi pool (`/pools/kimi/ready`, `/metrics`,
1220
+ and `/pools/kimi/status` when tokened), but an update only ships the new `throttle-pulse` file: `lanes
1221
+ install-container` starts nothing while the `pulse` session runs, so the 1.4.0 loop keeps running and the sidebar's
1222
+ `◆ moonshot` row says `no data` (`lanes doctor`: `kimi ?`) until it is restarted (DEF-2003): `docker exec -u 1000
1223
+ <container> tmux -L lanes-pulse kill-session -t pulse`, then `lanes install-container <container>`.
1224
+
1225
+ **Throttle-side items (the Throttle provider lane's; filed, not built in Lanes):**
1226
+
1227
+ - DEF-2000 — Throttle's managed MCP entry for Kimi is written to `~/.kimi/mcp.json` (`src/cli.rs`
1228
+ `stage_additional_gateway_user_entries` and its remove twin), a path Kimi Code 2.1.1 does not read: it reads
1229
+ `~/.kimi-code/mcp.json`.
1230
+ - DEF-2001 — Throttle synthesizes `cc-session/` and `codex-session/` flows for untraced Claude and Codex requests, but
1231
+ no `kimi-session/` flow: Kimi sends no session id on the Anthropic wire. A Kimi lane's requests join their lane only
1232
+ through the `KIMI_CODE_CUSTOM_HEADERS` trace.
1233
+ - Grok: Throttle has no xAI pool, so there is no Grok fleet row and no Grok trace join. The paid-API lane and the
1234
+ subscription pool, and their gates (O-XAI-key, O-XAI-capture, O-XAI-legal, O-XAI-oauth), are the review's
1235
+ (`docs/plans/2026-10-14-xai-grok-pool-review.md`).
1236
+ - **Operator note, not a defect:** Kimi Code on productbox is not attached to Throttle — no `[pools.kimi]` in its
1237
+ configuration, port 8095 closed — so its requests go to Kimi directly and the fleet's kimi row reads `0/0`.
1238
+ Attaching it is a Throttle step (`throttle accounts add <name> --provider kimi --pool kimi`, then `throttle setup
1239
+ --install-clients`; the [`throttle-kimi-provider`](../../skills/throttle-kimi-provider/SKILL.md) skill), outside
1240
+ Lanes.
1241
+
996
1242
  ## The agents' MCP entries (`lane-mcp`, on by default)
997
1243
 
998
1244
  `lanes install-container <name>` ships `~/.lz/bin/lane-mcp` (with `lane_core.py`) and adds it to both agents in that
@@ -1022,7 +1268,7 @@ hand.
1022
1268
  the file. Same exit 1 and reason as `lanes hooks install` (DEF-1727). A symbolic link at `~/.claude.json`,
1023
1269
  `~/.codex` or `config.toml` is refused too, also by `lane mcp install`: from the host it would resolve outside
1024
1270
  the container. Add the entry to the file it points at by hand.
1025
- - Inside the container: `~/.lz/bin/lane mcp install [--agent claude|codex|all]` prints the diff, `--write` applies it.
1271
+ - Inside the container: `~/.lz/bin/lane mcp install [--agent claude|codex|kimi|grok|grok-cli|all]` prints the diff, `--write` applies it (`all`, the default: Claude Code, Codex and each of Kimi Code, Grok Build and grok-cli whose home is there; a harness named on its own is written even without its home).
1026
1272
  - New agent sessions load the server; a running one keeps its tools. Check from a lane with the agent's own MCP
1027
1273
  listing (`/mcp` in Claude Code). The server's log is `~/.lz/s/logs/lane-mcp.log` (one line per request, rotated
1028
1274
  at 1 MiB; never a capture's text, a sent text or a note).
@@ -1211,7 +1457,7 @@ Settings (environment of the installer — `install/services.py` renders them in
1211
1457
  | Variable | Default | Meaning |
1212
1458
  |---|---|---|
1213
1459
  | `LANES_THROTTLE_CONTROL` | `http://127.0.0.1:19181` | control base URL, resolved in the poller's network namespace (see above); `none` sends no request at all (the log fold still runs) |
1214
- | `LANES_THROTTLE_TOKEN_FILE` | unset | file holding a bearer; a loopback daemon without `remote_key` needs none. Never written to `economy.json` or the cursor |
1460
+ | `LANES_THROTTLE_TOKEN_FILE` | unset | file holding the control bearer (the daemon's `remote_key`, or the local client token `lanes throttle adopt-token` copies). `/status` and `/sessions` are guarded control routes: without it they answer `HTTP 401`, loopback or not — the control port has no loopback exemption (DEF-1930). Never written to `economy.json` or the cursor |
1215
1461
  | `LANES_THROTTLE_CONTAINER` | unset | the container whose home volume holds the daemon's log; the log dir becomes its `/home/coder/.local/share/throttle` (via `containers.json`) |
1216
1462
  | `LANES_THROTTLE_LOG_DIR` | `~/.local/share/throttle` | where `proxy.log` and `proxy.log.<day>` are read (read only); wins over `LANES_THROTTLE_CONTAINER` |
1217
1463
 
@@ -1224,6 +1470,157 @@ when it recovers; the reason is never written to `economy.json`. `economy.json`
1224
1470
  the next poll re-folds today. For a look without the daemon: `LANES_THROTTLE_CONTROL=none lanes economy --once
1225
1471
  --dry-run --status-file status.json --sessions-file sessions.json` with saved documents.
1226
1472
 
1473
+ ## Fleet block and Throttle pulse: operator steps (1.4.0)
1474
+
1475
+ 1.5.0 adds the Kimi pool to the pulse and the block (`◆ moonshot`): after that update restart the pulse once, as
1476
+ "Harnesses" above says (DEF-2003); everything else below is unchanged.
1477
+
1478
+ The sidebar's fleet block (user guide "Fleet") needs nothing from you to show the host and its containers. Its Throttle
1479
+ rows need the pulse running in the daemon's container, and its money and burn figures need one opt-in. In order (the
1480
+ reference follows: "Throttle pulse" for the container side, "Fleet facts" for what statusd publishes):
1481
+
1482
+ 1. **Install 1.4.0.** On an npm-installed server with `lanes-update.timer` on, the publish is the deploy: the update
1483
+ restarts statusd (the new `fleet`/`throttle` keys) and re-runs `lanes install-container` for every container `lanes
1484
+ sync` recorded, and for the daemon's container when sync did not record it, which ships `~/.lz/bin/throttle-pulse`
1485
+ and starts its loop in the daemon's container. Otherwise: `npx @vibelord/lanes@1.4.0 --server --update-only`, or
1486
+ `lanes update --now`.
1487
+ 2. **Check the pulse.** `lanes doctor` prints `throttle pulse: 4 s old (container <c>), daemon up, tokened: no — lanes
1488
+ throttle adopt-token <c>`. `none in status.json` means no pulse yet: the daemon's container is not one `lanes sync`
1489
+ recorded (not on a board), or it is not the one Lanes picked (the Throttle tab's container) — put
1490
+ `LANES_THROTTLE_CONTAINER=<c>` into the server's `~/.config/lanes/site.conf` (the one place Lanes reads it for the
1491
+ pulse: `lanes install-container`, `lanes doctor` and every update's re-ship, re-runs and rollbacks included; never
1492
+ the environment) and run `lanes install-container <c>` once. A name there (or a Throttle tab) whose container docker
1493
+ no longer knows, or that has no home volume, is skipped by every update with a warning (`WARN install-container <c>
1494
+ (skipped: docker has no such container)`; the result stays `ok`): correct or remove the line. `stale` (the pulse's
1495
+ `at` more than 90 s old, the bound statusd uses too) means the loop stopped: statusd starts it again within 10
1496
+ minutes ("The re-arm" below, in its journal), or run `lanes install-container <c>`. `daemon down (connection
1497
+ refused)` is the daemon, not the pulse.
1498
+ 3. **Opt in to money and burn (optional).** `lanes throttle adopt-token <c>` copies the container's own local Throttle
1499
+ client token into `~/.lz/throttle-token`; the pulse then reads the guarded `/pools/<pool>/status` ("The token"
1500
+ below). Every guarded control route needs that bearer, loopback included (DEF-1930): there is no token-free way to
1501
+ read spend. To opt out, delete the file; the next pulse says `tokened: no`.
1502
+ 4. **The dashboard link (only after an authenticated Grafana).** Put `LANES_FLEET_DASHBOARD_URL=https://<host>[:port]
1503
+ [/path]` into the server's `~/.config/lanes/site.conf` once the fleet-management Grafana (`christian-fleet`) is
1504
+ behind authentication — never before: the link would hand every tailnet viewer the URL. No Lanes command writes
1505
+ it. `lanes doctor` prints the URL, `unset`, or `⚠ … ignored — not an https URL`; a query string, a user part or a
1506
+ fragment is refused too.
1507
+ 5. **Laptops with a client from before 1.2.0** do not update themselves: their owner runs `npx @vibelord/lanes@1.4.0`
1508
+ once. Newer clients ask `lanes hello --client-version` and install 1.4.0 within 6 h of the server running it.
1509
+
1510
+ The pulse only reads (GET, no redirect) and writes one file; it never changes the daemon. Removing it: stop the loop
1511
+ as the user it runs as (`docker exec -u 1000 <c> tmux -L lanes-pulse kill-server`) and delete
1512
+ `~/.lz/bin/throttle-pulse`, `~/.lz/state/throttle.json` and `~/.lz/throttle-token` in that container; the block's
1513
+ Throttle rows then show the stale mark. The pulse is part of the container contract, so the next update ships it and
1514
+ starts it in the daemon's container again.
1515
+
1516
+ ## Throttle pulse
1517
+
1518
+ The Throttle daemon binds its own container's `127.0.0.1` (DEF-1525), so the host cannot ask it anything. The pulse
1519
+ asks from inside: `~/.lz/bin/throttle-pulse` (Python 3 stdlib), shipped to every workspace container by `lanes
1520
+ install-container` with the rest of the contract, runs as a loop **only in the daemon's container** and writes what
1521
+ it learns to `~/.lz/state/throttle.json` there, which `lanes-statusd` reads through `/proc/<init>/root` for the
1522
+ sidebar's fleet block.
1523
+
1524
+ **Which container.** `LANES_THROTTLE_CONTAINER=<name>` in `~/.config/lanes/site.conf`, else the container of the
1525
+ registry's Throttle Monitor tab (`tools--throttle-monitor`, the container that hosts most boards' monitor). `lanes
1526
+ install-container <that container>` starts the loop; for any other container it ships the file and says
1527
+ `throttle pulse: installed, not started here — …`.
1528
+
1529
+ **How it runs.** A tmux session `pulse` on the socket `lanes-pulse`, as uid 1000 with `HOME=/home/coder`:
1530
+
1531
+ ```sh
1532
+ docker exec -d -u 1000 -e HOME=/home/coder <container> tmux -L lanes-pulse new -d -s pulse /home/coder/.lz/bin/throttle-pulse --loop
1533
+ ```
1534
+
1535
+ `lanes install-container` asks `tmux -L lanes-pulse has-session -t =pulse` first and starts nothing when the session
1536
+ runs (`throttle pulse: already running in …`); a start docker refuses is printed and makes the exit 1, after
1537
+ everything else was installed. To restart it after an update, end the session and install again:
1538
+ `docker exec -u 1000 <container> tmux -L lanes-pulse kill-session -t pulse`, then `lanes install-container
1539
+ <container>`. Its pane holds the journal: `docker exec -u 1000 <container> tmux -L lanes-pulse capture-pane -p -t
1540
+ pulse` (one line each time the reason a source failed changes, `ok` when it recovers; never the token).
1541
+
1542
+ **What it reads.** Every 10 s, GET only, on `http://127.0.0.1:$LANES_THROTTLE_CONTROL_PORT` (19181 when the
1543
+ container's environment does not set it), 3 s timeout, at most 1 MiB per answer, no redirect followed, no proxy:
1544
+
1545
+ | Route | Guarded | Gives |
1546
+ |---|---|---|
1547
+ | `/pools/claude/ready`, `/pools/codex/ready`, `/pools/kimi/ready` | no | configured / usable / exhausted / auth-failed / benched subscriptions, overflow ready (a `503` is a pool that is not ready; its body is read). A daemon with no Kimi account answers the kimi route with nothing configured; one from before its Kimi pool answers `404` (journalled; the other pools are read) |
1548
+ | `/metrics` | no | `throttle_pool_pressure_phase`, `throttle_pool_spill_fraction`, `throttle_overflow_in_flight`, `throttle_upstream_429_total` per pool |
1549
+ | `/pools/claude/status`, `/pools/codex/status`, `/pools/kimi/status` | yes | per pool: the worst measured 7-day and 5-hour utilization over its subscription accounts, the soonest reset; the overflow spend, enforced daily budget, fraction and alert level — for claude and codex only: Kimi has no paid overflow lane, so `overflow.kimi` stays `null` whatever its status renders |
1550
+
1551
+ `cooling` is what is left of `configured` after usable, exhausted, auth-failed, disabled, billing- or
1552
+ organization-blocked and unhealthy accounts (the daemon's buckets overlap; floored at 0).
1553
+
1554
+ **The token (opt-in, never automatic).** The `status` routes are guarded: the daemon's ControlAuth answers `401`
1555
+ to any request without its bearer, loopback included (DEF-1930). Without a token the pulse reads the open routes only
1556
+ and the burn and money fields stay `null`. The operator opts in once per daemon container:
1557
+
1558
+ ```sh
1559
+ lanes throttle adopt-token <container>
1560
+ ```
1561
+
1562
+ It copies `env.ANTHROPIC_API_KEY` from that container's own `~/.claude/settings.json` — the LOCAL proxy bearer
1563
+ `throttle setup --install-clients` wrote there, which must start `throttle_`; it is never a provider credential — to
1564
+ the container's `~/.lz/throttle-token` (0600, by rename, never through a link) and prints `adopted: …` or why not,
1565
+ never the value. The pulse picks it up on its next cycle and sends it only to the `status` routes. It uses a
1566
+ token file that is not a regular file or is readable by group or other (mode not 0600) as no token, and says so in
1567
+ its journal. To withdraw the opt-in, remove the file: `docker exec -u 1000 <container> rm ~/.lz/throttle-token`. After
1568
+ `throttle setup` changes the client token, adopt it again.
1569
+
1570
+ **How to see it.**
1571
+
1572
+ ```sh
1573
+ docker exec -u 1000 <container> cat /home/coder/.lz/state/throttle.json
1574
+ docker exec -u 1000 -e HOME=/home/coder <container> /home/coder/.lz/bin/throttle-pulse --once # one cycle, printed; exit 1 when the daemon did not answer
1575
+ ```
1576
+
1577
+ `{"v": 1, "at", "up", "tokened", "pools": {"claude", "codex", "kimi"}, "overflow": {"claude", "codex", "kimi"},
1578
+ "error"}` (the spec §2.1 shape, with W12's kimi pool; DM-LANES-THROTTLE-PULSE). The file is rewritten only when a value changed or 60 s passed, so `at` is a
1579
+ heartbeat: an `at` more than 90 s old means the loop stopped (a healthy pulse is read up to about 84 s after its
1580
+ `at`: the 60 s heartbeat lands on the next 10 s cycle, DEF-1941). `up: false` with `error` (`connection refused`,
1581
+ `timed out`, `HTTP 500`, …) means no open route answered; the pools then keep their last values. Utilizations and
1582
+ fractions are 0–1, rounded to three places; resets are epoch seconds.
1583
+
1584
+ ## Fleet facts (`fleet` and `throttle` in status.json, W11)
1585
+
1586
+ statusd publishes two additive blocks in `~/.local/state/lanes/status.json` that the sidebar's fleet block and
1587
+ `lanes doctor` read (they read nothing else for it). No collector, no new service, no new spawn per cycle.
1588
+
1589
+ **`fleet` — the host and its biggest containers.** Every `FLEET_INTERVAL` (10 s) statusd reads, from plain files:
1590
+ the `cpu` line of `/proc/stat`, `MemTotal`/`MemAvailable` from `/proc/meminfo`, `/proc/sys/fs/inotify/max_user_watches`
1591
+ (the ceiling only — watches in use are not counted in the loop), the PSI and load it reads every cycle anyway, and
1592
+ per container recorded in `containers.json` its cgroup `memory.current` and `cpu.stat` `usage_usec`. Rates come from
1593
+ two samples 10 s apart, so right after a statusd restart `system.cpu.utilization` and every `container.cpu.percent`
1594
+ are `null` for one interval. `container.cpu.percent` is of the whole host (`usage_usec` delta ÷ interval ÷ logical
1595
+ cpus × 100): 12 % of a 64-cpu host is about 8 cpus busy. `containers` holds the three biggest by memory. Figures are
1596
+ quantized (1 %, 0.1 GiB, 0.1 load), so `status.json` is rewritten only when one of them visibly moves or at the 15 s
1597
+ heartbeat. On a `critical` host no sample is taken: the last facts stay, `"stale": true`, until the host recovers.
1598
+
1599
+ ```sh
1600
+ jq .fleet ~/.local/state/lanes/status.json # the facts; "stale": true = not sampled since the host went critical
1601
+ jq '.fleet.containers' ~/.local/state/lanes/status.json
1602
+ ```
1603
+
1604
+ **`throttle` — the Throttle pulse's last document.** The pulse ("Throttle pulse" above) writes
1605
+ `~/.lz/state/throttle.json` in the daemon's container and statusd copies it: one `stat` per cycle through
1606
+ `/proc/<init>/root`, a read only when the file moved, at most 64 KiB, never through a link. statusd names the daemon's
1607
+ container as the pulse section's "Which container" says (the tab's container: `jq '.tools'
1608
+ ~/.local/state/lanes/registry.json`) and looks it up again after a `lanes sync` that changed the registry and every 5
1609
+ min (edit site.conf, then wait or `systemctl --user restart lanes-statusd`). No container known = no `throttle` key at
1610
+ all. `"stale": true` means the pulse's `at` is more than 90 s old (a healthy pulse rewrites every 60 s and is read up
1611
+ to about 84 s after its `at`, so 60–90 s is normal; `lanes doctor` and the sidebar use the same 90 s, DEF-1941), the
1612
+ file is absent, or it was refused (a link, a FIFO, more than 64 KiB, no v1 document) — the last values are kept and
1613
+ shown dim.
1614
+
1615
+ **The re-arm (the only spawn this adds).** When the pulse file is older than 90 s (or missing) while the daemon's
1616
+ container runs and the host is not `critical`, statusd — between two cycles, never inside one — runs the start command
1617
+ of "How it runs" above, byte for byte (DEF-1940), at most once per 10 min and bounded to 10 s, and journals it
1618
+ (`journalctl --user -u lanes-statusd | grep 'throttle pulse'`: the file's age, `re-armed (exit N)` and the argv). It
1619
+ starts nothing when `~/.lz/bin/throttle-pulse` is not installed in that container (`lanes install-container <c>` ships
1620
+ it), and it never runs on a `critical` host — a pulse that keeps going quiet on a healthy host is a pulse that
1621
+ crashes: run it by hand in the container (`~/.lz/bin/throttle-pulse` without `--loop`, as "How to see it" above
1622
+ shows) and read its stderr.
1623
+
1227
1624
  ## Push notifications (opt-in)
1228
1625
 
1229
1626
  `lanes-notify.service` sends one push to an [ntfy](https://ntfy.sh) topic when a lane's agent ENTERS needs-you —