@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.
- package/README.md +36 -5
- package/bin/lanes-install.mjs +166 -13
- package/lib/plan.mjs +113 -3
- package/package.json +1 -1
- package/payload/lanes/README.md +47 -23
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +2753 -202
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
- package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
- package/payload/lanes/client/lanes-launch +289 -40
- package/payload/lanes/client/lanes-window +15 -3
- package/payload/lanes/container/hooks/claude-settings.json +1 -0
- package/payload/lanes/container/lane +147 -92
- package/payload/lanes/container/lane-edit +100 -0
- package/payload/lanes/container/lane-mcp +1150 -0
- package/payload/lanes/container/lane-signal +109 -29
- package/payload/lanes/container/lane_core.py +1318 -0
- package/payload/lanes/container/vimrc +31 -0
- package/payload/lanes/docs/ARCHITECTURE.md +793 -16
- package/payload/lanes/docs/DESIGN.md +127 -46
- package/payload/lanes/docs/INSTALL.md +175 -18
- package/payload/lanes/docs/RUNBOOK.md +968 -76
- package/payload/lanes/docs/SHARING.md +3 -2
- package/payload/lanes/docs/USERGUIDE.md +967 -150
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/enable-web.sh +2 -0
- package/payload/lanes/install/host-install.sh +197 -7
- package/payload/lanes/install/services.py +148 -18
- package/payload/lanes/install.sh +68 -7
- package/payload/lanes/libexec/agent_state.py +74 -1
- package/payload/lanes/libexec/cursor_sync.py +6 -0
- package/payload/lanes/libexec/device_sync.py +47 -10
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/economy_poll.py +1 -0
- package/payload/lanes/libexec/enrold.py +41 -22
- package/payload/lanes/libexec/keys.py +54 -23
- package/payload/lanes/libexec/lanes_lib.py +620 -22
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +21 -2
- package/payload/lanes/libexec/notify.py +169 -29
- package/payload/lanes/libexec/registrations.py +216 -18
- package/payload/lanes/libexec/session_actions.py +182 -14
- package/payload/lanes/libexec/sidebar +11 -3
- package/payload/lanes/libexec/sidebar.py +2232 -365
- package/payload/lanes/libexec/statusd.py +231 -12
- package/payload/lanes/libexec/studio_host.py +8 -1
- package/payload/lanes/libexec/tokens.py +148 -35
- package/payload/lanes/libexec/tokens.sh +86 -2
- package/payload/lanes/libexec/update.py +1044 -0
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +614 -0
- package/payload/lanes/libexec/view.sh +34 -6
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/systemd/lanes-ensure.service +3 -0
- package/payload/lanes/systemd/lanes-gc.service +1 -1
- package/payload/lanes/systemd/lanes-update.service +17 -0
- package/payload/lanes/systemd/lanes-update.timer +17 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +165 -28
- package/payload/lanes/web/browser.js +4 -8
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.css +17 -17
- package/payload/lanes/web/mobile.js +45 -32
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/lanes/workspaces.example/README.md +3 -1
- package/payload/lanes/zellij/config.kdl +106 -56
- 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
|
|
3
|
-
|
|
4
|
-
lanes-statusd reads through /proc/<container init
|
|
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:
|
|
26
|
-
|
|
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 $
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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:
|