@vibelord/lanes 1.2.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/lanes/README.md +7 -5
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +1091 -76
- package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
- package/payload/lanes/container/hooks/grok-hooks.json +12 -0
- package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
- package/payload/lanes/container/lane +55 -8
- package/payload/lanes/container/lane-mcp +120 -22
- package/payload/lanes/container/lane-signal +86 -23
- package/payload/lanes/container/lane_core.py +524 -36
- package/payload/lanes/container/throttle-pulse +456 -0
- package/payload/lanes/docs/ARCHITECTURE.md +357 -34
- package/payload/lanes/docs/DESIGN.md +13 -3
- package/payload/lanes/docs/INSTALL.md +47 -10
- package/payload/lanes/docs/RUNBOOK.md +761 -12
- package/payload/lanes/docs/USERGUIDE.md +421 -46
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/host-install.sh +39 -5
- package/payload/lanes/libexec/agent_state.py +604 -12
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/economy_poll.py +6 -2
- package/payload/lanes/libexec/harness_grok.py +742 -0
- package/payload/lanes/libexec/lanes_lib.py +143 -20
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +5 -0
- package/payload/lanes/libexec/notify.py +65 -5
- package/payload/lanes/libexec/sidebar.py +626 -25
- package/payload/lanes/libexec/statusd.py +530 -32
- package/payload/lanes/libexec/tokens.py +42 -9
- package/payload/lanes/libexec/update.py +171 -26
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +31 -3
- package/payload/lanes/site/site.example.conf +4 -0
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +86 -8
- package/payload/lanes/web/browser.js +2 -4
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.js +3 -2
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/manifest.json +5 -3
|
@@ -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
|
|
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)
|
|
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 =
|
|
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
|
|
547
|
+
if word == '--agent' and rest and rest[0] in C.MCP_AGENTS + ('all',):
|
|
537
548
|
pick = rest.pop(0)
|
|
538
|
-
agents =
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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.
|
|
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',
|
|
138
|
-
'description': 'any: lanes with an agent; none: lanes without one; claude / codex
|
|
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
|
|
143
|
-
'
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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
|
-
|
|
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'):
|