@vibelord/lanes 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +4 -3
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +301 -45
  5. package/payload/lanes/container/lane +42 -1
  6. package/payload/lanes/container/lane-mcp +65 -6
  7. package/payload/lanes/container/lane_core.py +178 -0
  8. package/payload/lanes/docs/ARCHITECTURE.md +192 -20
  9. package/payload/lanes/docs/DESIGN.md +6 -3
  10. package/payload/lanes/docs/INSTALL.md +25 -8
  11. package/payload/lanes/docs/RUNBOOK.md +369 -8
  12. package/payload/lanes/docs/USERGUIDE.md +201 -25
  13. package/payload/lanes/holders.lock +5 -0
  14. package/payload/lanes/install/enable-docs.sh +78 -0
  15. package/payload/lanes/install/host-install.sh +39 -5
  16. package/payload/lanes/libexec/docs_store.py +995 -0
  17. package/payload/lanes/libexec/docsd.py +2010 -0
  18. package/payload/lanes/libexec/mdlite.py +575 -0
  19. package/payload/lanes/libexec/members.py +5 -0
  20. package/payload/lanes/libexec/notify.py +65 -5
  21. package/payload/lanes/libexec/sidebar.py +106 -13
  22. package/payload/lanes/libexec/statusd.py +97 -5
  23. package/payload/lanes/libexec/tokens.py +31 -9
  24. package/payload/lanes/libexec/update.py +94 -22
  25. package/payload/lanes/libexec/uploads.py +538 -0
  26. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  27. package/payload/lanes/web/Caddyfile +24 -1
  28. package/payload/lanes/web/README.md +86 -8
  29. package/payload/lanes/web/browser.js +2 -4
  30. package/payload/lanes/web/docs.css +52 -0
  31. package/payload/lanes/web/docs.js +36 -0
  32. package/payload/lanes/web/mobile.js +3 -2
  33. package/payload/lanes/web/upload.css +17 -0
  34. package/payload/lanes/web/upload.js +84 -0
  35. package/payload/manifest.json +4 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibelord/lanes",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Installer for Lanes: shared agent terminals over SSH with Zellij views, registry sync, uploads and a phone interface.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -139,16 +139,17 @@ The installer starts an empty catalog safely; it does not assume anyone's contai
139
139
  | Rename a lane | `R` in the sidebar (Enter saves, Esc cancels; arrows and Tab type nothing in the name, a click on the list cancels it first), or `lanes rename <lane-id> <name>` on the server; the lane id never changes, and your name wins over a `displayName` the repository's `terminals.json` declares |
140
140
  | Add a workspace (board) | `lanes workspaces add <id> --container <name> --repo /home/coder/<repo> [--title T]` on the server prints the catalog and manifest diff; add `--write` to apply, then `lanes sync` ([install](docs/INSTALL.md#2-configure-workspaces-and-editor-sync)) |
141
141
  | Onboard a user's container | `lanes onboard <login> --container <name> --workspace <id> --repo /home/coder/<repo>` on the server prints every step (board, contract, Claude and Codex hooks, Throttle attachment, canonical recipes, sync, doctor); add `--write` to apply them and keep a receipt ([install](docs/INSTALL.md#onboard-a-users-container)) |
142
- | Let an agent see and manage its lanes | The container's agents get the Lanes MCP server, `lane-mcp`, from `lanes install-container <name>` (on by default; `--no-mcp` leaves it out). `lanes_whoami`, `lanes_list`, `lanes_status` and `lanes_capture` read the lanes of that container. `lanes_register`, `lanes_rename`, `lanes_unregister` and `lanes_new` change a lane's declaration, shown on the boards within about 15 s. `lanes_send` types into a sibling lane, and `lanes_signal` reports the agent's own status. Ending, interrupting or attaching a lane stays yours ([architecture](docs/ARCHITECTURE.md#agent-api), [guide](docs/USERGUIDE.md#agent-tools-mcp)) |
142
+ | Let an agent see and manage its lanes | The container's agents get the Lanes MCP server, `lane-mcp`, from `lanes install-container <name>` (on by default; `--no-mcp` leaves it out). `lanes_whoami`, `lanes_list`, `lanes_status` and `lanes_capture` read the lanes of that container. `lanes_register`, `lanes_rename`, `lanes_unregister` and `lanes_new` change a lane's declaration, shown on the boards within about 15 s. `lanes_send` types into a sibling lane, `lanes_signal` reports the agent's own status, and `lanes_publish` puts a file on the docs page (`lane publish <file>` in a shell). Ending, interrupting or attaching a lane stays yours ([architecture](docs/ARCHITECTURE.md#agent-api), [guide](docs/USERGUIDE.md#agent-tools-mcp)) |
143
143
  | See which agents need you | The sidebar sorts **Needs you** (◆ approval, ✗ error, ◇ waiting for a prompt), **Working** (▸) and **Done** (✓, until you open the lane) above the rest; the header counts them in the same glyphs (`◆1 ◇2 ▸6 ✓1`, zeros left out; `N lanes` when no lane runs an agent). The queue holds still: each section keeps the order its lanes entered it, a lane moves up a section at once but down only after the new state has held 8 s (leaving Needs you) or 5 s (leaving Working or Done), so an agent that asks again within seconds of an answer does not jump; the Needs you and Working headers stay drawn, `(0)` included, and the cursor stays on its lane ([how](docs/ARCHITECTURE.md#attention-queue)) |
144
144
  | Read a sidebar row | `▌` cursor · agent glyph · `›` the lane this pane shows (`²`/`³`: shown in pane 2/3; `▲` pinned) · the name (`·` per other viewer, `…` when clipped) · an **age column** on every row (agent turn, else last output: `45s`, ` 3m`, ` 2h`, ` 4d`; amber in **Needs you**) · stream glyph · one tail cell by priority (`?` not observed right now > `⚠` starving: producing output but no request for over 10 min, or a 429 > `↓` downgraded > `$` mostly paid — the economy cells need the opt-in economy poller; a dim `⚠`/`↓` is a frozen last-known figure, not a live alarm) · `×` close. `⋮ N more` ends a list that continues below |
145
145
  | End an unregistered session and its jobs | **×** beside the session, or select it and press `x`: ` › close ‘name’ — y⏎ confirm · Esc cancel`; `y` arms (` — ⏎ confirm`), Enter closes; any other key cancels (typed text closes nothing unless it is `x`, `y`, Enter in a row) |
146
- | Every sidebar key | `?` replaces the list with the key sheet until the next key (that key only closes it): the short form of the [user guide's table](docs/USERGUIDE.md#the-sidebar), first `lanes <installed> · view <running>`, then companion keys on one line (`J K`, `Tab S-Tab`, `+ U`, `u z`, `p h`, `H o`, `r ?`), ending with Ctrl–Alt–L, the chord that brings the keyboard to the sidebar from a lane. The footer legend says ` ⏎ open · / find · x close · ? keys`; one prompt at a time, in one shape on the desktop and the phone (` › close ‘name’ — y⏎ confirm · Esc cancel`, ` › Rename: …█`, ` › Register as: …█` then ` › register ‘name’ — y⏎ confirm`) and a filter counts its matches (` /abc · 3 matches`). What an action says in the footer (` Closed sh`, ` Cancelled`, why it failed) stays 3 s or until your next action, not just until the next key. After an action — a close, rename, register, cancel, `Esc`, `r`, closing `?`, a click that opens no lane — the keyboard goes back to the lane, so what you type next reaches the lane, not the sidebar; a prompt, the filter and the armed upload strip keep it |
146
+ | Every sidebar key | `?` replaces the list with the key sheet until the next key (that key only closes it): the short form of the [user guide's table](docs/USERGUIDE.md#the-sidebar), first `lanes <installed> · view <running>`, then companion keys on one line (`J K`, `Tab S-Tab`, `+ U d`, `u z`, `p h`, `H o`, `r ?`; `d` shows the cursor lane's docs link), ending with Ctrl–Alt–L, the chord that brings the keyboard to the sidebar from a lane. The footer legend says ` ⏎ open · / find · x close · ? keys`; one prompt at a time, in one shape on the desktop and the phone (` › close ‘name’ — y⏎ confirm · Esc cancel`, ` › Rename: …█`, ` › Register as: …█` then ` › register ‘name’ — y⏎ confirm`) and a filter counts its matches (` /abc · 3 matches`). What an action says in the footer (` Closed sh`, ` Cancelled`, why it failed) stays 3 s or until your next action, not just until the next key. After an action — a close, rename, register, cancel, `Esc`, `r`, closing `?`, a click that opens no lane — the keyboard goes back to the lane, so what you type next reaches the lane, not the sidebar; a prompt, the filter and the armed upload strip keep it |
147
147
  | Pin, hide | `p` pins the cursor lane (it leads its section, `▲`, and never falls below `⋮ N more`), `h` hides it, `o` shows the hidden lanes again (faint), `H` then `y` `Enter` hides the whole workspace — its tab closes, its lanes keep running. They are yours, not the device's: every device you own follows your profile on its next tick; a session under **Unregistered** is never hidden ([guide](docs/USERGUIDE.md#the-sidebar)) |
148
148
  | Jump to the next lane that needs you | `Tab` / `Shift-Tab` in the sidebar (cycles **Needs you** from the lane the pane shows; the pane title carries the counts and a lane that starts needing you raises one OSC 9 desktop notification on desktop layouts — `lanes-launch <device> --notify 0` (or `lanes attach <device> --notify 0` on the server) silences it for that device, live; `LANES_NOTIFY=0` in the shell that creates a view session does the same for it, see the [user guide](docs/USERGUIDE.md#9-appearance)) |
149
149
  | Other workspaces that need you | The row above the footer rule: ` ◆1 Worlds · ◇2 Code` (blank and absent when none; panes of 18 rows or more) |
150
150
  | What the fleet spends | With the opt-in economy poller ([RUNBOOK](docs/RUNBOOK.md#economy-projection-opt-in)) the same row is the fleet economy strip instead: ` $8.5k/16k 53% · F2 S1 · spill 76% · free 3/19` — overflow spend against the daily cap (amber at the alert level), new upstream 429s, this workspace's lanes by model tier (`F` fable, `O` opus, `S` sonnet, `H` haiku, `G` gpt), the pool's spill fraction, free accounts and the next 5 h reset, whole parts dropped from the end to fit; a leading `?` means the figures are frozen — the poller could not reach the daemon, or it stopped (no rewrite for over 3 min) |
151
151
  | Upload | Click the one-row upload strip above the footer (` ↑ uploads · click, then drop (U)`), press `U` in the sidebar or Ctrl–Alt–U from anywhere in the tab, then drop anywhere on the window; or use `~/Lanes Uploads/<workspace>/`, or `lanes-upload <files>` |
152
+ | See what an agent published, or upload from a phone | The Docs page, `https://<server>:8443/lanes-docs/`, open only to your own tailnet login: **More → Docs** on a phone or tablet, **Docs** in the laptop's Lanes bar, a ⌘/Ctrl-click on the sidebar's ` ↑ uploads · ▤ docs N` strip, or `d` on a lane for its own link. An agent publishes with `lanes_publish` (or `~/.lz/bin/lane publish <file>` in a lane's shell): Markdown with Mermaid diagrams rendered, HTML and SVG opened in a sandbox, any other file a download, each with a sha256 receipt; the page's drop area lands files in the workspace's `uploads/` with the same receipt. It answers nobody until the operator pins the Serve header check (`lanes enrol mode request`) ([user guide](docs/USERGUIDE.md#docs)) |
152
153
  | Phone view from a client | `lanes-launch web phone` in your own terminal: prepares `<your device>-phone` (phone layout), makes Tailscale Serve forward private HTTPS 8443 when the mapping is missing, prints the private URL with a QR code of it and a login token; scan the code or open the URL in Safari. `sh install/enable-phone.sh --host lanes` is the older one-shot for the device `phone` ([guide](docs/USERGUIDE.md#phone-safari-home-screen)) |
153
154
  | Laptop or tablet browser view | `lanes-launch web laptop` (`<your device>-web`, desktop layout) or `lanes-launch web <device> [--desktop\|--mobile]` from a client; on the server `lanes web <device> [--desktop\|--mobile] [--viewports N] [--token \| --print-only]` (prepares the view, enables the loopback web services, adds the Tailscale Serve `https:8443` mapping when missing, prints the private URL) ([guide](docs/INSTALL.md#laptop-and-tablet-browser)) |
154
155
  | Web login tokens | `lanes token list` (each token with the device it was created for), `lanes token revoke <device>`, `lanes token revoke-all --yes` ([runbook](docs/RUNBOOK.md#login-tokens)) |
@@ -190,7 +191,7 @@ long (agent since-time, or last output). Glyph then ASCII twin (`LANES_GLYPHS=as
190
191
  | --- | --- | --- | --- | --- |
191
192
  | `◆` `!` needs approval | `◇` `?` needs input | `●` `*` output in the last 20 s | `○` `o` quiet | `▌` `\|` cursor · `›` `=` viewing · `²` `2` / `³` `3` shown in viewport 2/3 · `▲` `^` pinned |
192
193
  | `▸` `>` working | `✓` `v` done (until viewed) | `–` `-` no session right now | `!` `!` pane dead | `·` `.` viewers · `…` `~` clipped (end of a name) or on its way — closing, registering, a new shell (column 3, dim) · `×` `x` close |
193
- | `✗` `X` errored | `_` `_` shell · `·` `.` idle | `✗` `x` container down | `?` `?` not observed · `·` `.` helper | `⋮` `:` more rows · `↑` `^` uploads · `↓` `v` drop now |
194
+ | `✗` `X` errored | `_` `_` shell · `·` `.` idle | `✗` `x` container down | `?` `?` not observed · `·` `.` helper | `⋮` `:` more rows · `↑` `^` uploads · `↓` `v` drop now · `▤` `#` docs |
194
195
 
195
196
  Column 34 is one tail cell: `?` stale (last known state shown) > `⚠` `!` starving > `↓` `v` downgraded > `$` mostly
196
197
  paid. Colour is reinforcement, never the only signal; `LANES_COLOR=none` keeps every glyph. Full tables, tokens and
@@ -1 +1 @@
1
- 1.2.0
1
+ 1.3.0
@@ -149,7 +149,14 @@
149
149
  lanes upload <ws> | --active <device> [--paste]
150
150
  read a tar stream on stdin into that board's uploads/ folder (the same folder Cursor
151
151
  users drop files into); prints the in-container paths. --paste also types the path(s)
152
- into the lane that device is looking at. `lanes workspaces` lists board ids.
152
+ into the lane that device is looking at. `lanes workspaces` lists board ids. Each file
153
+ also goes to the docs store with a sha256 receipt (stderr `# docs:` lines), for the
154
+ login that owns the device; the Docs page's upload (POST /up) lands the same way.
155
+ lanes docs push <id> the hook for pushing one docs-store document to the data service's publication surface
156
+ — NOT BUILT: it prints the document's receipt (receipt.json: id, sha256, bytes, url,
157
+ at; looked up by id in every login's store, for the operator), says `not configured`
158
+ on stderr and exits 2. It sends nothing and writes nothing. Exit 1: no document with
159
+ that id, an id that is not one, or another argument (this usage)
153
160
  lanes cursor-sync [ws…] [--write] [--diff] [--canonicalize] [--identity]
154
161
  make Cursor/VS Code terminals open exactly the board's lanes through `lane open`
155
162
  (dry-run by default: prints the git-style diff; --write applies with a backup).
@@ -170,7 +177,7 @@
170
177
  current ✓ · statusd runs 1.1.0 ✗ · enrold notify ✓` — this tree's VERSION and the npm
171
178
  stamp's commit, the tree, whether ~/.local/share/lanes/current names it (✗ with the
172
179
  tree it does name), then the tree each long-running process recorded at its last start
173
- (~/.local/state/lanes/run/<name>.tree: statusd, enrold, notify, economy, studio_host,
180
+ (~/.local/state/lanes/run/<name>.tree: statusd, enrold, docsd, notify, economy, studio_host,
174
181
  the newest sidebar), ✗ for one that is not the current tree (`runs 1.1.0 ✗`) or that
175
182
  started before its tree's code (VERSION, bin/, libexec/) last changed — a checkout
176
183
  updated in place by `git pull` keeps its path (`started before its tree changed ✗`) —
@@ -269,6 +276,11 @@
269
276
  lanes-web / lanes-mobile units (⚠ not enabled, ✗ enabled but not running), the login-token
270
277
  database the running lanes-web reads (✗ when it is not the one `lanes token` uses) and the
271
278
  Tailscale Serve https:8443 mapping (⚠ not checked on a host without a Tailscale name);
279
+ the docs service: lanes-docsd enabled, active and answering on its loopback port (⚠
280
+ not enabled, ✗ enabled but not running or not answering), the pin gate (⚠ until `lanes
281
+ enrol mode request`), the store (documents, bytes, docs.log), the Mermaid pin (⚠ not
282
+ installed: install/enable-docs.sh; ✗ not the pinned build) and the age switch (✗ set
283
+ without an age binary);
272
284
  then the release that runs: ~/.local/share/lanes/current names a tree whose VERSION
273
285
  agrees with its pkg/<version> name and npm stamp and that this lanes runs (⚠ without the
274
286
  link: the rest compares with this tree); every lanes process that runs now has a run
@@ -777,9 +789,18 @@ def hold_sessions(stack, timeout, doing):
777
789
  return None
778
790
 
779
791
 
792
+ # The web front's own path roots (web/Caddyfile): a view session is served at /<device>, so a device of one of these
793
+ # names would sit on a route a service owns — the enrolment service (/enrol/*), the docs service (/lanes-docs/*) and
794
+ # the shell's assets (/lanes-assets/*). Compared casefolded: Caddy's path matcher ignores case.
795
+ RESERVED_DEVICES = ('enrol', 'lanes-assets', 'lanes-docs')
796
+
797
+
780
798
  def device_name(value):
781
799
  if not L.IDENT.match(value or '') or len(value) > 32:
782
800
  raise L.LanesError('Invalid device name')
801
+ if value.casefold() in RESERVED_DEVICES:
802
+ raise L.LanesError(f'Invalid device name: {value} is a path the web front reserves '
803
+ f'({", ".join(RESERVED_DEVICES)})')
783
804
  return value
784
805
 
785
806
 
@@ -2129,8 +2150,12 @@ class UploadReader:
2129
2150
 
2130
2151
 
2131
2152
  def cmd_upload(args):
2153
+ """`lanes upload <workspace> | --active <device> [--device D] [--paste] [--request R]`: a tar stream on stdin into
2154
+ the workspace's `uploads/` AND the docs store (W10 W5, libexec/uploads.py receive_tar): each file lands as it always
2155
+ did — same folder, `-1` collision suffix, `.git/info/exclude` line — and gets a sha256 receipt, `source` ssh, for
2156
+ the login that owns the asking device (users.login_for; none: a docs.log `no-owner` line), under the lane its view
2157
+ shows (uploads.upload_lane). stdout and `<request>.done` stay the landed in-container paths, one per line."""
2132
2158
  import shlex
2133
- import tarfile
2134
2159
  registry = L.load_registry()
2135
2160
  device = args[args.index('--active') + 1] if '--active' in args else None
2136
2161
  paste_device = args[args.index('--device') + 1] if '--device' in args else device
@@ -2151,34 +2176,20 @@ def cmd_upload(args):
2151
2176
  raise L.LanesError(f'unknown board: {names[0]} (see `lanes workspaces`)')
2152
2177
  else:
2153
2178
  raise L.LanesError('usage: lanes upload <workspace> | --active <device> [--paste] (tar stream on stdin)')
2154
- folder = next((lane.get('folder') for lane in workspace['lanes'] if lane.get('folder')), None) or workspace['cwd']
2155
- info = registry['containers'].get(workspace['container']) or {}
2156
- root = L.host_path(info, folder)
2157
- uploads = root / 'uploads'
2158
- uploads.mkdir(exist_ok=True)
2159
- # Keep uploads out of commits without touching a tracked .gitignore: the repository-local exclude file.
2160
- exclude = root / '.git' / 'info' / 'exclude'
2161
- # check-ignore: 0 = ignored, 1 = not ignored; an error or no answer (None) is not a reason to append.
2162
- if (root / '.git').is_dir() and L.run_bounded(['git', '-C', str(root), 'check-ignore', '-q', 'uploads/x'], 10)[0] == 1:
2163
- exclude.parent.mkdir(exist_ok=True)
2164
- with open(exclude, 'a') as handle:
2165
- handle.write('\n# lanes upload: local intake folder, never committed\nuploads/\n')
2166
- staging = uploads / f'.incoming-{os.getpid()}'
2167
- staging.mkdir()
2168
- landed = []
2179
+ uploads_lib, users = libexec_module('uploads'), libexec_module('users')
2180
+ folder, home, parts = uploads_lib.landing(registry, workspace)
2181
+ # The view that asked (a suffixed view's sidecar, else the device named): whose upload it is, and whose lane.
2182
+ view_device = paste_target(spool, request, paste_device) if spool else paste_device
2183
+ login = users.login_for(view_device) if view_device else None
2184
+ lane = uploads_lib.upload_lane(workspace, view_device)
2185
+ uploads = uploads_lib.open_uploads(home, parts)
2169
2186
  try:
2170
2187
  stream = UploadReader(sys.stdin.buffer, spool / f'{request}.progress' if spool else None)
2171
- with tarfile.open(fileobj=stream, mode='r|') as archive:
2172
- archive.extractall(staging, filter='data') # regular files/dirs only, no absolute paths, no links out
2173
- for entry in sorted(staging.iterdir()):
2174
- final, n = uploads / entry.name, 1
2175
- while final.exists(): # never overwrite an earlier upload
2176
- final = uploads / f'{entry.stem}-{n}{entry.suffix}'
2177
- n += 1
2178
- entry.rename(final)
2179
- landed.append(f"{folder}/uploads/{final.name}")
2188
+ landed = [f'{folder}/uploads/{name}' for name in uploads_lib.receive_tar(
2189
+ stream, uploads, login=login, ws=workspace['id'], lane=lane,
2190
+ say=lambda line: print(line, file=sys.stderr, flush=True))]
2180
2191
  finally:
2181
- shutil.rmtree(staging, ignore_errors=True)
2192
+ os.close(uploads)
2182
2193
  if not landed:
2183
2194
  # The helper reports the client-side cause (eg. macOS denied Downloads). Publishing a generic .err here
2184
2195
  # races that report: the sidebar consumes it and loses the useful error before the helper can send it.
@@ -2187,7 +2198,6 @@ def cmd_upload(args):
2187
2198
  print(path)
2188
2199
  if '--paste' in args and paste_device:
2189
2200
  # The result files (.done / .err) stay in the base spool, where the asking sidebar looks for them.
2190
- view_device = paste_target(spool, request, paste_device) if spool else paste_device
2191
2201
  shown = type_into_lane(registry, device_name(view_device), workspace, ' '.join(shlex.quote(p) for p in landed) + ' ')
2192
2202
  print(f'# typed into lane: {shown}' if shown else '# could not type into the lane (not a tmux lane?)', file=sys.stderr)
2193
2203
  if spool:
@@ -2195,6 +2205,32 @@ def cmd_upload(args):
2195
2205
  return 0
2196
2206
 
2197
2207
 
2208
+ DOCS_USAGE = 'usage: lanes docs push <id>'
2209
+ # What a publication target needs and does not have yet: the data service's token-authenticated publication surface
2210
+ # (plan W10 "Operator gates" 3 — decided once that surface is stable). Until then `lanes docs push` is the hook only.
2211
+ DOCS_PUSH_UNCONFIGURED = ('lanes docs push: not configured — no publication target exists yet (the data service\'s '
2212
+ 'publication surface is not built; plan W10, operator gate 3): nothing was sent')
2213
+
2214
+
2215
+ def cmd_docs(args):
2216
+ """`lanes docs push <id>` (docs cockpit W10 W9, LNS-110): the hook for the data service's publication surface — a
2217
+ STUB, not built. It looks the document up in every login's docs store (docs_store.operator_receipt: the operator is
2218
+ the account the store belongs to, so no login's visibility applies), prints its receipt.json on stdout
2219
+ (DM-LANES-DOCS-RECEIPT, as stored: indent 2), says `not configured` on stderr and returns 2. Nothing is sent,
2220
+ written or logged. No such document, an id that is not one (docs_store.ID_RE) or any other argument: LanesError,
2221
+ exit 1."""
2222
+ if len(args) != 2 or args[0] != 'push':
2223
+ raise L.LanesError(DOCS_USAGE)
2224
+ D = libexec_module('docs_store')
2225
+ found = D.operator_receipt(args[1])
2226
+ if found is None:
2227
+ raise L.LanesError(f'docs push: no document {args[1]} in the docs store ({D.root()})')
2228
+ _meta, receipt = found
2229
+ print(json.dumps(receipt, indent=2, ensure_ascii=False))
2230
+ print(DOCS_PUSH_UNCONFIGURED, file=sys.stderr)
2231
+ return 2
2232
+
2233
+
2198
2234
  def resolve_device(name, args):
2199
2235
  """`coder-launch worlds` style: a board id given where a device is expected means "my default device"."""
2200
2236
  try:
@@ -2768,7 +2804,7 @@ def cmd_hello(args):
2768
2804
 
2769
2805
  # The record_tree names, in the order `lanes version` lists them: statusd first (every view's status comes from it),
2770
2806
  # then the services, then the newest sidebar. A name not listed (a newer tree's process) follows, sorted.
2771
- RUN_ORDER = ('statusd', 'enrold', 'notify', 'economy', 'studio_host', 'sidebar')
2807
+ RUN_ORDER = ('statusd', 'enrold', 'docsd', 'notify', 'economy', 'studio_host', 'sidebar')
2772
2808
  VERSION_SCHEMA = 'lanes.version/1'
2773
2809
  COMMIT_RE = re.compile(r'([0-9a-f]{7})[0-9a-f]{0,33}(-dirty)?') # scripts/build-payload.mjs: rev-parse HEAD [-dirty]
2774
2810
  # A tree's code, as far as a recorded process runs it: VERSION and the regular files directly in these folders (every
@@ -3919,6 +3955,25 @@ def cmd_device(args):
3919
3955
  return 0
3920
3956
 
3921
3957
 
3958
+ def unit_properties(units):
3959
+ """One bounded `systemctl --user show` of `units` (Id, LoadState, ActiveState, UnitFileState, MainPID) ->
3960
+ ({unit: {property: value}}, None), or (None, why) when systemctl did not answer or failed. Read-only."""
3961
+ argv = ['systemctl', '--user', 'show', '-p', 'Id', '-p', 'LoadState', '-p', 'ActiveState', '-p', 'UnitFileState',
3962
+ '-p', 'MainPID', *units]
3963
+ rc, out, err = L.run_bounded(argv, 10, env=clean_env())
3964
+ if rc != 0:
3965
+ return None, 'did not answer in 10 s' if rc is None else err.strip() or f'exit {rc}'
3966
+ found = {}
3967
+ for block in out.strip().split('\n\n'):
3968
+ props = dict(line.partition('=')[::2] for line in block.splitlines() if '=' in line)
3969
+ found[props.get('Id', '')] = props
3970
+ return found, None
3971
+
3972
+
3973
+ def unit_enabled(props):
3974
+ return props.get('UnitFileState', '').startswith(('enabled', 'linked', 'alias'))
3975
+
3976
+
3922
3977
  def web_doctor():
3923
3978
  """`lanes doctor`'s browser/phone lines -> [(mark, text)], mark ✓ | ⚠ | ✗ (LNS-50). One bounded
3924
3979
  `systemctl --user show` for both web units, a read of the running lanes-web's environment, `tailscale status --json`
@@ -3932,20 +3987,14 @@ def web_doctor():
3932
3987
  if not shutil.which('systemctl'):
3933
3988
  lines.append(('⚠', 'web units not checked — no systemctl on this host (`lanes web` needs systemd user services)'))
3934
3989
  else:
3935
- argv = ['systemctl', '--user', 'show', '-p', 'Id', '-p', 'LoadState', '-p', 'ActiveState', '-p', 'UnitFileState',
3936
- '-p', 'MainPID', *WEB_UNITS]
3937
- rc, out, err = L.run_bounded(argv, 10, env=clean_env())
3938
- if rc != 0:
3939
- lines.append(('✗', f"web units NOT checked — systemctl --user {'did not answer in 10 s' if rc is None else err.strip() or f'exit {rc}'}"))
3990
+ units, why = unit_properties(WEB_UNITS)
3991
+ if units is None:
3992
+ lines.append(('✗', f'web units NOT checked — systemctl --user {why}'))
3940
3993
  else:
3941
- units = {}
3942
- for block in out.strip().split('\n\n'):
3943
- props = dict(line.partition('=')[::2] for line in block.splitlines() if '=' in line)
3944
- units[props.get('Id', '')] = props
3945
3994
  for unit in WEB_UNITS:
3946
3995
  props = units.get(unit, {})
3947
3996
  active = props.get('ActiveState') or 'unknown'
3948
- enabled = props.get('UnitFileState', '').startswith(('enabled', 'linked', 'alias'))
3997
+ enabled = unit_enabled(props)
3949
3998
  enabled_any = enabled_any or enabled
3950
3999
  if enabled and active == 'active':
3951
4000
  lines.append(('✓', f'{unit} active'))
@@ -3990,6 +4039,207 @@ def web_doctor():
3990
4039
  return lines
3991
4040
 
3992
4041
 
4042
+ # ------------------------------------------------------------------ doctor: the docs service (W10 W8, LNS-109)
4043
+ DOCS_UNIT = 'lanes-docsd.service'
4044
+ DOCS_PROBE_S = 2 # seconds the loopback connect to lanes-docsd may take
4045
+ ENABLE_DOCS = 'install/enable-docs.sh'
4046
+
4047
+
4048
+ def home_shown(path, home=None):
4049
+ """`path` as `~/…` under the home, else as it is (plain)."""
4050
+ try:
4051
+ return plain(f'~/{Path(path).relative_to(Path(home) if home is not None else Path.home())}')
4052
+ except (ValueError, KeyError, RuntimeError):
4053
+ return plain(path)
4054
+
4055
+
4056
+ def byte_size(count):
4057
+ """A size for a person: `812 B`, `3.4 KiB`, `17.0 MiB`, `1.2 GiB`."""
4058
+ if count < 1024:
4059
+ return f'{count} B'
4060
+ for unit in ('KiB', 'MiB', 'GiB'):
4061
+ count /= 1024
4062
+ if count < 1024 or unit == 'GiB':
4063
+ return f'{count:.1f} {unit}'
4064
+
4065
+
4066
+ def tree_bytes(root):
4067
+ """(regular files, their bytes) under the directory `root`, never through a link (a link at `root` itself counts
4068
+ nothing); (0, 0) when it does not exist. Unreadable entries are skipped."""
4069
+ if os.path.islink(root):
4070
+ return 0, 0
4071
+ files = total = 0
4072
+ stack = [os.fspath(root)]
4073
+ while stack:
4074
+ try:
4075
+ with os.scandir(stack.pop()) as entries:
4076
+ for entry in entries:
4077
+ try:
4078
+ if entry.is_dir(follow_symlinks=False):
4079
+ stack.append(entry.path)
4080
+ elif entry.is_file(follow_symlinks=False):
4081
+ files, total = files + 1, total + entry.stat(follow_symlinks=False).st_size
4082
+ except OSError:
4083
+ continue
4084
+ except OSError:
4085
+ continue
4086
+ return files, total
4087
+
4088
+
4089
+ def docs_port():
4090
+ """(port, None): the port lanes-docsd listens on — LANES_DOCS_PORT from its drop-in (docs_store.unit_environment,
4091
+ the files `lanes upload` reads too), else docsd.DEFAULT_PORT — or (None, why) for a setting that is not a port."""
4092
+ value = libexec_module('docs_store').unit_environment().get('LANES_DOCS_PORT') or ''
4093
+ if not value:
4094
+ return libexec_module('docsd').DEFAULT_PORT, None
4095
+ if value.isdigit() and 0 < int(value) <= 65535:
4096
+ return int(value), None
4097
+ return None, f'LANES_DOCS_PORT={plain(value)[:20]} in its drop-in is not a port number'
4098
+
4099
+
4100
+ def port_answers(port, timeout=DOCS_PROBE_S):
4101
+ """Whether 127.0.0.1:<port> accepts a TCP connection within `timeout` s. It connects and closes without sending a
4102
+ byte, so lanes-docsd answers nothing and writes no docs.log line (its handler reads an empty request line)."""
4103
+ import socket as sockets
4104
+ try:
4105
+ with sockets.create_connection(('127.0.0.1', port), timeout=timeout):
4106
+ return True
4107
+ except OSError:
4108
+ return False
4109
+
4110
+
4111
+ def docs_unit_line(probe):
4112
+ restart = f'journalctl --user -u {DOCS_UNIT}; systemctl --user restart {DOCS_UNIT}'
4113
+ if not shutil.which('systemctl'):
4114
+ return ('⚠', f'{DOCS_UNIT} not checked — no systemctl on this host')
4115
+ units, why = unit_properties([DOCS_UNIT])
4116
+ if units is None:
4117
+ return ('✗', f'{DOCS_UNIT} NOT checked — systemctl --user {why}')
4118
+ props = units.get(DOCS_UNIT, {})
4119
+ active, enabled = props.get('ActiveState') or 'unknown', unit_enabled(props)
4120
+ if active != 'active':
4121
+ if enabled:
4122
+ return ('✗', f'{DOCS_UNIT} enabled but {plain(active)} — {restart}')
4123
+ return ('⚠', f'{DOCS_UNIT} not enabled, so there is no docs page — the full server install enables it (an '
4124
+ f'update with --update-only enables nothing): systemctl --user enable --now {DOCS_UNIT}')
4125
+ port, why = docs_port()
4126
+ if port is None:
4127
+ return ('✗', f'{DOCS_UNIT} active, but {why} — fix it with systemctl --user edit {DOCS_UNIT}')
4128
+ where = f'127.0.0.1:{port}'
4129
+ if not probe(port):
4130
+ return ('✗', f'{DOCS_UNIT} active, but {where} does not answer — {restart}')
4131
+ if not enabled:
4132
+ return ('⚠', f'{DOCS_UNIT} active on {where} but not enabled: a reboot loses it — systemctl --user enable '
4133
+ f'{DOCS_UNIT}')
4134
+ return ('✓', f'{DOCS_UNIT} active, answering on {where}')
4135
+
4136
+
4137
+ def docs_pin_line():
4138
+ """The pin gate as docsd reads it on every request (enrold.read_mode): an operator gate, so never a ✗."""
4139
+ mode, problem = libexec_module('enrold').read_mode()
4140
+ if mode == 'request':
4141
+ return ('✓', 'docs: pinned — lanes-docsd answers members through Tailscale Serve (lanes enrol mode request)')
4142
+ said = problem or ('pinned invite only' if mode == 'invite' else 'unpinned: auto')
4143
+ return ('⚠', f'docs: refuses until the Serve header check is pinned ({plain(said)}): every request is 403 '
4144
+ 'not-pinned — run the check (RUNBOOK "Enrolment service", Operator verification), then lanes enrol '
4145
+ 'mode request (RUNBOOK "Docs service")')
4146
+
4147
+
4148
+ def docs_store_line(home=None):
4149
+ D = libexec_module('docs_store')
4150
+ counts = D.read_counts()
4151
+ documents = sum(entry['n'] for entry in counts.values())
4152
+ _files, size = tree_bytes(D.root())
4153
+ try:
4154
+ log = f'docs.log {byte_size(D.log_path().lstat().st_size)}'
4155
+ except FileNotFoundError:
4156
+ log = 'no docs.log yet'
4157
+ except OSError as error:
4158
+ log = f'docs.log not readable ({error.strerror or error})'
4159
+ return ('', f"docs store: {documents} document{'' if documents == 1 else 's'} in {len(counts)} "
4160
+ f"lane{'' if len(counts) == 1 else 's'} (counts.json), {byte_size(size)} under "
4161
+ f'{home_shown(D.root(), home)}; {log}')
4162
+
4163
+
4164
+ def docs_mermaid_line(home=None):
4165
+ """holders.lock's opt-in `mermaid` pin against the file install/enable-docs.sh installs: the member digest, read
4166
+ without following a link or blocking on a FIFO."""
4167
+ import hashlib
4168
+ SH = libexec_module('studio_host')
4169
+ script = f"sh {home_shown(L.CURRENT, home)}/{ENABLE_DOCS}"
4170
+ try:
4171
+ pin = SH.parse_lock((L.REPO / 'holders.lock').read_text()).get('mermaid')
4172
+ why = 'no mermaid line' if pin is None else '' if pin.opt_in else 'the mermaid line is not an opt-in pin'
4173
+ except (OSError, ValueError, SH.Refusal) as error:
4174
+ pin, why = None, str(error)
4175
+ if why:
4176
+ return ('✗', f'docs: Mermaid not checked — {L.REPO / "holders.lock"}: {plain(why)}')
4177
+ target = L.SHARE / 'vendor' / f'mermaid-{pin.version}.min.js'
4178
+ shown, version = home_shown(target, home), plain(pin.version)
4179
+ try:
4180
+ fd = os.open(target, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | getattr(os, 'O_CLOEXEC', 0))
4181
+ except FileNotFoundError:
4182
+ return ('⚠', f'docs: Mermaid {version} not installed ({shown}), so diagrams show as source — run {script} '
4183
+ '(every server install and update runs it)')
4184
+ except OSError as error:
4185
+ return ('✗', f'docs: Mermaid {version} refused — {shown} is not a regular file '
4186
+ f'({error.strerror or error}) — {script} replaces it from a verified download')
4187
+ try:
4188
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
4189
+ return ('✗', f'docs: Mermaid {version} refused — {shown} is not a regular file — {script} replaces it '
4190
+ 'from a verified download')
4191
+ digest = hashlib.sha256()
4192
+ for chunk in iter(lambda: os.read(fd, 1 << 20), b''):
4193
+ digest.update(chunk)
4194
+ finally:
4195
+ os.close(fd)
4196
+ actual = digest.hexdigest()
4197
+ if actual != pin.binary_sha256:
4198
+ return ('✗', f'docs: Mermaid {version} refused — {shown} is not the pinned build (sha256 {actual[:12]}…, '
4199
+ f'holders.lock {pin.binary_sha256[:12]}…): browsers refuse it by its integrity, so diagrams '
4200
+ f'show as source — {script} replaces it from a verified download')
4201
+ return ('✓', f'docs: Mermaid {version} pinned ({shown}, sha256 {actual[:12]}…)')
4202
+
4203
+
4204
+ def docs_age_line():
4205
+ """The store's encryption switch (docs_store.age_settings: this environment, else lanes-docsd's drop-in, as
4206
+ `lanes upload` reads it) and the `age` on this PATH. The recipient is never printed."""
4207
+ D = libexec_module('docs_store')
4208
+ binary = D.age_binary()
4209
+ has = f'age at {plain(binary)}' if binary else 'no age binary on PATH'
4210
+ try:
4211
+ recipient, identity = D.age_settings()
4212
+ except D.DocsError as error:
4213
+ return ('✗', f'docs: age encryption NOT usable — {error}: every upload and publish fails until it is fixed '
4214
+ f'({home_shown(D.dropin_dir())})')
4215
+ if recipient is None:
4216
+ return ('', f'docs: age encryption off (LANES_DOCS_AGE_RECIPIENT unset; {has})')
4217
+ if binary is None:
4218
+ return ('✗', 'docs: age encryption on (LANES_DOCS_AGE_RECIPIENT set) but there is no age binary on PATH: every '
4219
+ 'upload and publish fails (encrypt-failed) — install age or remove the setting (the server '
4220
+ 'install refuses it)')
4221
+ opens = ('lanes-docsd opens them with LANES_DOCS_AGE_IDENTITY' if identity else
4222
+ 'no LANES_DOCS_AGE_IDENTITY, so their downloads answer 409 encrypted')
4223
+ return ('✓', f'docs: age encryption on — new store copies are age output ({has}); {opens}')
4224
+
4225
+
4226
+ def docs_doctor(home=None, probe=None):
4227
+ """`lanes doctor`'s docs lines (W10 W8, LNS-109) -> [(mark, text)], mark ✓ | ✗ | ⚠ | '' (information), in this
4228
+ order; read-only (one bounded `systemctl --user show`, one loopback connect, file reads):
4229
+ lanes-docsd.service — enabled, active and its loopback port (docs_port) accepting a connection ✓ · enabled but not
4230
+ active, or active while its port refuses ✗ · active but not enabled ⚠ (a reboot loses it) · not enabled ⚠ (the
4231
+ full server install enables it; an update with --update-only enables nothing) · systemctl silent ✗ · no
4232
+ systemctl ⚠. `probe` (port -> bool) stands in for port_answers.
4233
+ the pin gate — `request` ✓ `docs: pinned` · else ⚠ `docs: refuses until the Serve header check is pinned`
4234
+ the store — information: documents and lanes from counts.json, the bytes under docs/, docs.log's size
4235
+ the Mermaid pin — the pinned member digest ✓ · absent ⚠ (run install/enable-docs.sh) · another digest or not a
4236
+ regular file ✗ (refused: browsers refuse it by its integrity) · no valid pin ✗
4237
+ the `age` switch — unset: information · set with an `age` on PATH ✓ · set without one, or no recipient ✗"""
4238
+ home = Path(home) if home is not None else Path.home()
4239
+ return [docs_unit_line(port_answers if probe is None else probe), docs_pin_line(), docs_store_line(home),
4240
+ docs_mermaid_line(home), docs_age_line()]
4241
+
4242
+
3993
4243
  # ------------------------------------------------------------------ doctor: the release that runs (W9 V3, LNS-85)
3994
4244
  # `lanes version` says which tree each process last started from; doctor judges it, with the reference `lanes version`
3995
4245
  # uses (the tree the `current` link names; this tree when there is none). A run record outlives its process, so only a
@@ -4004,8 +4254,8 @@ def web_doctor():
4004
4254
  # <tree>/libexec/sidebar.py …`. Anything else that names the file — an editor, a pager, a grep, the script run by
4005
4255
  # hand another way — and a one-shot that never records (RUN_ONESHOTS) is not that daemon (DEF-1785). studio_host
4006
4256
  # records a oneshot step (install/enable-studio-host.sh), never a running process.
4007
- RUN_SCRIPTS = {'statusd': 'statusd.py', 'enrold': 'enrold.py', 'notify': 'notify.py', 'economy': 'economy_poll.py',
4008
- 'sidebar': 'sidebar.py'}
4257
+ RUN_SCRIPTS = {'statusd': 'statusd.py', 'enrold': 'enrold.py', 'docsd': 'docsd.py', 'notify': 'notify.py',
4258
+ 'economy': 'economy_poll.py', 'sidebar': 'sidebar.py'}
4009
4259
  RUN_INTERPRETER = re.compile(r'python3(?:\.\d+)?') # argv[0]'s name, by path or not: python3, a versioned python3.N
4010
4260
  RUN_FLAGS = ['-S', '-E'] # the interpreter flags of every launch, in this order
4011
4261
  # The arguments that make a run a one-shot that never records, as each record_tree comment names them. statusd reads
@@ -4013,8 +4263,8 @@ RUN_FLAGS = ['-S', '-E'] # the interpreter flags of
4013
4263
  # anywhere and by any prefix it can tell apart (`--dry`; none of these prefixes another option of its script).
4014
4264
  RUN_ONESHOTS = {'statusd': ('--once',), 'notify': ('--test',), 'economy': ('--once', '--dry-run')}
4015
4265
  RUN_FIRST_ARGUMENT = {'statusd'}
4016
- RUN_UNITS = {'statusd': 'lanes-statusd.service', 'enrold': 'lanes-enrold.service', 'notify': 'lanes-notify.service',
4017
- 'economy': 'lanes-economy.service'} # what restarts each daemon; a sidebar restarts with its view
4266
+ RUN_UNITS = {'statusd': 'lanes-statusd.service', 'enrold': 'lanes-enrold.service', 'docsd': 'lanes-docsd.service',
4267
+ 'notify': 'lanes-notify.service', 'economy': 'lanes-economy.service'} # what restarts each daemon; a sidebar restarts with its view
4018
4268
  LAYOUT_COMMAND = re.compile(r'command=("(?:[^"\\]|\\.)*")') # q(): a JSON string
4019
4269
  LAYOUT_FILES = L.LAYOUT_FILES # the tree files a generated layout runs
4020
4270
  UNIT_ENTRY = re.compile(r'"@REPO@/([^"]+)"') # a unit template's tree file (install/services.py)
@@ -4370,6 +4620,12 @@ def cmd_doctor(_args):
4370
4620
  print(f'⚠ {text}')
4371
4621
  else:
4372
4622
  check(mark == '✓', text)
4623
+ # The docs service (W10 W8, LNS-109): lanes-docsd and its port, the pin gate, the store, the Mermaid pin, `age`.
4624
+ for mark, text in docs_doctor():
4625
+ if mark in ('⚠', ''):
4626
+ print(f'{mark or " "} {text}')
4627
+ else:
4628
+ check(mark == '✓', text)
4373
4629
  # The release that runs (W9 V3): `current`, the run records against the one process-table sweep above, the
4374
4630
  # device layouts, the units.
4375
4631
  for mark, text in release_doctor(table if rc == 0 else None):
@@ -5053,7 +5309,7 @@ COMMANDS = {'rebuilt': cmd_rebuilt, 'hello': cmd_hello, 'client-config': cmd_cli
5053
5309
  'sidebar-key': cmd_sidebar_key, 'studio-host': cmd_studio_host,
5054
5310
  'keys': cmd_keys, 'invite': cmd_invite, 'enrol': cmd_enrol, 'offboard': cmd_offboard,
5055
5311
  'token': cmd_token, 'onboard': cmd_onboard, 'user': cmd_user, 'lane': cmd_lane, 'here': cmd_here,
5056
- 'version': cmd_version, 'update': cmd_update}
5312
+ 'version': cmd_version, 'update': cmd_update, 'docs': cmd_docs}
5057
5313
 
5058
5314
  if __name__ == '__main__':
5059
5315
  if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
@@ -23,6 +23,12 @@ a `holder: tmux` lane through its exact socket and session; any other id is a zm
23
23
  hook sidecar says the agent is `working` unless --force
24
24
  lane gc --sockets unlink this container's tmux socket files that tmux is refused on (ECONNREFUSED: no
25
25
  server listens); prints each unlinked path. A live, empty or silent server keeps its file
26
+ lane publish <file> [--title T] [--for <login>] [--kind md|html|svg|file]
27
+ put the file on the Lanes docs page from the lane this runs in ($LANE_ID, else the tmux
28
+ pane): writes ~/.lz/state/publish/<lane>/<id>.json (the file's container path, hashed
29
+ once) and prints {id, …, picked_up_by, expected_within_s} as JSON; the host's lanes-docsd
30
+ takes it within ~5 s and writes <id>.receipt.json beside it (kind from the extension when
31
+ --kind is left out; the file must be under /home/coder, at most 32 MiB)
26
32
  lane mcp install [--agent claude|codex|all] [--write]
27
33
  add the Lanes MCP server (~/.lz/bin/lane-mcp) to ~/.claude.json (mcpServers.lanes) and
28
34
  ~/.codex/config.toml ([mcp_servers.lanes], with env_vars passing LANE_ID, LANE_WS, TMUX
@@ -574,8 +580,43 @@ def cmd_mcp(args):
574
580
  sys.exit(1 if refused else 0)
575
581
 
576
582
 
583
+ PUBLISH_USAGE = 'usage: lane publish <file> [--title T] [--for <login>] [--kind md|html|svg|file]'
584
+
585
+
586
+ def cmd_publish(args):
587
+ """`lane publish` (plan W10 W4): lane_core.publish from the lane this process runs in (lane_core.whoami: $LANE_ID,
588
+ else the tmux pane) for the file named relative to the working directory. Prints the answer as JSON on stdout
589
+ (what lanes_publish answers) and one line for a person on stderr. A refusal is one stderr line, exit 1."""
590
+ options, rest, path = {}, list(args), None
591
+ while rest:
592
+ word = rest.pop(0)
593
+ if word in ('--title', '--for', '--kind'):
594
+ if not rest or word in options:
595
+ sys.exit(PUBLISH_USAGE)
596
+ options[word] = rest.pop(0)
597
+ elif word.startswith('--') or path is not None:
598
+ sys.exit(PUBLISH_USAGE)
599
+ else:
600
+ path = word
601
+ if path is None:
602
+ sys.exit(PUBLISH_USAGE)
603
+ found = C.whoami(ROOT, os.environ)
604
+ if not found['lane']:
605
+ sys.exit(f"lane publish: {found.get('why') or 'not inside a lane'}")
606
+ try:
607
+ out = C.publish(ROOT, Path(os.environ.get('HOME') or Path.home()), os.path.abspath(path), found['lane'],
608
+ title=options.get('--title'), for_login=options.get('--for'), kind=options.get('--kind'))
609
+ except C.LaneError as error:
610
+ sys.exit(f'lane publish: {error}')
611
+ out.update(picked_up_by=C.PUBLISH_PICKUP, expected_within_s=C.PUBLISH_LATENCY_S)
612
+ print(json.dumps(out, ensure_ascii=False, indent=1))
613
+ sys.stderr.write(f"lane publish: {out['path']} ({out['kind']}, {out['bytes']} bytes) -> request {out['id']} of "
614
+ f"{out['lane']}; {C.PUBLISH_PICKUP} takes it within ~{C.PUBLISH_LATENCY_S} s and answers in "
615
+ f"{out['receipt']}\n")
616
+
617
+
577
618
  COMMANDS = {'new': cmd_new, 'open': cmd_open, 'attach': cmd_attach, 'send': cmd_send, 'capture': cmd_capture, 'ls': cmd_ls,
578
- 'status': cmd_status, 'kill': cmd_kill, 'gc': cmd_gc, 'mcp': cmd_mcp}
619
+ 'status': cmd_status, 'kill': cmd_kill, 'gc': cmd_gc, 'mcp': cmd_mcp, 'publish': cmd_publish}
579
620
 
580
621
  if __name__ == '__main__':
581
622
  if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: