@vibelord/lanes 1.1.1 → 1.3.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 (71) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +47 -23
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2753 -202
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +147 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1150 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1318 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +793 -16
  20. package/payload/lanes/docs/DESIGN.md +127 -46
  21. package/payload/lanes/docs/INSTALL.md +175 -18
  22. package/payload/lanes/docs/RUNBOOK.md +968 -76
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +967 -150
  25. package/payload/lanes/holders.lock +5 -0
  26. package/payload/lanes/install/enable-docs.sh +78 -0
  27. package/payload/lanes/install/enable-web.sh +2 -0
  28. package/payload/lanes/install/host-install.sh +197 -7
  29. package/payload/lanes/install/services.py +148 -18
  30. package/payload/lanes/install.sh +68 -7
  31. package/payload/lanes/libexec/agent_state.py +74 -1
  32. package/payload/lanes/libexec/cursor_sync.py +6 -0
  33. package/payload/lanes/libexec/device_sync.py +47 -10
  34. package/payload/lanes/libexec/docs_store.py +995 -0
  35. package/payload/lanes/libexec/docsd.py +2010 -0
  36. package/payload/lanes/libexec/economy_poll.py +1 -0
  37. package/payload/lanes/libexec/enrold.py +41 -22
  38. package/payload/lanes/libexec/keys.py +54 -23
  39. package/payload/lanes/libexec/lanes_lib.py +620 -22
  40. package/payload/lanes/libexec/mdlite.py +575 -0
  41. package/payload/lanes/libexec/members.py +21 -2
  42. package/payload/lanes/libexec/notify.py +169 -29
  43. package/payload/lanes/libexec/registrations.py +216 -18
  44. package/payload/lanes/libexec/session_actions.py +182 -14
  45. package/payload/lanes/libexec/sidebar +11 -3
  46. package/payload/lanes/libexec/sidebar.py +2232 -365
  47. package/payload/lanes/libexec/statusd.py +231 -12
  48. package/payload/lanes/libexec/studio_host.py +8 -1
  49. package/payload/lanes/libexec/tokens.py +148 -35
  50. package/payload/lanes/libexec/tokens.sh +86 -2
  51. package/payload/lanes/libexec/update.py +1044 -0
  52. package/payload/lanes/libexec/uploads.py +538 -0
  53. package/payload/lanes/libexec/users.py +614 -0
  54. package/payload/lanes/libexec/view.sh +34 -6
  55. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  56. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  57. package/payload/lanes/systemd/lanes-gc.service +1 -1
  58. package/payload/lanes/systemd/lanes-update.service +17 -0
  59. package/payload/lanes/systemd/lanes-update.timer +17 -0
  60. package/payload/lanes/web/Caddyfile +24 -1
  61. package/payload/lanes/web/README.md +165 -28
  62. package/payload/lanes/web/browser.js +4 -8
  63. package/payload/lanes/web/docs.css +52 -0
  64. package/payload/lanes/web/docs.js +36 -0
  65. package/payload/lanes/web/mobile.css +17 -17
  66. package/payload/lanes/web/mobile.js +45 -32
  67. package/payload/lanes/web/upload.css +17 -0
  68. package/payload/lanes/web/upload.js +84 -0
  69. package/payload/lanes/workspaces.example/README.md +3 -1
  70. package/payload/lanes/zellij/config.kdl +106 -56
  71. package/payload/manifest.json +6 -3
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env python3
2
- """lane-signal <status> [--event NAME] [--harness claude|codex] [PAYLOAD] — the hook target of Claude Code and Codex
3
- inside a workspace container. Writes ~/.lz/state/hooks/<lane id>.json (atomic replace), which the host's
4
- lanes-statusd reads through /proc/<container init pid>/root and publishes as the lane's `agent{}` (source `hook`).
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`).
5
6
 
6
7
  The sidecar has its own directory because `lane-run`, the root process of every zmx-held lane, writes its
7
8
  `{phase, exit, at, pid}` record to ~/.lz/state/<zmx name>.json and exports LANE_ID=<zmx name> to the agent: a
@@ -10,7 +11,7 @@ this script writes state/hooks/, and lane-run never writes there.
10
11
 
11
12
  {"v":1, "lane":"<id>", "harness":"claude|codex", "status":"<agent{} vocabulary>", "since":<epoch the status
12
13
  began>, "at":<epoch of this event>, "event":"<hook event>", "pid":<agent pid>, "start":"<its starttime>",
13
- "session":"<session/thread id or ''>"}
14
+ "session":"<session/thread id or ''>", "name":"<the agent's own note>" (only when there is one)}
14
15
 
15
16
  It never fails the agent and never speaks: every path exits 0 and nothing reaches stdout or stderr (a Claude Code
16
17
  UserPromptSubmit/SessionStart hook's stdout becomes model context). An unresolvable lane, a status outside the
@@ -20,15 +21,33 @@ Lane id: $LANE_ID — exported in a tmux lane only by a canonical/`--identity` r
20
21
  `lane open` never exports it), and in a zmx-held lane by `lane-run` as the zmx session name (which is NOT the
21
22
  `<ws>--z-<name>` id lanes-statusd publishes for it, so statusd does not join that sidecar to the zmx lane yet:
22
23
  DEF-1573) — else the pane's tmux session (`tmux -S ${TMUX%%,*} display -p -t $TMUX_PANE '#S'`) looked up by (socket
23
- name, session) in ~/.lz/registry.json, the projection `lanes sync` writes.
24
+ name, session) in ~/.lz/registry.json, the projection `lanes sync` writes. lane-mcp passes the lane it resolved as
25
+ $LANE_ID.
24
26
 
25
- Agent pid: the parent process, or that parent's parent when the parent is a shell (Claude Code runs a hook command
26
- through a shell, which may fork it rather than exec it; a pid that dies with the shell would read as a dead agent).
27
+ Agent pid: --agent-pid when that process exists (lane-mcp passes its own parent, the agent that started it: the
28
+ default rule below would name lane-mcp itself); else the parent process, or that parent's parent when the parent is
29
+ a shell (Claude Code runs a hook command through a shell, which may fork it rather than exec it; a pid that dies
30
+ with the shell would read as a dead agent).
27
31
  Session id: `session_id` of the hook's JSON on stdin, `thread-id` of a JSON PAYLOAD argument (Codex `notify` passes
28
- its JSON as the last argument), else $CLAUDE_SESSION_ID / $CODEX_THREAD_ID. Stdin is read only when it is not a
29
- terminal and for at most STDIN_WAIT seconds: a stdin that never closes cannot hang the hook.
30
-
31
- Same status as the file already holds (same session) keeps `since` and advances `at`; a new status starts `since`.
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.
35
+ The same agent: the same process (pid and starttime: lane-mcp's parent and a hook's are the agent), else the same
36
+ session when both writes name one (lane-mcp under Codex knows no session; the agent's hooks do).
37
+
38
+ Same status as the file already holds (same session, or no session on one side and the same agent) keeps `since` and
39
+ advances `at`; a new status starts `since`.
40
+ Note (LNS-35): --note TEXT is the agent's own one-line account of what it is doing (`lanes_signal`), stored as the
41
+ sidecar's `name` — the field the host already reads as an agent's task line (agent_state.read_sidecar_dict: the
42
+ sidebar's task row, a notification's "lane: task — status") — with control characters dropped and cut to NOTE_MAX
43
+ characters; `--note ''` clears it. Without --note the note is kept while the status repeats (a hook's PostToolUse
44
+ `working` keeps a `working` note) and dropped when the status changes.
45
+ The agent's own word (EXPLICIT, DEF-1801): a report with --event lanes_signal stands against the same agent's hooks
46
+ that only say what the report already says better — the PostToolUse of the lanes_signal tool call itself (the hook
47
+ fires for MCP tools too, on the very call that wrote the report), and, unless the report is `working`, the end of a
48
+ turn (RESTING: Stop's needs_input/done, the idle Notification's needs_input). Those advance `at` and change nothing
49
+ else. Any other event — the next tool call, a new prompt, a permission prompt, a failed turn, the session's end or
50
+ start — is the agent doing something new and replaces the report as before.
32
51
  """
33
52
  import json
34
53
  import os
@@ -37,16 +56,22 @@ import select
37
56
  import subprocess
38
57
  import sys
39
58
  import time
59
+ import unicodedata
40
60
 
41
61
  STATUSES = ('working', 'needs_input', 'needs_approval', 'done', 'errored', 'shell', 'idle') # agent_state.STATUSES
42
62
  LANE_ID = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,127}')
43
63
  SHELLS = {'sh', 'dash', 'bash', 'zsh', 'ksh', 'fish'}
44
64
  STDIN_WAIT = 0.5
45
65
  STDIN_MAX = 1 << 20
66
+ NOTE_MAX = 120
67
+ EXPLICIT = 'lanes_signal' # the --event of lane-mcp's lanes_signal: the agent's own word
68
+ REPORT_TOOL = 'lanes_signal' # its tool name; Claude Code names an MCP tool mcp__<server>__<tool>
69
+ RESTING = {('Stop', 'needs_input'), ('Stop', 'done'), ('Notification', 'needs_input')} # the hooks' "turn ended"
70
+ SESSION_VARS = ('CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID')
46
71
 
47
72
 
48
73
  def options(argv):
49
- status, event, harness, payload = (argv[0] if argv else ''), '', '', ''
74
+ status, event, harness, payload, note, agent_pid = (argv[0] if argv else ''), '', '', '', None, None
50
75
  rest = argv[1:]
51
76
  while rest:
52
77
  word = rest.pop(0)
@@ -54,9 +79,20 @@ def options(argv):
54
79
  event = rest.pop(0)
55
80
  elif word == '--harness' and rest:
56
81
  harness = rest.pop(0)
82
+ elif word == '--note' and rest:
83
+ note = clean_note(rest.pop(0))
84
+ elif word == '--agent-pid' and rest:
85
+ agent_pid = rest.pop(0)
57
86
  elif word.startswith('{'):
58
87
  payload = word
59
- return status, event[:64], harness[:16], payload
88
+ return status, event[:64], harness[:16], payload, note, agent_pid
89
+
90
+
91
+ def clean_note(text):
92
+ """One line of at most NOTE_MAX characters: control characters (C0, DEL, C1) and lone surrogates dropped,
93
+ whitespace runs collapsed — the host shows it in a terminal row and a notification."""
94
+ kept = ''.join(ch for ch in text if unicodedata.category(ch) not in ('Cc', 'Cs'))
95
+ return ' '.join(kept.split())[:NOTE_MAX]
60
96
 
61
97
 
62
98
  def lane_id(root):
@@ -87,7 +123,18 @@ def stat_fields(pid):
87
123
  return comm, text.rsplit(')', 1)[1].split()
88
124
 
89
125
 
90
- def agent():
126
+ def agent(given=None):
127
+ """(pid, starttime) of the agent: `given` (--agent-pid) when that process exists, else the parent — or the
128
+ parent's parent when the parent is a shell."""
129
+ try:
130
+ pid = int(given) if given is not None else 0
131
+ except ValueError:
132
+ pid = 0
133
+ if pid > 1:
134
+ try:
135
+ return pid, stat_fields(pid)[1][19]
136
+ except (OSError, ValueError, IndexError):
137
+ pass # gone (or never there): the parent rule below names the agent
91
138
  pid = os.getppid()
92
139
  try:
93
140
  comm, fields = stat_fields(pid)
@@ -99,12 +146,14 @@ def agent():
99
146
  return pid, None
100
147
 
101
148
 
102
- def stdin_session():
149
+ def stdin_doc():
150
+ """The hook's JSON on stdin as a dict ({} when there is none): Claude Code and Codex pass `session_id`,
151
+ `hook_event_name` and, for a tool event, `tool_name`."""
103
152
  try:
104
153
  if os.isatty(0):
105
- return ''
154
+ return {}
106
155
  except OSError:
107
- return ''
156
+ return {}
108
157
  data, deadline = b'', time.monotonic() + STDIN_WAIT
109
158
  try:
110
159
  while len(data) < STDIN_MAX:
@@ -117,29 +166,46 @@ def stdin_session():
117
166
  data += chunk
118
167
  doc = json.loads(data.decode('utf-8', 'replace')) if data.strip() else {}
119
168
  except (OSError, ValueError):
120
- return ''
121
- return str(doc.get('session_id') or '') if isinstance(doc, dict) else ''
169
+ return {}
170
+ return doc if isinstance(doc, dict) else {}
171
+
172
+
173
+ def report_call(tool):
174
+ """True for the lanes_signal tool itself, however the harness prefixes an MCP tool (mcp__lanes__lanes_signal)."""
175
+ return isinstance(tool, str) and re.split(r'__|[/.:]', tool)[-1] == REPORT_TOOL
176
+
177
+
178
+ def same_agent(old, session, pid, start):
179
+ """`old` was written by this agent: the same process (pid and starttime; a session the agent cleared or resumed
180
+ is still its own, and lane-mcp's CLAUDE_CODE_SESSION_ID is the one it was started with), else the same session
181
+ when both name one (a hook run through two shells names a shell, not the agent)."""
182
+ if pid > 1 and old.get('pid') == pid and start is not None and old.get('start') == start:
183
+ return True
184
+ stored = str(old.get('session') or '')
185
+ return bool(session and stored and session == stored)
122
186
 
123
187
 
124
188
  def main(argv):
125
- status, event, harness, payload = options(argv)
189
+ status, event, harness, payload, note, agent_pid = options(argv)
126
190
  if status not in STATUSES:
127
191
  return
128
192
  root = os.environ.get('LANE_ROOT') or os.path.join(os.path.expanduser('~'), '.lz')
129
193
  lane = lane_id(root)
130
194
  if not lane:
131
195
  return
132
- session = stdin_session()
196
+ hook = stdin_doc()
197
+ session = str(hook.get('session_id') or '')
133
198
  if not session and payload:
134
199
  try:
135
200
  doc = json.loads(payload)
136
201
  session = str(doc.get('thread-id') or doc.get('session_id') or '') if isinstance(doc, dict) else ''
137
202
  except ValueError:
138
203
  session = ''
139
- session = session or os.environ.get('CLAUDE_SESSION_ID') or os.environ.get('CODEX_THREAD_ID') or ''
204
+ session = (session or next((os.environ[key] for key in SESSION_VARS if os.environ.get(key)), ''))[:128]
205
+ given_harness = harness
140
206
  harness = harness or os.environ.get('LANE_HARNESS') or ('claude' if os.environ.get('CLAUDE_CODE_ENTRYPOINT')
141
207
  else 'codex')
142
- pid, start = agent()
208
+ pid, start = agent(agent_pid)
143
209
  now = int(time.time())
144
210
  state = os.path.join(root, 'state', 'hooks') # never state/<id>.json: lane-run's phase record (DEF-1572)
145
211
  path = os.path.join(state, f'{lane}.json')
@@ -147,12 +213,26 @@ def main(argv):
147
213
  old = json.load(open(path))
148
214
  except (OSError, ValueError):
149
215
  old = None
150
- session, since = session[:128], now
151
- if (isinstance(old, dict) and old.get('v') == 1 and old.get('status') == status
152
- and (not session or old.get('session') == session) and isinstance(old.get('since'), int)):
153
- since, session = old['since'], session or str(old.get('session') or '')
154
- doc = {'v': 1, 'lane': lane, 'harness': harness, 'status': status, 'since': since, 'at': now, 'event': event,
155
- 'pid': pid, 'start': start, 'session': session}
216
+ if not (isinstance(old, dict) and old.get('v') == 1 and old.get('status') in STATUSES
217
+ and isinstance(old.get('since'), int)):
218
+ old = None
219
+ stored = str((old or {}).get('session') or '')
220
+ if (old and old.get('event') == EXPLICIT and event != EXPLICIT and same_agent(old, session, pid, start)
221
+ and ((event == 'PostToolUse' and report_call(hook.get('tool_name')))
222
+ or ((event, status) in RESTING and old['status'] != 'working'))):
223
+ # The agent's own report stands (EXPLICIT): this hook only says what the report already says better.
224
+ doc = dict(old, at=now, session=stored or session, harness=given_harness or old.get('harness') or harness)
225
+ else:
226
+ since = now
227
+ if (old and old['status'] == status
228
+ and (not session or stored == session or (not stored and same_agent(old, session, pid, start)))):
229
+ since, session = old['since'], session or stored
230
+ if note is None and isinstance(old.get('name'), str):
231
+ note = clean_note(old['name']) # the same stretch keeps the agent's note; a new status drops it
232
+ doc = {'v': 1, 'lane': lane, 'harness': harness, 'status': status, 'since': since, 'at': now, 'event': event,
233
+ 'pid': pid, 'start': start, 'session': session}
234
+ if note:
235
+ doc['name'] = note
156
236
  os.makedirs(state, exist_ok=True)
157
237
  tmp = os.path.join(state, f'.{lane}.json.{os.getpid()}.tmp')
158
238
  try: