@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.
@@ -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): 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
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 = 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)
540
545
  while rest:
541
546
  word = rest.pop(0)
542
- 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',):
543
548
  pick = rest.pop(0)
544
- 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
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 (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)')
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 (`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
 
@@ -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 its env carries one
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', 'claude', 'codex'],
147
- '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.'}}),
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 = C.whoami(self.root, self.environ)
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 ('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):
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 = C.whoami(self.root, self.environ)
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 = C.whoami(self.root, self.environ)
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 = C.whoami(self.root, self.environ)
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
- client = self.client.lower()
1078
- harness = ('claude' if 'claude' in client else 'codex' if 'codex' in client
1079
- 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()
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
- 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]
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 = C.whoami(self.root, self.environ)
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] [--agent-pid PID] [PAYLOAD] — the hook
3
- target of Claude Code and Codex inside a workspace container, and the writer behind lane-mcp's `lanes_signal`. Writes
4
- ~/.lz/state/hooks/<lane id>.json (atomic replace), which the host's lanes-statusd reads through /proc/<container init
5
- pid>/root and publishes as the lane's `agent{}` (source `hook`).
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>", "since":<epoch the status
13
- began>, "at":<epoch of this event>, "event":"<hook event>", "pid":<agent pid>, "start":"<its starttime>",
14
- "session":"<session/thread id or ''>", "name":"<the agent's own note>" (only when there is one)}
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 PAYLOAD argument (Codex `notify` passes
32
- its JSON as the last argument), else $CLAUDE_CODE_SESSION_ID / $CODEX_THREAD_ID (lane-mcp passes the first: Claude
33
- Code gives it to its MCP servers). Stdin is read only when it is not a terminal and for at most STDIN_WAIT seconds:
34
- a stdin that never closes cannot hang the hook.
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'/proc/{pid}/stat') as handle:
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
- given_harness = harness
206
- harness = harness or os.environ.get('LANE_HARNESS') or ('claude' if os.environ.get('CLAUDE_CODE_ENTRYPOINT')
207
- else 'codex')
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')