@vibelord/lanes 1.2.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +7 -5
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +1091 -76
  5. package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
  6. package/payload/lanes/container/hooks/grok-hooks.json +12 -0
  7. package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
  8. package/payload/lanes/container/lane +55 -8
  9. package/payload/lanes/container/lane-mcp +120 -22
  10. package/payload/lanes/container/lane-signal +86 -23
  11. package/payload/lanes/container/lane_core.py +524 -36
  12. package/payload/lanes/container/throttle-pulse +456 -0
  13. package/payload/lanes/docs/ARCHITECTURE.md +357 -34
  14. package/payload/lanes/docs/DESIGN.md +13 -3
  15. package/payload/lanes/docs/INSTALL.md +47 -10
  16. package/payload/lanes/docs/RUNBOOK.md +761 -12
  17. package/payload/lanes/docs/USERGUIDE.md +421 -46
  18. package/payload/lanes/holders.lock +5 -0
  19. package/payload/lanes/install/enable-docs.sh +78 -0
  20. package/payload/lanes/install/host-install.sh +39 -5
  21. package/payload/lanes/libexec/agent_state.py +604 -12
  22. package/payload/lanes/libexec/docs_store.py +995 -0
  23. package/payload/lanes/libexec/docsd.py +2010 -0
  24. package/payload/lanes/libexec/economy_poll.py +6 -2
  25. package/payload/lanes/libexec/harness_grok.py +742 -0
  26. package/payload/lanes/libexec/lanes_lib.py +143 -20
  27. package/payload/lanes/libexec/mdlite.py +575 -0
  28. package/payload/lanes/libexec/members.py +5 -0
  29. package/payload/lanes/libexec/notify.py +65 -5
  30. package/payload/lanes/libexec/sidebar.py +626 -25
  31. package/payload/lanes/libexec/statusd.py +530 -32
  32. package/payload/lanes/libexec/tokens.py +42 -9
  33. package/payload/lanes/libexec/update.py +171 -26
  34. package/payload/lanes/libexec/uploads.py +538 -0
  35. package/payload/lanes/libexec/users.py +31 -3
  36. package/payload/lanes/site/site.example.conf +4 -0
  37. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  38. package/payload/lanes/web/Caddyfile +24 -1
  39. package/payload/lanes/web/README.md +86 -8
  40. package/payload/lanes/web/browser.js +2 -4
  41. package/payload/lanes/web/docs.css +52 -0
  42. package/payload/lanes/web/docs.js +36 -0
  43. package/payload/lanes/web/mobile.js +3 -2
  44. package/payload/lanes/web/upload.css +17 -0
  45. package/payload/lanes/web/upload.js +84 -0
  46. package/payload/manifest.json +5 -3
@@ -0,0 +1,8 @@
1
+ {"hooks": {
2
+ "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal working --event UserPromptSubmit --harness grok-cli", "timeout": 2}]}],
3
+ "PreToolUse": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal working --event PreToolUse --harness grok-cli", "timeout": 2}]}],
4
+ "PostToolUse": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal working --event PostToolUse --harness grok-cli", "timeout": 2}]}],
5
+ "Stop": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal needs_input --event Stop --harness grok-cli", "timeout": 2}]}],
6
+ "StopFailure": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal errored --event StopFailure --harness grok-cli", "timeout": 2}]}],
7
+ "SessionEnd": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal shell --event SessionEnd --harness grok-cli", "timeout": 2}]}]
8
+ }}
@@ -0,0 +1,12 @@
1
+ {"hooks": {
2
+ "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal working --event UserPromptSubmit --harness grok", "timeout": 2}]}],
3
+ "PreToolUse": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal working --event PreToolUse --harness grok", "timeout": 2}]}],
4
+ "PostToolUse": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal working --event PostToolUse --harness grok", "timeout": 2}]}],
5
+ "Notification": [{"matcher": "permission_prompt", "hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal needs_approval --event Notification --harness grok", "timeout": 2}]},
6
+ {"matcher": "idle_prompt", "hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal needs_input --event Notification --harness grok", "timeout": 2}]}],
7
+ "Stop": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal needs_input --event Stop --harness grok", "timeout": 2}]}],
8
+ "StopFailure": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal errored --event StopFailure --harness grok", "timeout": 2}]}],
9
+ "StopCancelled": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal needs_input --event StopCancelled --harness grok", "timeout": 2}]}],
10
+ "SessionStart": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal idle --event SessionStart --harness grok", "timeout": 2}]}],
11
+ "SessionEnd": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal shell --event SessionEnd --harness grok", "timeout": 2}]}]
12
+ }}
@@ -0,0 +1,49 @@
1
+ # >>> lanes hooks >>>
2
+ # Kimi Code -> ~/.lz/bin/lane-signal (`lanes hooks install --agent kimi` merges this block into
3
+ # ~/.kimi-code/config.toml between these two marker lines; nothing outside them is rewritten). Kimi Code
4
+ # runs each command through a shell with the hook JSON (session_id, snake_case) on stdin.
5
+ [[hooks]]
6
+ event = "PreToolUse"
7
+ command = "~/.lz/bin/lane-signal working --event PreToolUse --harness kimi"
8
+ timeout = 2
9
+ [[hooks]]
10
+ event = "PostToolUse"
11
+ command = "~/.lz/bin/lane-signal working --event PostToolUse --harness kimi"
12
+ timeout = 2
13
+ [[hooks]]
14
+ event = "PermissionRequest"
15
+ command = "~/.lz/bin/lane-signal needs_approval --event PermissionRequest --harness kimi"
16
+ timeout = 2
17
+ [[hooks]]
18
+ event = "PermissionResult"
19
+ command = "~/.lz/bin/lane-signal working --event PermissionResult --harness kimi"
20
+ timeout = 2
21
+ [[hooks]]
22
+ event = "UserPromptSubmit"
23
+ command = "~/.lz/bin/lane-signal working --event UserPromptSubmit --harness kimi"
24
+ timeout = 2
25
+ [[hooks]]
26
+ event = "TurnStarted"
27
+ command = "~/.lz/bin/lane-signal working --event TurnStarted --harness kimi"
28
+ timeout = 2
29
+ [[hooks]]
30
+ event = "Stop"
31
+ command = "~/.lz/bin/lane-signal needs_input --event Stop --harness kimi"
32
+ timeout = 2
33
+ [[hooks]]
34
+ event = "StopFailure"
35
+ command = "~/.lz/bin/lane-signal errored --event StopFailure --harness kimi"
36
+ timeout = 2
37
+ [[hooks]]
38
+ event = "Interrupt"
39
+ command = "~/.lz/bin/lane-signal needs_input --event Interrupt --harness kimi"
40
+ timeout = 2
41
+ [[hooks]]
42
+ event = "SessionStart"
43
+ command = "~/.lz/bin/lane-signal idle --event SessionStart --harness kimi"
44
+ timeout = 2
45
+ [[hooks]]
46
+ event = "SessionEnd"
47
+ command = "~/.lz/bin/lane-signal shell --event SessionEnd --harness kimi"
48
+ timeout = 2
49
+ # <<< lanes hooks <<<
@@ -23,10 +23,21 @@ 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 mcp install [--agent claude|codex|all] [--write]
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)
32
+ lane mcp install [--agent claude|codex|kimi|grok|grok-cli|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
29
- and TMUX_PANE: Codex clears a server's environment): prints the diff; --write applies
35
+ and TMUX_PANE: Codex clears a server's environment), and — where that harness's home is
36
+ a directory here, or when named — ~/.kimi-code/mcp.json (mcpServers.lanes),
37
+ ~/.grok-build/config.toml (Grok Build's GROK_HOME: a `# >>> lanes mcp >>>` block with
38
+ [mcp_servers.lanes]) and ~/.grok/user-settings.json (grok-cli: mcp.servers id `lanes`);
39
+ `all` is that default set, a named harness is written even where its home is
40
+ missing: prints the diff; --write applies
30
41
  it, keeping <file>.bak-<UTC> first; an entry that is already there is left alone (a
31
42
  Codex table missing those env_vars gains them), one named `lanes` that starts something
32
43
  else is never replaced (exit 1). New agent sessions load it
@@ -521,7 +532,7 @@ def cmd_gc(args):
521
532
  print(f'lane gc --sockets: {removed} unlinked, {kept} kept in {directory}')
522
533
 
523
534
 
524
- MCP_USAGE = 'usage: lane mcp install [--agent claude|codex|all] [--write]'
535
+ MCP_USAGE = 'usage: lane mcp install [--agent claude|codex|kimi|grok|grok-cli|all] [--write]'
525
536
 
526
537
 
527
538
  def cmd_mcp(args):
@@ -530,12 +541,12 @@ def cmd_mcp(args):
530
541
  left alone (not valid JSON/TOML, or a `lanes` entry that starts something else) or lane-mcp is not installed."""
531
542
  if not args or args[0] != 'install':
532
543
  sys.exit(MCP_USAGE)
533
- agents, write, rest = C.MCP_AGENTS, False, list(args[1:])
544
+ agents, write, rest = None, False, list(args[1:]) # None: lane_core.mcp_agents (claude, codex, installed others)
534
545
  while rest:
535
546
  word = rest.pop(0)
536
- if word == '--agent' and rest and rest[0] in ('claude', 'codex', 'all'):
547
+ if word == '--agent' and rest and rest[0] in C.MCP_AGENTS + ('all',):
537
548
  pick = rest.pop(0)
538
- agents = C.MCP_AGENTS if pick == 'all' else (pick,)
549
+ agents = None if pick == 'all' else (pick,) # all: claude, codex and every harness installed here
539
550
  elif word == '--write':
540
551
  write = True
541
552
  else:
@@ -570,12 +581,48 @@ def cmd_mcp(args):
570
581
  print(f"backup {result['backup']}")
571
582
  print(f"wrote {result['path']}")
572
583
  if added:
573
- print('new Claude Code and Codex sessions load it (a running session keeps its tools)')
584
+ print('new Claude Code and Codex sessions load it, and new Kimi Code, Grok Build and grok-cli ones where '
585
+ 'their entry was written (a running session keeps its tools)')
574
586
  sys.exit(1 if refused else 0)
575
587
 
576
588
 
589
+ PUBLISH_USAGE = 'usage: lane publish <file> [--title T] [--for <login>] [--kind md|html|svg|file]'
590
+
591
+
592
+ def cmd_publish(args):
593
+ """`lane publish` (plan W10 W4): lane_core.publish from the lane this process runs in (lane_core.whoami: $LANE_ID,
594
+ else the tmux pane) for the file named relative to the working directory. Prints the answer as JSON on stdout
595
+ (what lanes_publish answers) and one line for a person on stderr. A refusal is one stderr line, exit 1."""
596
+ options, rest, path = {}, list(args), None
597
+ while rest:
598
+ word = rest.pop(0)
599
+ if word in ('--title', '--for', '--kind'):
600
+ if not rest or word in options:
601
+ sys.exit(PUBLISH_USAGE)
602
+ options[word] = rest.pop(0)
603
+ elif word.startswith('--') or path is not None:
604
+ sys.exit(PUBLISH_USAGE)
605
+ else:
606
+ path = word
607
+ if path is None:
608
+ sys.exit(PUBLISH_USAGE)
609
+ found = C.whoami(ROOT, os.environ)
610
+ if not found['lane']:
611
+ sys.exit(f"lane publish: {found.get('why') or 'not inside a lane'}")
612
+ try:
613
+ out = C.publish(ROOT, Path(os.environ.get('HOME') or Path.home()), os.path.abspath(path), found['lane'],
614
+ title=options.get('--title'), for_login=options.get('--for'), kind=options.get('--kind'))
615
+ except C.LaneError as error:
616
+ sys.exit(f'lane publish: {error}')
617
+ out.update(picked_up_by=C.PUBLISH_PICKUP, expected_within_s=C.PUBLISH_LATENCY_S)
618
+ print(json.dumps(out, ensure_ascii=False, indent=1))
619
+ sys.stderr.write(f"lane publish: {out['path']} ({out['kind']}, {out['bytes']} bytes) -> request {out['id']} of "
620
+ f"{out['lane']}; {C.PUBLISH_PICKUP} takes it within ~{C.PUBLISH_LATENCY_S} s and answers in "
621
+ f"{out['receipt']}\n")
622
+
623
+
577
624
  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}
625
+ 'status': cmd_status, 'kill': cmd_kill, 'gc': cmd_gc, 'mcp': cmd_mcp, 'publish': cmd_publish}
579
626
 
580
627
  if __name__ == '__main__':
581
628
  if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env python3
2
2
  """lane-mcp — the Lanes Agent API inside a workspace container: an MCP server on stdin/stdout (LNS-34/35, wire v1).
3
3
  Installed at /home/coder/.lz/bin/lane-mcp by `lanes install-container`; Claude Code and Codex start it from
4
- `mcpServers.lanes` in ~/.claude.json and `[mcp_servers.lanes]` in ~/.codex/config.toml (`lane mcp install`).
4
+ `mcpServers.lanes` in ~/.claude.json and `[mcp_servers.lanes]` in ~/.codex/config.toml, Kimi Code from
5
+ ~/.kimi-code/mcp.json, Grok Build from `[mcp_servers.lanes]` in $GROK_HOME/config.toml and grok-cli from `mcp.servers`
6
+ in ~/.grok/user-settings.json (`lane mcp install`, W12).
5
7
 
6
8
  usage: lane-mcp (speaks MCP on stdin/stdout; nothing else to run by hand)
7
9
 
@@ -18,7 +20,8 @@ capture's text, a sent text or a note), rotated to .1 past 1 MiB.
18
20
  Read tools (the lanes of THIS container: what ~/.lz/registry.json and ~/.lz/status.json hold):
19
21
  lanes_whoami which lane this agent runs in ($LANE_ID, else its tmux pane's session)
20
22
  lanes_list [workspace] [needs_you] [agent] every lane here with its state and agent
21
- lanes_status <lane> one lane in full: registry facts, observed state, agent, tmux liveness, hook sidecar
23
+ lanes_status <lane> one lane in full: registry facts, observed state, agent, documents (`docs`), tmux
24
+ liveness, hook sidecar, and the lane's latest publish receipt (`published`)
22
25
  lanes_capture <lane> [lines] the last N (1–500, default 100) non-blank lines of that lane's screen and scrollback
23
26
 
24
27
  Write tools (LNS-35). Each answers JSON: on success what changed, what picks it up (`picked_up_by`) and when
@@ -34,8 +37,12 @@ terminal); `lanes sync` publishes the change (lanes-statusd starts one within ~1
34
37
  lanes_signal <status> [note] report this agent's own status (and a one-line note) through lane-signal; the report
35
38
  stands until the agent's next tool call, prompt or permission prompt, and the end of
36
39
  its turn keeps it (lane-signal's EXPLICIT rule)
40
+ lanes_publish <path> [title] [for] [kind] put a file of this container on the Lanes docs page from this agent's
41
+ own lane (plan W10 W4): a request in ~/.lz/state/publish/<lane>/ that the host's
42
+ lanes-docsd takes within ~5 s, answering in <id>.receipt.json (lane_core.publish)
37
43
  Human-only by omission (no tool exists): ending or closing a lane, interrupting it (C-c or any key that is not
38
- printable text), opening or attaching one, uploads, the host's sync/gc/ensure, renaming a tmux session, and any
44
+ printable text), opening or attaching one, uploads (a person's file into a lane: the Docs page or the desktop drop;
45
+ an agent's own file goes the other way, lanes_publish), the host's sync/gc/ensure, renaming a tmux session, and any
39
46
  edit of an adopted (`lanes register`ed, `--x-`) session — those stay with a person on the boards or the host.
40
47
  """
41
48
  import json
@@ -71,7 +78,29 @@ PATH_MAX, COMMAND_MAX = 1024, 1024
71
78
  SYNC_LATENCY_S, STATUS_LATENCY_S = 15, 5
72
79
  SYNC_PICKUP = 'lanes sync'
73
80
  STATUS_PICKUP = 'lanes-statusd'
74
- SESSION_VARS = ('CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID') # the agent's session id, when its env carries one
81
+ # A publish request: lanes-docsd's own pick-up cycle (5 s, plan W10 W4) takes it and writes the receipt back.
82
+ PUBLISH_PICKUP, PUBLISH_LATENCY_S = C.PUBLISH_PICKUP, C.PUBLISH_LATENCY_S
83
+ SESSION_VARS = ('CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID', 'GROK_SESSION_ID') # the agent's session id, when known
84
+ # The harness of the client that started this server (W12, DM-LANES-LANE-MCP), from its `clientInfo.name` (MEASURED:
85
+ # Claude Code `claude-code`, Codex `codex-mcp-client`, Grok Build `grok-shell-<server>`, grok-cli `grok-cli-<id>`;
86
+ # Kimi Code's names `kimi`), else the agent process above this one (lane_core.parent_harness), else `unknown`.
87
+ HARNESS_FILTERS = ('claude', 'codex', 'kimi', 'grok', 'grok-cli') # lanes_list `agent`
88
+
89
+
90
+ def client_harness(client):
91
+ """The harness a client name says (spec W12 §2), else None."""
92
+ name = (client or '').lower()
93
+ if 'claude' in name:
94
+ return 'claude'
95
+ if 'codex' in name:
96
+ return 'codex'
97
+ if 'kimi' in name: # kimi-code, kimi-code-acp, kimi
98
+ return 'kimi'
99
+ if name.startswith('grok-shell'):
100
+ return 'grok'
101
+ if name.startswith('grok-cli'):
102
+ return 'grok-cli'
103
+ return None
75
104
  # How long a lanes_signal report holds against the agent's own hooks (lane-signal's EXPLICIT rule, DEF-1801).
76
105
  SIGNAL_STANDS = ('your next tool call, a new prompt, a permission prompt, a failed turn or the session\'s end; the end '
77
106
  'of your turn keeps it')
@@ -86,7 +115,9 @@ INSTRUCTIONS = ('Lanes tools for the workspace container this agent runs in. A l
86
115
  'display name, lanes_new declares and starts a new lane, lanes_send types text into a lane (refused '
87
116
  'while its agent is working or waiting for an approval unless force: true), lanes_signal reports '
88
117
  'your own status with a short note. Every write answers what changed, what picks it up and when; a '
89
- 'refusal answers a machine code and what a person can do instead. Ending, interrupting or attaching '
118
+ 'refusal answers a machine code and what a person can do instead. lanes_publish puts a file of this '
119
+ 'container on the Lanes docs page, where the people on your boards open it (the host takes it within '
120
+ 'about 5 s; lanes_status shows the receipt as `published`). Ending, interrupting or attaching '
90
121
  'a lane is a person\'s call: no tool does it.')
91
122
 
92
123
  ID_PATTERN = '^[A-Za-z0-9_][A-Za-z0-9_.-]*$'
@@ -134,13 +165,15 @@ TOOLS = (
134
165
  'needs_you': {'type': 'boolean',
135
166
  'description': 'true: only lanes whose agent waits for a person (needs_approval, errored, '
136
167
  'needs_input — the boards\' "Needs you" section).'},
137
- 'agent': {'type': 'string', 'enum': ['any', 'none', 'claude', 'codex'],
138
- 'description': 'any: lanes with an agent; none: lanes without one; claude / codex: that harness.'}}),
168
+ 'agent': {'type': 'string', 'enum': ['any', 'none', *HARNESS_FILTERS],
169
+ 'description': 'any: lanes with an agent; none: lanes without one; claude / codex / kimi / grok '
170
+ '(Grok Build) / grok-cli: that harness.'}}),
139
171
  _tool('lanes_status', 'Show one lane',
140
172
  'One lane in full: the registry facts (board, display name, kind, tmux socket and session, the declaring '
141
173
  'terminals.json and repository), the host\'s observation (state, agent, attached clients, viewers, last '
142
- 'activity, size, stale), whether its tmux session is alive now (null when tmux did not answer) and its '
143
- 'lane-signal hook sidecar.',
174
+ 'activity, size, stale, and `docs`: how many documents the docs page holds for the lane and when the '
175
+ 'newest arrived), whether its tmux session is alive now (null when tmux did not answer), its '
176
+ 'lane-signal hook sidecar, and `published`: the lane\'s latest publish receipt (id, url, at, outcome).',
144
177
  {'lane': LANE_ARG}, required=('lane',)),
145
178
  _tool('lanes_capture', 'Read a lane\'s screen',
146
179
  'The last `lines` non-blank lines of a tmux lane\'s screen and scrollback, as plain text (wrapped lines '
@@ -213,6 +246,25 @@ TOOLS = (
213
246
  'description': f'What you are doing, one line of at most {NOTE_MAX} characters; "" clears it. '
214
247
  'Kept while the report stands or its status repeats.'}},
215
248
  required=('status',), hints=_writes(False, True)),
249
+ _tool('lanes_publish', 'Publish a file to the docs page',
250
+ 'Put a file of this container on the Lanes docs page, from the lane this agent runs in: Markdown (with '
251
+ 'Mermaid diagrams) is rendered there, an HTML or SVG artifact opens sandboxed on its own page, any other '
252
+ 'file is offered as a download. Writes a request the host\'s lanes-docsd takes within about 5 s; it reads '
253
+ 'the file itself, checks its sha256 against the one answered here and writes a receipt that lanes_status '
254
+ 'shows as `published` (outcome ok with the document\'s url, or refused with the reason). The file must be '
255
+ f'under /home/coder (every link resolved) and at most {C.PUBLISH_MAX >> 20} MiB. Who sees it: `for`, else '
256
+ 'the person who last opened this lane, else every member whose record lists the lane\'s board.',
257
+ {'path': {'type': 'string', 'minLength': 1, 'maxLength': PATH_MAX,
258
+ 'description': 'The file: an absolute path under /home/coder.'},
259
+ 'title': {'type': 'string', 'maxLength': C.TITLE_MAX,
260
+ 'description': f'The title the docs page shows, one line of at most {C.TITLE_MAX} characters; '
261
+ 'default: the file name.'},
262
+ 'for': {'type': 'string', 'minLength': 1, 'maxLength': C.LOGIN_MAX,
263
+ 'description': 'The tailnet login of the member the document is for (only they see it).'},
264
+ 'kind': {'type': 'string', 'enum': list(C.PUBLISH_KINDS),
265
+ 'description': 'md, html, svg or file; default: from the extension (.md/.markdown, .html/.htm, '
266
+ '.svg, anything else file).'}},
267
+ required=('path',), hints=_writes(False, False)),
216
268
  )
217
269
  TOOL_BY_NAME = {tool['name']: tool for tool in TOOLS}
218
270
  WRITE_TOOLS = frozenset(tool['name'] for tool in TOOLS if not tool['annotations']['readOnlyHint'])
@@ -280,6 +332,13 @@ HUMAN = {
280
332
  'tmux_failed': 'lanes_status says whether the lane is running.',
281
333
  'signal_not_written': 'A person checks the lane-signal install (`lanes install-container <container>` on the '
282
334
  'host).',
335
+ 'outside_home': 'Publish a file under /home/coder: copy it there first (a link that leads outside is refused).',
336
+ 'not_a_file': 'Pass an existing regular file (not a directory, FIFO or device).',
337
+ 'too_large': f'A published file is at most {C.PUBLISH_MAX >> 20} MiB: publish a smaller one, or tell a person '
338
+ 'where the file is.',
339
+ 'invalid_title': 'A title is one line of printable text without leading or trailing spaces.',
340
+ 'invalid_for': 'Pass one tailnet login (as `anna@example.com`), or leave `for` out.',
341
+ 'invalid_kind': 'Pass md, html, svg or file, or leave `kind` out.',
283
342
  'refused': 'A person takes it from here.',
284
343
  }
285
344
 
@@ -384,17 +443,22 @@ def agent_summary(agent):
384
443
  return {key: agent[key] for key in ('harness', 'status', 'name', 'since') if agent.get(key) is not None}
385
444
 
386
445
 
387
- OBSERVED = ('attached', 'viewers', 'activity', 'size', 'stale')
446
+ # The observed keys a row copies from ~/.lz/status.json when present. `docs` ({n, at}: the documents the docs cockpit
447
+ # holds for the lane, plan W10 W7) is an additive result key within wire v1, absent while the lane has none.
448
+ OBSERVED = ('attached', 'viewers', 'activity', 'size', 'stale', 'docs')
388
449
 
389
450
 
390
451
  class Server:
391
- def __init__(self, root, environ=None, log=None, proc=Path('/proc'), home=None, signal=None):
452
+ def __init__(self, root, environ=None, log=None, proc=Path('/proc'), home=None, signal=None, parent=None):
392
453
  self.root, self.proc = Path(root), proc
393
454
  # The container's /home/coder as this process sees it: itself in a container (root ~/.lz), the volume a test
394
455
  # builds otherwise. Every container path a write tool reads or writes goes through local().
395
456
  self.home = Path(home) if home else self.root.parent
396
457
  self.signal = Path(signal) if signal else Path(__file__).resolve().parent / 'lane-signal'
397
458
  self.environ = os.environ if environ is None else environ
459
+ # The agent that started this server — read (whoami's environ fallback, the harness when the client's name
460
+ # says none) only when this server reads its own environment: a caller that hands one in names its own parent.
461
+ self.parent = parent if parent is not None else (os.getppid() if environ is None else None)
398
462
  self.log = log or Log(None)
399
463
  self.protocol = None
400
464
  self.client = ''
@@ -551,8 +615,21 @@ class Server:
551
615
  text = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False, indent=1)
552
616
  return {'content': [{'type': 'text', 'text': text}], 'isError': error}
553
617
 
618
+ def whoami(self):
619
+ """lane_core.whoami for the agent this server serves: its environment, then (grok-cli hands a server a
620
+ stripped one, DEF-1992) the agent's own /proc environ when this server reads its own environment."""
621
+ return C.whoami(self.root, self.environ, proc=self.proc, parent=self.parent)
622
+
623
+ def harness(self):
624
+ """The harness to record for this agent: the client's name (client_harness), else the agent's process
625
+ (lane_core.parent_harness, when this server reads its own environment), else `unknown`."""
626
+ found = client_harness(self.client)
627
+ if not found and self.parent is not None:
628
+ found = C.parent_harness(self.proc, self.parent)
629
+ return found or 'unknown'
630
+
554
631
  def tool_whoami(self, _arguments):
555
- found = C.whoami(self.root, self.environ)
632
+ found = self.whoami()
556
633
  out = {'lane': found['lane'], 'found_by': found['found_by'], 'kind': found['kind']}
557
634
  entry, session = found.get('entry'), found.get('session')
558
635
  if entry:
@@ -605,7 +682,7 @@ class Server:
605
682
  continue
606
683
  if want_agent == 'any' and not agent or want_agent == 'none' and agent:
607
684
  continue
608
- if want_agent in ('claude', 'codex') and (not agent or agent.get('harness') != want_agent):
685
+ if want_agent in HARNESS_FILTERS and (not agent or agent.get('harness') != want_agent):
609
686
  continue
610
687
  picked.append(row)
611
688
  container = next((entry.get('container') for entry in lanes.values() if entry.get('container')), None)
@@ -643,6 +720,9 @@ class Server:
643
720
  alive = None if code in (124, 127) else code == 0
644
721
  out['alive'] = alive
645
722
  out['hook'] = C.hook_agent(self.root / 'state' / 'hooks', lane, self.proc) or None
723
+ published = C.latest_receipt(self.root, lane) # plan W10 W4: read here, in the container, never via status.json
724
+ if published:
725
+ out['published'] = published
646
726
  out['status'] = freshness(status)
647
727
  return out
648
728
 
@@ -835,7 +915,7 @@ class Server:
835
915
  self.refuse_lane(wanted, lanes[wanted])
836
916
  refuse('not_in_container', f'no unregistered session {wanted!r} in this container')
837
917
  else:
838
- found = C.whoami(self.root, self.environ)
918
+ found = self.whoami()
839
919
  ghost = found.get('session') if found['kind'] == 'unregistered' else None
840
920
  if ghost is None:
841
921
  if found.get('entry'):
@@ -940,7 +1020,7 @@ class Server:
940
1020
  lanes, status = self.lanes()
941
1021
  ws = arguments.get('workspace')
942
1022
  if not ws:
943
- found = C.whoami(self.root, self.environ)
1023
+ found = self.whoami()
944
1024
  ws = (found.get('entry') or {}).get('ws') or (found.get('session') or {}).get('workspace')
945
1025
  if not ws:
946
1026
  boards = sorted({entry.get('ws') for entry in self.declared(lanes).values() if entry.get('ws')})
@@ -1027,25 +1107,25 @@ class Server:
1027
1107
  word, note = arguments['status'], arguments.get('note')
1028
1108
  if note is not None and self.unprintable(note):
1029
1109
  refuse('invalid_note', f'the note holds {", ".join(self.unprintable(note))}')
1030
- found = C.whoami(self.root, self.environ)
1110
+ found = self.whoami()
1031
1111
  lane = found['lane']
1032
1112
  if not lane:
1033
1113
  refuse('not_in_lane', found.get('why') or 'this agent runs in no lane',
1034
1114
  'lanes_signal reports the lane this agent runs in: start the agent inside a lane (for Codex, '
1035
1115
  '`lane mcp install` passes the lane\'s variables to this server).')
1036
- client = self.client.lower()
1037
- harness = ('claude' if 'claude' in client else 'codex' if 'codex' in client
1038
- else 'claude' if self.environ.get('CLAUDE_CODE_ENTRYPOINT') else '')
1116
+ # Always a harness (DEF-1991): before W12 a client whose name said neither claude nor codex, outside Claude
1117
+ # Code's environment, passed none, and lane-signal's old default recorded it as Codex.
1118
+ harness = self.harness()
1039
1119
  # The agent is this server's parent (Claude Code and Codex start it directly): lane-signal records ITS pid,
1040
1120
  # so the host's liveness check follows the agent, not this process.
1041
- argv = [sys.executable, str(self.signal), word, '--event', 'lanes_signal', '--agent-pid', str(os.getppid())]
1042
- argv += ['--harness', harness] if harness else []
1121
+ argv = [sys.executable, str(self.signal), word, '--event', 'lanes_signal', '--agent-pid', str(os.getppid()),
1122
+ '--harness', harness]
1043
1123
  argv += ['--note', note] if note is not None else []
1044
1124
  env = {'PATH': os.environ.get('PATH') or '/usr/bin:/bin', 'HOME': str(self.home), 'LANG': 'C.UTF-8',
1045
1125
  'LANE_ROOT': str(self.root), 'LANE_ID': lane}
1046
1126
  # The agent's session, so the sidecar names the session its own hooks name (their stdin's `session_id`):
1047
1127
  # Claude Code gives its MCP servers CLAUDE_CODE_SESSION_ID (MEASURED 2026-10-06 in a server's environ; it
1048
- # equals the session's transcript id). lane-signal matches a report to the agent's hooks by the agent's pid and
1128
+ # equals the session's transcript id); Grok Build GROK_SESSION_ID (its source, evidence 2026-10-15 §5). lane-signal matches a report to the agent's hooks by the agent's pid and
1049
1129
  # starttime first (Codex clears a server's environment; a /clear gives the agent a new session), then by this
1050
1130
  # session (DEF-1801).
1051
1131
  env.update({key: self.environ[key] for key in SESSION_VARS if self.environ.get(key)})
@@ -1066,6 +1146,24 @@ class Server:
1066
1146
  'picked_up_by': STATUS_PICKUP, 'expected_within_s': STATUS_LATENCY_S}
1067
1147
 
1068
1148
 
1149
+ def tool_publish(self, arguments):
1150
+ found = self.whoami()
1151
+ lane = found['lane']
1152
+ if not lane:
1153
+ refuse('not_in_lane', found.get('why') or 'this agent runs in no lane',
1154
+ 'lanes_publish publishes from the lane this agent runs in: start the agent inside a lane (for '
1155
+ 'Codex, `lane mcp install` passes the lane\'s variables to this server).')
1156
+ try:
1157
+ local = self.local(arguments['path'])
1158
+ except ValueError as error:
1159
+ refuse('outside_home', f'{error}: pass an absolute path under {C.CONTAINER_HOME}')
1160
+ out = C.publish(self.root, self.home, local, lane, title=arguments.get('title'),
1161
+ for_login=arguments.get('for'), kind=arguments.get('kind'))
1162
+ return {**out, 'changed': f'{out["request"]}: a publish request for {out["path"]} ({out["kind"]}, '
1163
+ f'{out["bytes"]} bytes, sha256 {out["sha256"]})',
1164
+ 'picked_up_by': PUBLISH_PICKUP, 'expected_within_s': PUBLISH_LATENCY_S}
1165
+
1166
+
1069
1167
  def main(argv):
1070
1168
  if argv:
1071
1169
  if argv[0] in ('-h', '--help'):