@vibelord/lanes 1.2.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +7 -5
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +1091 -76
  5. package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
  6. package/payload/lanes/container/hooks/grok-hooks.json +12 -0
  7. package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
  8. package/payload/lanes/container/lane +55 -8
  9. package/payload/lanes/container/lane-mcp +120 -22
  10. package/payload/lanes/container/lane-signal +86 -23
  11. package/payload/lanes/container/lane_core.py +524 -36
  12. package/payload/lanes/container/throttle-pulse +456 -0
  13. package/payload/lanes/docs/ARCHITECTURE.md +357 -34
  14. package/payload/lanes/docs/DESIGN.md +13 -3
  15. package/payload/lanes/docs/INSTALL.md +47 -10
  16. package/payload/lanes/docs/RUNBOOK.md +761 -12
  17. package/payload/lanes/docs/USERGUIDE.md +421 -46
  18. package/payload/lanes/holders.lock +5 -0
  19. package/payload/lanes/install/enable-docs.sh +78 -0
  20. package/payload/lanes/install/host-install.sh +39 -5
  21. package/payload/lanes/libexec/agent_state.py +604 -12
  22. package/payload/lanes/libexec/docs_store.py +995 -0
  23. package/payload/lanes/libexec/docsd.py +2010 -0
  24. package/payload/lanes/libexec/economy_poll.py +6 -2
  25. package/payload/lanes/libexec/harness_grok.py +742 -0
  26. package/payload/lanes/libexec/lanes_lib.py +143 -20
  27. package/payload/lanes/libexec/mdlite.py +575 -0
  28. package/payload/lanes/libexec/members.py +5 -0
  29. package/payload/lanes/libexec/notify.py +65 -5
  30. package/payload/lanes/libexec/sidebar.py +626 -25
  31. package/payload/lanes/libexec/statusd.py +530 -32
  32. package/payload/lanes/libexec/tokens.py +42 -9
  33. package/payload/lanes/libexec/update.py +171 -26
  34. package/payload/lanes/libexec/uploads.py +538 -0
  35. package/payload/lanes/libexec/users.py +31 -3
  36. package/payload/lanes/site/site.example.conf +4 -0
  37. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  38. package/payload/lanes/web/Caddyfile +24 -1
  39. package/payload/lanes/web/README.md +86 -8
  40. package/payload/lanes/web/browser.js +2 -4
  41. package/payload/lanes/web/docs.css +52 -0
  42. package/payload/lanes/web/docs.js +36 -0
  43. package/payload/lanes/web/mobile.js +3 -2
  44. package/payload/lanes/web/upload.css +17 -0
  45. package/payload/lanes/web/upload.js +84 -0
  46. package/payload/manifest.json +5 -3
@@ -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')