@vibelord/lanes 1.3.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/lanes/README.md +4 -3
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +797 -34
- 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 +13 -7
- package/payload/lanes/container/lane-mcp +56 -17
- package/payload/lanes/container/lane-signal +86 -23
- package/payload/lanes/container/lane_core.py +346 -36
- package/payload/lanes/container/throttle-pulse +456 -0
- package/payload/lanes/docs/ARCHITECTURE.md +182 -14
- package/payload/lanes/docs/DESIGN.md +7 -0
- package/payload/lanes/docs/INSTALL.md +23 -3
- package/payload/lanes/docs/RUNBOOK.md +402 -5
- package/payload/lanes/docs/USERGUIDE.md +222 -23
- package/payload/lanes/libexec/agent_state.py +604 -12
- package/payload/lanes/libexec/attach.py +181 -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/sidebar.py +522 -14
- package/payload/lanes/libexec/statusd.py +434 -28
- package/payload/lanes/libexec/tokens.py +12 -1
- package/payload/lanes/libexec/update.py +78 -5
- package/payload/lanes/libexec/users.py +31 -3
- package/payload/lanes/site/site.example.conf +4 -0
- package/payload/manifest.json +4 -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 <<<
|
|
@@ -29,10 +29,15 @@ a `holder: tmux` lane through its exact socket and session; any other id is a zm
|
|
|
29
29
|
once) and prints {id, …, picked_up_by, expected_within_s} as JSON; the host's lanes-docsd
|
|
30
30
|
takes it within ~5 s and writes <id>.receipt.json beside it (kind from the extension when
|
|
31
31
|
--kind is left out; the file must be under /home/coder, at most 32 MiB)
|
|
32
|
-
lane mcp install [--agent claude|codex|all] [--write]
|
|
32
|
+
lane mcp install [--agent claude|codex|kimi|grok|grok-cli|all] [--write]
|
|
33
33
|
add the Lanes MCP server (~/.lz/bin/lane-mcp) to ~/.claude.json (mcpServers.lanes) and
|
|
34
34
|
~/.codex/config.toml ([mcp_servers.lanes], with env_vars passing LANE_ID, LANE_WS, TMUX
|
|
35
|
-
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
|
|
36
41
|
it, keeping <file>.bak-<UTC> first; an entry that is already there is left alone (a
|
|
37
42
|
Codex table missing those env_vars gains them), one named `lanes` that starts something
|
|
38
43
|
else is never replaced (exit 1). New agent sessions load it
|
|
@@ -527,7 +532,7 @@ def cmd_gc(args):
|
|
|
527
532
|
print(f'lane gc --sockets: {removed} unlinked, {kept} kept in {directory}')
|
|
528
533
|
|
|
529
534
|
|
|
530
|
-
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]'
|
|
531
536
|
|
|
532
537
|
|
|
533
538
|
def cmd_mcp(args):
|
|
@@ -536,12 +541,12 @@ def cmd_mcp(args):
|
|
|
536
541
|
left alone (not valid JSON/TOML, or a `lanes` entry that starts something else) or lane-mcp is not installed."""
|
|
537
542
|
if not args or args[0] != 'install':
|
|
538
543
|
sys.exit(MCP_USAGE)
|
|
539
|
-
agents, write, rest =
|
|
544
|
+
agents, write, rest = None, False, list(args[1:]) # None: lane_core.mcp_agents (claude, codex, installed others)
|
|
540
545
|
while rest:
|
|
541
546
|
word = rest.pop(0)
|
|
542
|
-
if word == '--agent' and rest and rest[0] in
|
|
547
|
+
if word == '--agent' and rest and rest[0] in C.MCP_AGENTS + ('all',):
|
|
543
548
|
pick = rest.pop(0)
|
|
544
|
-
agents =
|
|
549
|
+
agents = None if pick == 'all' else (pick,) # all: claude, codex and every harness installed here
|
|
545
550
|
elif word == '--write':
|
|
546
551
|
write = True
|
|
547
552
|
else:
|
|
@@ -576,7 +581,8 @@ def cmd_mcp(args):
|
|
|
576
581
|
print(f"backup {result['backup']}")
|
|
577
582
|
print(f"wrote {result['path']}")
|
|
578
583
|
if added:
|
|
579
|
-
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)')
|
|
580
586
|
sys.exit(1 if refused else 0)
|
|
581
587
|
|
|
582
588
|
|
|
@@ -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
|
|
|
@@ -78,7 +80,27 @@ SYNC_PICKUP = 'lanes sync'
|
|
|
78
80
|
STATUS_PICKUP = 'lanes-statusd'
|
|
79
81
|
# A publish request: lanes-docsd's own pick-up cycle (5 s, plan W10 W4) takes it and writes the receipt back.
|
|
80
82
|
PUBLISH_PICKUP, PUBLISH_LATENCY_S = C.PUBLISH_PICKUP, C.PUBLISH_LATENCY_S
|
|
81
|
-
SESSION_VARS = ('CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID') # the agent's session id, when
|
|
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
|
|
82
104
|
# How long a lanes_signal report holds against the agent's own hooks (lane-signal's EXPLICIT rule, DEF-1801).
|
|
83
105
|
SIGNAL_STANDS = ('your next tool call, a new prompt, a permission prompt, a failed turn or the session\'s end; the end '
|
|
84
106
|
'of your turn keeps it')
|
|
@@ -143,8 +165,9 @@ TOOLS = (
|
|
|
143
165
|
'needs_you': {'type': 'boolean',
|
|
144
166
|
'description': 'true: only lanes whose agent waits for a person (needs_approval, errored, '
|
|
145
167
|
'needs_input — the boards\' "Needs you" section).'},
|
|
146
|
-
'agent': {'type': 'string', 'enum': ['any', 'none',
|
|
147
|
-
'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.'}}),
|
|
148
171
|
_tool('lanes_status', 'Show one lane',
|
|
149
172
|
'One lane in full: the registry facts (board, display name, kind, tmux socket and session, the declaring '
|
|
150
173
|
'terminals.json and repository), the host\'s observation (state, agent, attached clients, viewers, last '
|
|
@@ -426,13 +449,16 @@ OBSERVED = ('attached', 'viewers', 'activity', 'size', 'stale', 'docs')
|
|
|
426
449
|
|
|
427
450
|
|
|
428
451
|
class Server:
|
|
429
|
-
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):
|
|
430
453
|
self.root, self.proc = Path(root), proc
|
|
431
454
|
# The container's /home/coder as this process sees it: itself in a container (root ~/.lz), the volume a test
|
|
432
455
|
# builds otherwise. Every container path a write tool reads or writes goes through local().
|
|
433
456
|
self.home = Path(home) if home else self.root.parent
|
|
434
457
|
self.signal = Path(signal) if signal else Path(__file__).resolve().parent / 'lane-signal'
|
|
435
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)
|
|
436
462
|
self.log = log or Log(None)
|
|
437
463
|
self.protocol = None
|
|
438
464
|
self.client = ''
|
|
@@ -589,8 +615,21 @@ class Server:
|
|
|
589
615
|
text = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False, indent=1)
|
|
590
616
|
return {'content': [{'type': 'text', 'text': text}], 'isError': error}
|
|
591
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
|
+
|
|
592
631
|
def tool_whoami(self, _arguments):
|
|
593
|
-
found =
|
|
632
|
+
found = self.whoami()
|
|
594
633
|
out = {'lane': found['lane'], 'found_by': found['found_by'], 'kind': found['kind']}
|
|
595
634
|
entry, session = found.get('entry'), found.get('session')
|
|
596
635
|
if entry:
|
|
@@ -643,7 +682,7 @@ class Server:
|
|
|
643
682
|
continue
|
|
644
683
|
if want_agent == 'any' and not agent or want_agent == 'none' and agent:
|
|
645
684
|
continue
|
|
646
|
-
if want_agent in
|
|
685
|
+
if want_agent in HARNESS_FILTERS and (not agent or agent.get('harness') != want_agent):
|
|
647
686
|
continue
|
|
648
687
|
picked.append(row)
|
|
649
688
|
container = next((entry.get('container') for entry in lanes.values() if entry.get('container')), None)
|
|
@@ -876,7 +915,7 @@ class Server:
|
|
|
876
915
|
self.refuse_lane(wanted, lanes[wanted])
|
|
877
916
|
refuse('not_in_container', f'no unregistered session {wanted!r} in this container')
|
|
878
917
|
else:
|
|
879
|
-
found =
|
|
918
|
+
found = self.whoami()
|
|
880
919
|
ghost = found.get('session') if found['kind'] == 'unregistered' else None
|
|
881
920
|
if ghost is None:
|
|
882
921
|
if found.get('entry'):
|
|
@@ -981,7 +1020,7 @@ class Server:
|
|
|
981
1020
|
lanes, status = self.lanes()
|
|
982
1021
|
ws = arguments.get('workspace')
|
|
983
1022
|
if not ws:
|
|
984
|
-
found =
|
|
1023
|
+
found = self.whoami()
|
|
985
1024
|
ws = (found.get('entry') or {}).get('ws') or (found.get('session') or {}).get('workspace')
|
|
986
1025
|
if not ws:
|
|
987
1026
|
boards = sorted({entry.get('ws') for entry in self.declared(lanes).values() if entry.get('ws')})
|
|
@@ -1068,25 +1107,25 @@ class Server:
|
|
|
1068
1107
|
word, note = arguments['status'], arguments.get('note')
|
|
1069
1108
|
if note is not None and self.unprintable(note):
|
|
1070
1109
|
refuse('invalid_note', f'the note holds {", ".join(self.unprintable(note))}')
|
|
1071
|
-
found =
|
|
1110
|
+
found = self.whoami()
|
|
1072
1111
|
lane = found['lane']
|
|
1073
1112
|
if not lane:
|
|
1074
1113
|
refuse('not_in_lane', found.get('why') or 'this agent runs in no lane',
|
|
1075
1114
|
'lanes_signal reports the lane this agent runs in: start the agent inside a lane (for Codex, '
|
|
1076
1115
|
'`lane mcp install` passes the lane\'s variables to this server).')
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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()
|
|
1080
1119
|
# The agent is this server's parent (Claude Code and Codex start it directly): lane-signal records ITS pid,
|
|
1081
1120
|
# so the host's liveness check follows the agent, not this process.
|
|
1082
|
-
argv = [sys.executable, str(self.signal), word, '--event', 'lanes_signal', '--agent-pid', str(os.getppid())
|
|
1083
|
-
|
|
1121
|
+
argv = [sys.executable, str(self.signal), word, '--event', 'lanes_signal', '--agent-pid', str(os.getppid()),
|
|
1122
|
+
'--harness', harness]
|
|
1084
1123
|
argv += ['--note', note] if note is not None else []
|
|
1085
1124
|
env = {'PATH': os.environ.get('PATH') or '/usr/bin:/bin', 'HOME': str(self.home), 'LANG': 'C.UTF-8',
|
|
1086
1125
|
'LANE_ROOT': str(self.root), 'LANE_ID': lane}
|
|
1087
1126
|
# The agent's session, so the sidecar names the session its own hooks name (their stdin's `session_id`):
|
|
1088
1127
|
# Claude Code gives its MCP servers CLAUDE_CODE_SESSION_ID (MEASURED 2026-10-06 in a server's environ; it
|
|
1089
|
-
# 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
|
|
1090
1129
|
# starttime first (Codex clears a server's environment; a /clear gives the agent a new session), then by this
|
|
1091
1130
|
# session (DEF-1801).
|
|
1092
1131
|
env.update({key: self.environ[key] for key in SESSION_VARS if self.environ.get(key)})
|
|
@@ -1108,7 +1147,7 @@ class Server:
|
|
|
1108
1147
|
|
|
1109
1148
|
|
|
1110
1149
|
def tool_publish(self, arguments):
|
|
1111
|
-
found =
|
|
1150
|
+
found = self.whoami()
|
|
1112
1151
|
lane = found['lane']
|
|
1113
1152
|
if not lane:
|
|
1114
1153
|
refuse('not_in_lane', found.get('why') or 'this agent runs in no lane',
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""lane-signal <status> [--event NAME] [--harness claude|codex] [--note TEXT]
|
|
3
|
-
target of Claude Code
|
|
4
|
-
|
|
5
|
-
pid>/root and publishes as the
|
|
2
|
+
"""lane-signal <status> [--event NAME] [--harness claude|codex|kimi|grok|grok-cli|unknown] [--note TEXT]
|
|
3
|
+
[--agent-pid PID] [PAYLOAD] — the hook target of Claude Code, Codex, Kimi Code, Grok Build and grok-cli inside a
|
|
4
|
+
workspace container, and the writer behind lane-mcp's `lanes_signal`. Writes ~/.lz/state/hooks/<lane id>.json
|
|
5
|
+
(atomic replace), which the host's lanes-statusd reads through /proc/<container init pid>/root and publishes as the
|
|
6
|
+
lane's `agent{}` (source `hook`).
|
|
6
7
|
|
|
7
8
|
The sidecar has its own directory because `lane-run`, the root process of every zmx-held lane, writes its
|
|
8
9
|
`{phase, exit, at, pid}` record to ~/.lz/state/<zmx name>.json and exports LANE_ID=<zmx name> to the agent: a
|
|
9
10
|
sidecar at state/<lane id>.json would overwrite that record for the agent's whole life (DEF-1572). Nothing but
|
|
10
11
|
this script writes state/hooks/, and lane-run never writes there.
|
|
11
12
|
|
|
12
|
-
{"v":1, "lane":"<id>", "harness":"claude|codex", "status":"<agent{} vocabulary>",
|
|
13
|
-
began>, "at":<epoch of this event>, "event":"<hook event>", "pid":<agent pid>,
|
|
14
|
-
"session":"<session/thread id or ''>", "name":"<the agent's own note>" (only when
|
|
13
|
+
{"v":1, "lane":"<id>", "harness":"claude|codex|kimi|grok|grok-cli|unknown", "status":"<agent{} vocabulary>",
|
|
14
|
+
"since":<epoch the status began>, "at":<epoch of this event>, "event":"<hook event>", "pid":<agent pid>,
|
|
15
|
+
"start":"<its starttime>", "session":"<session/thread id or ''>", "name":"<the agent's own note>" (only when
|
|
16
|
+
there is one)}
|
|
15
17
|
|
|
16
18
|
It never fails the agent and never speaks: every path exits 0 and nothing reaches stdout or stderr (a Claude Code
|
|
17
19
|
UserPromptSubmit/SessionStart hook's stdout becomes model context). An unresolvable lane, a status outside the
|
|
@@ -28,10 +30,17 @@ Agent pid: --agent-pid when that process exists (lane-mcp passes its own parent,
|
|
|
28
30
|
default rule below would name lane-mcp itself); else the parent process, or that parent's parent when the parent is
|
|
29
31
|
a shell (Claude Code runs a hook command through a shell, which may fork it rather than exec it; a pid that dies
|
|
30
32
|
with the shell would read as a dead agent).
|
|
31
|
-
Session id: `session_id` of the hook's JSON on stdin, `thread-id` of a JSON
|
|
32
|
-
its JSON as the last argument), else $CLAUDE_CODE_SESSION_ID / $CODEX_THREAD_ID
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
Session id: `session_id` of the hook's JSON on stdin (Grok Build's camel-case `sessionId` too), `thread-id` of a JSON
|
|
34
|
+
PAYLOAD argument (Codex `notify` passes its JSON as the last argument), else $CLAUDE_CODE_SESSION_ID / $CODEX_THREAD_ID
|
|
35
|
+
/ $GROK_SESSION_ID (lane-mcp passes the first and the last: Claude Code and Grok Build give them to their MCP servers;
|
|
36
|
+
Grok Build also sets GROK_SESSION_ID for every hook it runs).
|
|
37
|
+
Harness (W12, DEF-1990): --harness when it is one of HARNESSES, else $LANE_HARNESS (the same rule), else the first
|
|
38
|
+
process up the parent chain (PARENT_DEPTH steps from this process's parent) whose comm is a harness's
|
|
39
|
+
(HARNESS_COMMS: claude, codex, kimi, kimi-code, grok; a `grok` is Grok Build when /proc/<pid>/exe ends in
|
|
40
|
+
GROK_BUILD_EXE, else grok-cli; any comm whose exe holds CLAUDE_EXE is Claude Code), else `unknown` — never `codex`:
|
|
41
|
+
before W12 every hook without --harness and outside Claude Code was recorded as Codex. $LANE_PROC names another /proc for the chain and the agent's starttime (tests).
|
|
42
|
+
Stdin is read only when it is not a terminal and for at most STDIN_WAIT seconds: a stdin that never closes cannot hang
|
|
43
|
+
the hook.
|
|
35
44
|
The same agent: the same process (pid and starttime: lane-mcp's parent and a hook's are the agent), else the same
|
|
36
45
|
session when both writes name one (lane-mcp under Codex knows no session; the agent's hooks do).
|
|
37
46
|
|
|
@@ -67,7 +76,12 @@ NOTE_MAX = 120
|
|
|
67
76
|
EXPLICIT = 'lanes_signal' # the --event of lane-mcp's lanes_signal: the agent's own word
|
|
68
77
|
REPORT_TOOL = 'lanes_signal' # its tool name; Claude Code names an MCP tool mcp__<server>__<tool>
|
|
69
78
|
RESTING = {('Stop', 'needs_input'), ('Stop', 'done'), ('Notification', 'needs_input')} # the hooks' "turn ended"
|
|
70
|
-
SESSION_VARS = ('CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID')
|
|
79
|
+
SESSION_VARS = ('CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID', 'GROK_SESSION_ID')
|
|
80
|
+
HARNESSES = ('claude', 'codex', 'kimi', 'grok', 'grok-cli', 'unknown') # agent{}.harness, but `shell`
|
|
81
|
+
HARNESS_COMMS = {'claude': 'claude', 'codex': 'codex', 'kimi': 'kimi', 'kimi-code': 'kimi', 'grok': 'grok'}
|
|
82
|
+
GROK_BUILD_EXE = '/downloads/grok-linux-x86_64' # Grok Build's file ($GROK_HOME/downloads/…); grok-cli's: bin/grok
|
|
83
|
+
CLAUDE_EXE = '/claude/versions/' # native Claude Code before 2.1.299 kept its file's name, the version, as comm (MEASURED)
|
|
84
|
+
PARENT_DEPTH = 8
|
|
71
85
|
|
|
72
86
|
|
|
73
87
|
def options(argv):
|
|
@@ -115,15 +129,15 @@ def lane_id(root):
|
|
|
115
129
|
return found if LANE_ID.fullmatch(found or '') else ''
|
|
116
130
|
|
|
117
131
|
|
|
118
|
-
def stat_fields(pid):
|
|
132
|
+
def stat_fields(pid, proc='/proc'):
|
|
119
133
|
"""Fields after `comm` of /proc/<pid>/stat (state is [0], ppid [1], starttime [19]) and the comm itself."""
|
|
120
|
-
with open(f'
|
|
134
|
+
with open(f'{proc}/{pid}/stat') as handle:
|
|
121
135
|
text = handle.read()
|
|
122
136
|
comm = text[text.index('(') + 1:text.rindex(')')]
|
|
123
137
|
return comm, text.rsplit(')', 1)[1].split()
|
|
124
138
|
|
|
125
139
|
|
|
126
|
-
def agent(given=None):
|
|
140
|
+
def agent(given=None, proc='/proc'):
|
|
127
141
|
"""(pid, starttime) of the agent: `given` (--agent-pid) when that process exists, else the parent — or the
|
|
128
142
|
parent's parent when the parent is a shell."""
|
|
129
143
|
try:
|
|
@@ -132,20 +146,69 @@ def agent(given=None):
|
|
|
132
146
|
pid = 0
|
|
133
147
|
if pid > 1:
|
|
134
148
|
try:
|
|
135
|
-
return pid, stat_fields(pid)[1][19]
|
|
149
|
+
return pid, stat_fields(pid, proc)[1][19]
|
|
136
150
|
except (OSError, ValueError, IndexError):
|
|
137
151
|
pass # gone (or never there): the parent rule below names the agent
|
|
138
152
|
pid = os.getppid()
|
|
139
153
|
try:
|
|
140
|
-
comm, fields = stat_fields(pid)
|
|
154
|
+
comm, fields = stat_fields(pid, proc)
|
|
141
155
|
if comm in SHELLS and int(fields[1]) > 1:
|
|
142
156
|
pid = int(fields[1])
|
|
143
|
-
comm, fields = stat_fields(pid)
|
|
157
|
+
comm, fields = stat_fields(pid, proc)
|
|
144
158
|
return pid, fields[19]
|
|
145
159
|
except (OSError, ValueError, IndexError):
|
|
146
160
|
return pid, None
|
|
147
161
|
|
|
148
162
|
|
|
163
|
+
def exe_of(pid, proc='/proc'):
|
|
164
|
+
"""The path /proc/<pid>/exe names ('' when it cannot be read), without the kernel's ` (deleted)`."""
|
|
165
|
+
try:
|
|
166
|
+
return os.readlink(f'{proc}/{pid}/exe').removesuffix(' (deleted)')
|
|
167
|
+
except OSError:
|
|
168
|
+
return ''
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def process_harness(comm, pid, proc='/proc'):
|
|
172
|
+
"""The harness process `pid` (whose comm is `comm`) is, or None. A `grok` is Grok Build when its executable is
|
|
173
|
+
$GROK_HOME/downloads/grok-linux-x86_64 (the installer's file, run through the bin/grok link), anything else grok-cli
|
|
174
|
+
(its own bin/grok, a real file); a process running a native Claude Code file (…/claude/versions/<version>) is
|
|
175
|
+
Claude Code whatever its comm."""
|
|
176
|
+
found = HARNESS_COMMS.get(comm)
|
|
177
|
+
if found == 'grok':
|
|
178
|
+
return 'grok' if exe_of(pid, proc).endswith(GROK_BUILD_EXE) else 'grok-cli'
|
|
179
|
+
if found:
|
|
180
|
+
return found
|
|
181
|
+
return 'claude' if CLAUDE_EXE in exe_of(pid, proc) else None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def parent_harness(proc='/proc', start=None):
|
|
185
|
+
"""The harness of the first process up the parent chain whose comm is a harness's (HARNESS_COMMS), at most
|
|
186
|
+
PARENT_DEPTH steps from `start` (default: this process's parent), else ''. A hook runs as the harness's child or a
|
|
187
|
+
shell's; lane-mcp's lanes_signal as lane-mcp's child, whose parent is the agent."""
|
|
188
|
+
pid = os.getppid() if start is None else start
|
|
189
|
+
for _step in range(PARENT_DEPTH):
|
|
190
|
+
if pid <= 1:
|
|
191
|
+
break
|
|
192
|
+
try:
|
|
193
|
+
comm, fields = stat_fields(pid, proc)
|
|
194
|
+
parent = int(fields[1])
|
|
195
|
+
except (OSError, ValueError, IndexError):
|
|
196
|
+
break
|
|
197
|
+
found = process_harness(comm, pid, proc)
|
|
198
|
+
if found:
|
|
199
|
+
return found
|
|
200
|
+
pid = parent
|
|
201
|
+
return ''
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def harness_of(given, proc='/proc'):
|
|
205
|
+
"""--harness, else $LANE_HARNESS, each only when it is one of HARNESSES; else the parent chain; else `unknown`."""
|
|
206
|
+
for word in (given, os.environ.get('LANE_HARNESS') or ''):
|
|
207
|
+
if word in HARNESSES:
|
|
208
|
+
return word
|
|
209
|
+
return parent_harness(proc) or 'unknown'
|
|
210
|
+
|
|
211
|
+
|
|
149
212
|
def stdin_doc():
|
|
150
213
|
"""The hook's JSON on stdin as a dict ({} when there is none): Claude Code and Codex pass `session_id`,
|
|
151
214
|
`hook_event_name` and, for a tool event, `tool_name`."""
|
|
@@ -194,7 +257,7 @@ def main(argv):
|
|
|
194
257
|
if not lane:
|
|
195
258
|
return
|
|
196
259
|
hook = stdin_doc()
|
|
197
|
-
session = str(hook.get('session_id') or '')
|
|
260
|
+
session = str(hook.get('session_id') or hook.get('sessionId') or '')
|
|
198
261
|
if not session and payload:
|
|
199
262
|
try:
|
|
200
263
|
doc = json.loads(payload)
|
|
@@ -202,10 +265,10 @@ def main(argv):
|
|
|
202
265
|
except ValueError:
|
|
203
266
|
session = ''
|
|
204
267
|
session = (session or next((os.environ[key] for key in SESSION_VARS if os.environ.get(key)), ''))[:128]
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
pid, start = agent(agent_pid)
|
|
268
|
+
proc = os.environ.get('LANE_PROC') or '/proc'
|
|
269
|
+
given_harness = harness if harness in HARNESSES else ''
|
|
270
|
+
harness = harness_of(harness, proc)
|
|
271
|
+
pid, start = agent(agent_pid, proc)
|
|
209
272
|
now = int(time.time())
|
|
210
273
|
state = os.path.join(root, 'state', 'hooks') # never state/<id>.json: lane-run's phase record (DEF-1572)
|
|
211
274
|
path = os.path.join(state, f'{lane}.json')
|