@vibelord/lanes 1.0.0 → 1.0.2

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 (56) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +92 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +928 -150
  5. package/payload/lanes/client/ghostty/common.conf +3 -0
  6. package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
  7. package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
  8. package/payload/lanes/client/ghostty/omarchy.conf +3 -2
  9. package/payload/lanes/client/lanes-launch +62 -5
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +8 -0
  12. package/payload/lanes/container/hooks/claude-settings.json +10 -0
  13. package/payload/lanes/container/hooks/codex-hooks.json +22 -0
  14. package/payload/lanes/container/lane +326 -25
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +158 -6
  17. package/payload/lanes/docs/DESIGN.md +250 -0
  18. package/payload/lanes/docs/INSTALL.md +39 -0
  19. package/payload/lanes/docs/RUNBOOK.md +507 -24
  20. package/payload/lanes/docs/SHARING.md +1 -1
  21. package/payload/lanes/holders/exec.sh +2 -5
  22. package/payload/lanes/holders/ssh.sh +18 -0
  23. package/payload/lanes/holders.lock +3 -0
  24. package/payload/lanes/install/enable-phone.sh +15 -6
  25. package/payload/lanes/install/enable-web.sh +19 -6
  26. package/payload/lanes/install/host-install.sh +10 -1
  27. package/payload/lanes/install/services.py +30 -3
  28. package/payload/lanes/install.sh +108 -3
  29. package/payload/lanes/libexec/agent_state.py +246 -0
  30. package/payload/lanes/libexec/connection.py +178 -0
  31. package/payload/lanes/libexec/cursor_sync.py +147 -6
  32. package/payload/lanes/libexec/device_sync.py +134 -0
  33. package/payload/lanes/libexec/economy_poll.py +685 -0
  34. package/payload/lanes/libexec/guard.py +27 -1
  35. package/payload/lanes/libexec/lanes_lib.py +436 -36
  36. package/payload/lanes/libexec/notify.py +317 -0
  37. package/payload/lanes/libexec/registrations.py +148 -0
  38. package/payload/lanes/libexec/session_actions.py +53 -22
  39. package/payload/lanes/libexec/sidebar.py +1190 -186
  40. package/payload/lanes/libexec/statusd.py +352 -90
  41. package/payload/lanes/libexec/tokens.py +310 -0
  42. package/payload/lanes/libexec/tokens.sh +11 -0
  43. package/payload/lanes/libexec/view.sh +22 -11
  44. package/payload/lanes/systemd/lanes-economy.service +20 -0
  45. package/payload/lanes/systemd/lanes-notify.service +19 -0
  46. package/payload/lanes/systemd/lanes-sync.service +3 -0
  47. package/payload/lanes/web/README.md +96 -2
  48. package/payload/lanes/web/browser.css +22 -0
  49. package/payload/lanes/web/browser.js +73 -0
  50. package/payload/lanes/web/index.html +2 -1
  51. package/payload/lanes/web/mobile.css +108 -221
  52. package/payload/lanes/web/mobile.js +105 -55
  53. package/payload/lanes/workspaces.example/README.md +9 -3
  54. package/payload/lanes/zellij/config.kdl +128 -19
  55. package/payload/manifest.json +7 -3
  56. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -1,25 +1,34 @@
1
1
  #!/usr/bin/env python3
2
2
  """lane — the in-container lane contract (Python 3 stdlib). Installed at /home/coder/.lz/bin/lane.
3
3
 
4
- A lane is one long-lived agent terminal, held by a per-lane zmx daemon (byte pass-through + instant
5
- re-attach). Viewers, orchestrators and the web harness use ONLY these verbs, so the holder stays swappable.
4
+ A lane is one long-lived agent terminal, held by a tmux session (the lanes registry's `holder: tmux` lanes, named in
5
+ ~/.lz/registry.json) or by a per-lane zmx daemon (`lane new`). Viewers, orchestrators and the web harness use ONLY
6
+ these verbs, so the holder stays swappable: every verb below resolves the id in ~/.lz/registry.json first and drives
7
+ a `holder: tmux` lane through its exact socket and session; any other id is a zmx lane, as before.
6
8
 
7
- lane new <id> [--ws W] [--title T] [--cwd DIR] [--icon I] [--color C] -- <command...>
9
+ lane new <id> [--ws W] [--title T] [--cwd DIR] [--icon I] [--color C] -- <command...> (zmx only)
8
10
  lane open <lane-id> the uniform entry point for editors (Cursor / VS Code terminals.json): attach to the
9
11
  lane; if it does not exist, run its recorded create recipe. Never nests inside tmux.
10
12
  While the lanes view shows the lane, attach with ignore-size (the lanes view owns the size).
11
- lane attach <id> attach this terminal (never creates)
12
- lane send <id> <text...> type into the lane (use $'\\r' for Enter)
13
+ lane attach <id> attach this terminal (never creates; a tmux lane never nests inside tmux)
14
+ lane send <id> [--no-enter] <text...> type into the lane. tmux: literally, then Enter unless --no-enter.
15
+ zmx: the bytes as given, never an Enter of its own (use $'\\r')
13
16
  lane capture <id> [--lines N] [--vt] print scrollback (plain text, or VT with --vt)
14
- lane ls [--json] lanes with holder metadata
15
- lane status <id> JSON: running?, last exit of the agent command, meta
16
- lane kill <id> stop the lane (the ONLY verb that ends an agent)
17
+ lane ls [--json] [--all] zmx lanes with holder metadata; --all adds the registry's tmux lanes with `alive`
18
+ (the default listing stays zmx-only: Throttle Studio reads every row of it as zmx)
19
+ lane status <id> JSON: alive/running?, the hook agent (`lane-signal`), meta; exit 1 when not running
20
+ lane kill <id> --yes [--force] stop the lane (the ONLY verb that ends an agent); refuses while the lane's
21
+ hook sidecar says the agent is `working` unless --force
22
+ lane gc --sockets unlink this container's tmux socket files that tmux is refused on (ECONNREFUSED: no
23
+ server listens); prints each unlinked path. A live, empty or silent server keeps its file
17
24
  """
18
25
  import json
19
26
  import os
20
27
  import pty
21
28
  import re
22
29
  import select
30
+ import socket
31
+ import stat
23
32
  import subprocess
24
33
  import sys
25
34
  import time
@@ -29,7 +38,11 @@ ROOT = Path(os.environ.get('LANE_ROOT', '/home/coder/.lz'))
29
38
  VERSION = os.environ.get('LANE_ZMX_VERSION', '0.8.1')
30
39
  ZMX = ROOT / 'bin' / f'zmx-{VERSION}'
31
40
  SOCKETS, META, STATE = ROOT / 's', ROOT / 'meta', ROOT / 'state'
41
+ HOOKS = STATE / 'hooks' # lane-signal's sidecars (never lane-run's phase records one directory up, DEF-1436)
42
+ PROC = Path('/proc') # this container's pid namespace: the hook's agent pid is checked here
32
43
  IDENT = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$')
44
+ STATUSES = ('working', 'needs_input', 'needs_approval', 'done', 'errored', 'shell', 'idle') # agent_state.STATUSES
45
+ HOOK_MAX_AGE = 3600 # statusd.SIDECAR_MAX_AGE: an older last event is no agent
33
46
 
34
47
 
35
48
  def zmx_env(extra=None):
@@ -148,9 +161,127 @@ def registry_entry(lane):
148
161
  return lanes[lane]
149
162
 
150
163
 
164
+ IDENTITY = ('LANE_ID', 'LANE_WS', 'THROTTLE_TRACE') # + a lanes/ ANTHROPIC_CUSTOM_HEADERS (identity_free)
165
+
166
+
167
+ def identity_free(environ, drop=('TMUX',)):
168
+ """`environ` without any lane identity (lanes/docs/ARCHITECTURE.md "Lane identity"). Whatever tmux client STARTS
169
+ a socket's server hands its whole environment to the server's GLOBAL environment, and every later session on
170
+ that socket created without its own `-e` inherits it (MEASURED, tmux 3.4: DEF-1478). Board sockets are shared by
171
+ many lanes, so a lane identity in the env of anything that may start one would be stamped on unrelated lanes.
172
+ Identity therefore reaches a lane's shell ONLY through `-e` in its create recipe (lanes_lib.canonical_recipe).
173
+ An operator's own ANTHROPIC_CUSTOM_HEADERS that is not a lane trace is theirs and stays."""
174
+ return {k: v for k, v in environ.items() if k not in drop and k not in IDENTITY
175
+ and not (k == 'ANTHROPIC_CUSTOM_HEADERS' and v.startswith('x-throttle-trace: lanes/'))}
176
+
177
+
178
+ def tmux_run(argv):
179
+ """One tmux client, never carrying a lane identity (it could be the client that starts a shared board server)."""
180
+ return subprocess.run(argv, capture_output=True, text=True, timeout=15, env=identity_free(os.environ))
181
+
182
+
151
183
  def tmux_cmd(socket, *args):
152
- env = {k: v for k, v in os.environ.items() if k != 'TMUX'}
153
- return subprocess.run(['tmux', '-L', socket, *args], capture_output=True, text=True, timeout=15, env=env)
184
+ return tmux_run(['tmux', '-L', socket, *args])
185
+
186
+
187
+ def registry_lanes(verb):
188
+ """{lane id: entry} of ~/.lz/registry.json; {} when there is none (a zmx-only container needs none). A newer
189
+ registry is refused, as `lane open` refuses it."""
190
+ try:
191
+ data = json.loads((ROOT / 'registry.json').read_text())
192
+ lanes = data['lanes']
193
+ except (OSError, ValueError, KeyError, TypeError):
194
+ return {}
195
+ if int(data.get('version', 1)) > 1:
196
+ sys.exit(f'lane {verb}: the registry is newer than this `lane` — on the host run: lanes install-container <container>')
197
+ return lanes if isinstance(lanes, dict) else {}
198
+
199
+
200
+ def tmux_target(entry):
201
+ """(socket, session) of a registry entry held by tmux, else None."""
202
+ if not isinstance(entry, dict) or entry.get('holder') != 'tmux':
203
+ return None
204
+ tmux = entry.get('tmux') or {}
205
+ socket, session = tmux.get('socket'), tmux.get('session')
206
+ return (str(socket), str(session)) if socket and session else None
207
+
208
+
209
+ def tmux_lane(verb, lane):
210
+ """(entry, socket, session) when the registry holds `lane` as a tmux lane, else None (a zmx lane)."""
211
+ entry = registry_lanes(verb).get(lane)
212
+ target = tmux_target(entry)
213
+ return (entry, *target) if target else None
214
+
215
+
216
+ def zmx_name(verb, lane):
217
+ """The zmx session of `lane`: the registry's `zmx.name` for a registry zmx lane, else the id itself (`lane new`)."""
218
+ entry = registry_lanes(verb).get(lane)
219
+ name = ((entry or {}).get('zmx') or {}).get('name') if isinstance(entry, dict) and entry.get('holder') == 'zmx' else None
220
+ return name if isinstance(name, str) and IDENT.match(name) else lane
221
+
222
+
223
+ def tmux_text(text):
224
+ """tmux's argv parser ends a command at ONE trailing `;` of ANY argument and turns a trailing `\\;` into `;`
225
+ (MEASURED, tmux 3.4: `send-keys -l -- 'echo a;'` types `echo a`). Writing that last `;` as `\\;` types the text
226
+ exactly, a trailing `\\;` included."""
227
+ return text[:-1] + '\\;' if text.endswith(';') else text
228
+
229
+
230
+ def send_argv(socket, session, text, enter=True):
231
+ """Type `text` literally into the session's active pane, then Enter — one tmux client, `;`-separated. The target
232
+ is `=<session>:` (exact session, its current window): a bare `=<session>` is refused by send-keys and
233
+ capture-pane with "can't find pane" (MEASURED, tmux 3.4)."""
234
+ target = f'={session}:'
235
+ argv = ['tmux', '-L', socket, 'send-keys', '-t', target, '-l', '--', tmux_text(text)]
236
+ return argv + [';', 'send-keys', '-t', target, 'Enter'] if enter else argv
237
+
238
+
239
+ def capture_argv(socket, session, lines, vt=False):
240
+ """Joined scrollback of the session's active pane: the last `lines` history lines plus the screen (`-S -N`; the
241
+ caller keeps the last N non-blank lines), or all of it (`-S -`); `--vt` keeps the escape sequences (`-e`)."""
242
+ argv = ['tmux', '-L', socket, 'capture-pane', '-p', '-J', '-S', f'-{lines}' if lines else '-', '-t', f'={session}:']
243
+ return argv + ['-e'] if vt else argv
244
+
245
+
246
+ def tmux_attach_argv(socket, session):
247
+ """attach-session; with ignore-size while the lanes view shows the lane (the lanes view owns the size)."""
248
+ clients = tmux_cmd(socket, 'list-clients', '-t', f'={session}', '-F', '#{client_termname}').stdout.split()
249
+ argv = ['tmux', '-L', socket, 'attach-session']
250
+ if VIEWER_TERM in clients:
251
+ argv += ['-f', 'ignore-size'] # the lanes view is showing this lane: never resize it from here
252
+ return argv + ['-t', f'={session}']
253
+
254
+
255
+ def hook_agent(name):
256
+ """The lane's `lane-signal` sidecar (~/.lz/state/hooks/<name>.json) as an agent dict, or {} — judged as the host's
257
+ `agent_state.read_sidecar_dict` judges it: ours (`v` 1, a vocabulary word), a last event (`at`, else `since`)
258
+ within HOOK_MAX_AGE, and a recorded agent pid still alive in this container (and the same process when the
259
+ writer recorded its `start`). A crashed agent's last word is never an agent."""
260
+ try:
261
+ d = json.loads((HOOKS / f'{name}.json').read_text())
262
+ except (OSError, ValueError):
263
+ return {}
264
+ if not isinstance(d, dict) or d.get('v') != 1 or d.get('status') not in STATUSES:
265
+ return {}
266
+ try:
267
+ since = int(d.get('since') or 0)
268
+ at = int(d.get('at') or since)
269
+ pid = int(d.get('pid') or 0)
270
+ except (TypeError, ValueError):
271
+ return {}
272
+ if since <= 0 or time.time() - at > HOOK_MAX_AGE:
273
+ return {}
274
+ if pid > 0:
275
+ try:
276
+ stat = (PROC / str(pid) / 'stat').read_text()
277
+ except OSError:
278
+ return {}
279
+ if d.get('start') is not None:
280
+ fields = stat.rsplit(')', 1)[-1].split()
281
+ if len(fields) < 20 or fields[19] != str(d['start']):
282
+ return {}
283
+ return {'harness': str(d.get('harness') or ''), 'status': d['status'], 'since': since, 'at': at,
284
+ 'event': str(d.get('event') or ''), 'session': str(d.get('session') or ''), 'pid': pid, 'source': 'hook'}
154
285
 
155
286
 
156
287
  def cmd_open(args):
@@ -159,7 +290,10 @@ def cmd_open(args):
159
290
  entry = registry_entry(args[0])
160
291
  if os.environ.get('TMUX') and '--force' not in args:
161
292
  sys.exit(f"lane open: already inside tmux — refusing to nest. The lane is: {json.dumps(entry.get('tmux') or entry.get('zmx'))}")
162
- env = {k: v for k, v in os.environ.items() if k not in ('TMUX', 'TMUX_PANE')}
293
+ # Nothing `lane open` execs carries a lane identity — neither this lane's nor one inherited from ANOTHER lane (an
294
+ # editor terminal started inside a lane): the recipe may be the first client on a shared board socket and would
295
+ # hand it to every later session there (DEF-1478). The canonical recipe's own `-e` flags give the identity.
296
+ env = identity_free(os.environ, drop=('TMUX', 'TMUX_PANE'))
163
297
  if entry.get('holder') == 'zmx':
164
298
  name = entry['zmx']['name']
165
299
  if alive(name):
@@ -167,11 +301,7 @@ def cmd_open(args):
167
301
  else:
168
302
  socket, session = entry['tmux']['socket'], entry['tmux']['session']
169
303
  if tmux_cmd(socket, 'has-session', '-t', f'={session}').returncode == 0:
170
- clients = tmux_cmd(socket, 'list-clients', '-t', f'={session}', '-F', '#{client_termname}').stdout.split()
171
- argv = ['tmux', '-L', socket, 'attach-session']
172
- if VIEWER_TERM in clients:
173
- argv += ['-f', 'ignore-size'] # the lanes view is showing this lane: never resize it from here
174
- os.execvpe('tmux', argv + ['-t', f'={session}'], env)
304
+ os.execvpe('tmux', tmux_attach_argv(socket, session), env)
175
305
  # The lane does not exist: run the recipe the repository recorded for it (it creates AND attaches).
176
306
  recipe = entry.get('create')
177
307
  folder = entry.get('folder') or str(Path.home())
@@ -188,6 +318,15 @@ def cmd_open(args):
188
318
 
189
319
  def cmd_attach(args):
190
320
  name = lane_id(args[0] if args else '')
321
+ held = tmux_lane('attach', name)
322
+ if held:
323
+ _, socket, session = held
324
+ if os.environ.get('TMUX'):
325
+ sys.exit(f'lane attach: already inside tmux — refusing to nest. The lane is: {socket} {session}')
326
+ if tmux_cmd(socket, 'has-session', '-t', f'={session}').returncode != 0:
327
+ sys.exit(f'lane attach: {name} is not running (never created implicitly; `lane open {name}` runs its recipe)')
328
+ os.execvpe('tmux', tmux_attach_argv(socket, session), identity_free(os.environ, drop=('TMUX', 'TMUX_PANE')))
329
+ name = zmx_name('attach', name)
191
330
  if not alive(name):
192
331
  sys.exit(f'lane attach: {name} is not running (never created implicitly)')
193
332
  # Clean environment on purpose: with ZMX_SESSION set, zmx would SWITCH the calling session instead of attaching.
@@ -195,23 +334,51 @@ def cmd_attach(args):
195
334
 
196
335
 
197
336
  def cmd_send(args):
337
+ enter = '--no-enter' not in args[:2] # an option only before the text: a later `--no-enter` is text
338
+ if not enter:
339
+ args = list(args)
340
+ args.remove('--no-enter')
198
341
  name = lane_id(args[0] if args else '')
199
- result = zmx('send', name, ' '.join(args[1:]))
342
+ text = ' '.join(args[1:])
343
+ held = tmux_lane('send', name)
344
+ if held:
345
+ _, socket, session = held
346
+ result = tmux_run(send_argv(socket, session, text, enter))
347
+ else:
348
+ result = zmx('send', zmx_name('send', name), text) # zmx: the bytes as given, never an Enter of its own
200
349
  sys.stderr.write(result.stderr)
201
350
  sys.exit(result.returncode)
202
351
 
203
352
 
353
+ def last_lines(text, lines):
354
+ """The last `lines` lines that end in something: a tmux capture ends with the screen's unused blank rows."""
355
+ rows = text.splitlines()
356
+ while rows and not rows[-1].strip():
357
+ rows.pop()
358
+ return '\n'.join(rows[-lines:]) + '\n'
359
+
360
+
204
361
  def cmd_capture(args):
205
362
  lines = int(option(args, '--lines', '0') or 0)
206
363
  flags = ['--vt'] if '--vt' in args else []
207
364
  name = lane_id(next((a for a in args if not a.startswith('--')), ''))
208
- text = zmx('history', name, *flags).stdout
365
+ held = tmux_lane('capture', name)
366
+ if held:
367
+ _, socket, session = held
368
+ result = tmux_run(capture_argv(socket, session, lines, vt=bool(flags)))
369
+ if result.returncode:
370
+ sys.stderr.write(result.stderr)
371
+ sys.exit(result.returncode)
372
+ sys.stdout.write(last_lines(result.stdout, lines) if lines else result.stdout)
373
+ return
374
+ text = zmx('history', zmx_name('capture', name), *flags).stdout
209
375
  if lines:
210
376
  text = '\n'.join(text.splitlines()[-lines:]) + '\n'
211
377
  sys.stdout.write(text)
212
378
 
213
379
 
214
380
  def describe():
381
+ """The zmx lanes, one row each — the `lane ls --json` shape Throttle Studio reads as zmx lanes (lane_cli.rs)."""
215
382
  rows = []
216
383
  for name, fields in sorted(sessions().items()):
217
384
  meta = {}
@@ -226,31 +393,87 @@ def describe():
226
393
  pass
227
394
  rows.append({'id': name, 'holder': 'zmx', 'pid': int(fields.get('pid', 0)), 'clients': int(fields.get('clients', 0)),
228
395
  'ws': meta.get('ws', ''), 'title': meta.get('title', name), 'cwd': meta.get('cwd', ''),
229
- 'icon': meta.get('icon', ''), 'color': meta.get('color', ''), 'agent': state})
396
+ 'icon': meta.get('icon', ''), 'color': meta.get('color', ''), 'agent': state, 'alive': True})
397
+ return rows
398
+
399
+
400
+ def tmux_row(lane, entry, socket, session, alive_now):
401
+ """A tmux lane: registry facts, `alive` from its server, `agent` from its hook sidecar ({} without one)."""
402
+ return {'id': lane, 'holder': 'tmux', 'ws': entry.get('ws') or '', 'title': entry.get('name') or lane,
403
+ 'tmux': {'socket': socket, 'session': session}, 'alive': alive_now, 'agent': hook_agent(lane)}
404
+
405
+
406
+ def tmux_rows():
407
+ """Every registry tmux lane, `alive` from ONE list-sessions per socket (a has-session per lane would be one tmux
408
+ client per lane; a socket whose server is not running lists nothing, so its lanes are not alive)."""
409
+ rows, listed = [], {}
410
+ for lane, entry in sorted(registry_lanes('ls').items()):
411
+ target = tmux_target(entry)
412
+ if not target or not IDENT.match(lane):
413
+ continue
414
+ socket, session = target
415
+ if socket not in listed:
416
+ result = tmux_cmd(socket, 'list-sessions', '-F', '#{session_name}')
417
+ listed[socket] = set(result.stdout.splitlines()) if result.returncode == 0 else set()
418
+ rows.append(tmux_row(lane, entry, socket, session, session in listed[socket]))
230
419
  return rows
231
420
 
232
421
 
233
422
  def cmd_ls(args):
234
- rows = describe()
423
+ # The default listing stays zmx-only: Throttle Studio reads EVERY `lane ls --json` row as a zmx lane, so tmux rows
424
+ # there would publish each registry tmux lane a second time. --all is the opt-in for people and scripts.
425
+ if '--all' in args:
426
+ try:
427
+ rows = describe()
428
+ except OSError: # no zmx binary in this root: no zmx lane can run here; the tmux lanes still list
429
+ rows = []
430
+ rows += tmux_rows()
431
+ else:
432
+ rows = describe() # unchanged: a missing zmx stays an error for the listing Studio reads
235
433
  if '--json' in args:
236
434
  print(json.dumps(rows, ensure_ascii=False, indent=1))
237
435
  return
238
436
  for row in rows:
437
+ if row['holder'] == 'tmux':
438
+ agent = row['agent'].get('status', '-')
439
+ print(f"{row['id']:<40} {row['ws']:<18} {'alive' if row['alive'] else 'dead':<9} {agent:<8} {row['title']}")
440
+ continue
239
441
  agent = row['agent'].get('phase', '?')
240
442
  print(f"{row['id']:<40} {row['ws']:<18} clients={row['clients']} {agent:<8} {row['title']}")
241
443
 
242
444
 
243
445
  def cmd_status(args):
244
446
  name = lane_id(args[0] if args else '')
245
- row = next((r for r in describe() if r['id'] == name), None)
447
+ held = tmux_lane('status', name)
448
+ if held:
449
+ entry, socket, session = held
450
+ alive_now = tmux_cmd(socket, 'has-session', '-t', f'={session}').returncode == 0
451
+ print(json.dumps(tmux_row(name, entry, socket, session, alive_now), ensure_ascii=False, indent=1))
452
+ sys.exit(0 if alive_now else 1)
453
+ zname = zmx_name('status', name)
454
+ row = next((r for r in describe() if r['id'] == zname), None)
455
+ if row:
456
+ row['hook'] = hook_agent(zname) # lane-run's phase record stays `agent` (DEF-1436)
246
457
  print(json.dumps(row or {'id': name, 'running': False}, ensure_ascii=False, indent=1))
247
458
  sys.exit(0 if row else 1)
248
459
 
249
460
 
250
461
  def cmd_kill(args):
251
- name = lane_id(args[0] if args else '')
252
- result = zmx('kill', name)
253
- for path in (META / f'{name}.json', STATE / f'{name}.json'):
462
+ name = lane_id(next((a for a in args if not a.startswith('--')), ''))
463
+ if '--yes' not in args:
464
+ sys.exit(f'lane kill: this ends the agent in {name} — confirm with --yes')
465
+ held = tmux_lane('kill', name)
466
+ target = name if held else zmx_name('kill', name)
467
+ if hook_agent(target).get('status') == 'working' and '--force' not in args:
468
+ sys.exit(f'lane kill: agent is working in {name} (its lane-signal hook says so); use --force')
469
+ if held:
470
+ _, socket, session = held
471
+ result = tmux_cmd(socket, 'kill-session', '-t', f'={session}')
472
+ stale = (HOOKS / f'{name}.json',) if result.returncode == 0 else ()
473
+ else:
474
+ result = zmx('kill', target)
475
+ stale = (META / f'{target}.json', STATE / f'{target}.json', HOOKS / f'{target}.json')
476
+ for path in stale:
254
477
  try:
255
478
  path.unlink()
256
479
  except OSError:
@@ -259,8 +482,86 @@ def cmd_kill(args):
259
482
  sys.exit(result.returncode)
260
483
 
261
484
 
485
+ GC_USAGE = 'usage: lane gc --sockets (unlink the tmux socket files in this container that no server listens on)'
486
+ GC_PROBE_TIMEOUT = 5
487
+
488
+
489
+ def tmux_socket_dir():
490
+ """Where this container's tmux servers put their sockets: $TMUX_TMPDIR (else /tmp) / tmux-<uid>, as tmux does."""
491
+ return Path(os.environ.get('TMUX_TMPDIR') or '/tmp') / f'tmux-{os.getuid()}'
492
+
493
+
494
+ def socket_refused(path):
495
+ """True only when connecting to the AF_UNIX socket file `path` fails with ECONNREFUSED (the file is there, nobody
496
+ listens: its server is gone). Anything else — a server that accepts, EACCES, ENOENT (gone meanwhile), a timeout,
497
+ a path too long for sun_path — is False: keep the file."""
498
+ probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
499
+ probe.settimeout(GC_PROBE_TIMEOUT)
500
+ try:
501
+ probe.connect(str(path))
502
+ except ConnectionRefusedError:
503
+ return True
504
+ except OSError:
505
+ return False
506
+ finally:
507
+ probe.close()
508
+ return False
509
+
510
+
511
+ def cmd_gc(args):
512
+ """`lane gc --sockets` (plan Task 18), explicit and in the container only — the host merely reports (`lanes
513
+ doctor`; the RUNBOOK NEVER rule). For each SOCKET file in tmux's directory one `tmux -N -S <path> list-sessions`
514
+ (-N: never start a server; no lane identity in its env): only a file tmux is REFUSED on (ECONNREFUSED — nobody
515
+ listens, its server is gone) is unlinked, and only if it is still the very file that was probed (a new server may
516
+ have taken the path meanwhile). A server that answers — even with no session — or that does not answer in time
517
+ keeps its file: a busy server is not a dead one. Prints each unlinked path.
518
+
519
+ Refusal is read from the errno, never from tmux's prose: tmux 3.4 words ECONNREFUSED as `no server running on
520
+ <path>` and uses `error connecting to <path> (<strerror>)` only for OTHER errnos (measured 2026-10-04: EACCES →
521
+ `(Permission denied)`, ENOENT → `(No such file or directory)`), so a 'connection refused' text match never fired
522
+ (DEF-1459). When tmux fails, `socket_refused` connects to the same path once and only ConnectionRefusedError
523
+ counts."""
524
+ if args != ['--sockets']:
525
+ sys.exit(GC_USAGE)
526
+ directory = tmux_socket_dir()
527
+ try:
528
+ entries = sorted(directory.iterdir())
529
+ except OSError as error:
530
+ print(f'lane gc --sockets: {directory}: {error.strerror or error} — nothing to do')
531
+ return
532
+ removed = kept = 0
533
+ for path in entries:
534
+ try:
535
+ before = path.lstat()
536
+ except OSError:
537
+ continue
538
+ if not stat.S_ISSOCK(before.st_mode):
539
+ continue
540
+ try:
541
+ result = subprocess.run(['tmux', '-N', '-S', str(path), 'list-sessions'], capture_output=True, text=True,
542
+ timeout=GC_PROBE_TIMEOUT, env=identity_free(os.environ))
543
+ except subprocess.TimeoutExpired:
544
+ print(f'kept {path}: no answer in {GC_PROBE_TIMEOUT} s')
545
+ kept += 1
546
+ continue
547
+ if result.returncode == 0 or not socket_refused(path):
548
+ kept += 1
549
+ continue
550
+ try:
551
+ after = path.lstat()
552
+ except OSError:
553
+ continue # gone meanwhile: nothing to remove
554
+ if (after.st_dev, after.st_ino, after.st_ctime_ns) != (before.st_dev, before.st_ino, before.st_ctime_ns):
555
+ kept += 1 # another server bound the path after the probe: not the file tmux was refused on
556
+ continue
557
+ path.unlink()
558
+ print(f'unlinked {path}')
559
+ removed += 1
560
+ print(f'lane gc --sockets: {removed} unlinked, {kept} kept in {directory}')
561
+
562
+
262
563
  COMMANDS = {'new': cmd_new, 'open': cmd_open, 'attach': cmd_attach, 'send': cmd_send, 'capture': cmd_capture, 'ls': cmd_ls,
263
- 'status': cmd_status, 'kill': cmd_kill}
564
+ 'status': cmd_status, 'kill': cmd_kill, 'gc': cmd_gc}
264
565
 
265
566
  if __name__ == '__main__':
266
567
  if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
@@ -0,0 +1,173 @@
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`).
5
+
6
+ The sidecar has its own directory because `lane-run`, the root process of every zmx-held lane, writes its
7
+ `{phase, exit, at, pid}` record to ~/.lz/state/<zmx name>.json and exports LANE_ID=<zmx name> to the agent: a
8
+ sidecar at state/<lane id>.json would overwrite that record for the agent's whole life (DEF-1436). Nothing but
9
+ this script writes state/hooks/, and lane-run never writes there.
10
+
11
+ {"v":1, "lane":"<id>", "harness":"claude|codex", "status":"<agent{} vocabulary>", "since":<epoch the status
12
+ began>, "at":<epoch of this event>, "event":"<hook event>", "pid":<agent pid>, "start":"<its starttime>",
13
+ "session":"<session/thread id or ''>"}
14
+
15
+ It never fails the agent and never speaks: every path exits 0 and nothing reaches stdout or stderr (a Claude Code
16
+ UserPromptSubmit/SessionStart hook's stdout becomes model context). An unresolvable lane, a status outside the
17
+ vocabulary or an unwritable state directory are silent no-ops.
18
+
19
+ Lane id: $LANE_ID — exported in a tmux lane only by a canonical/`--identity` recipe's `-e` flags (Task 7, DEF-1478;
20
+ `lane open` never exports it), and in a zmx-held lane by `lane-run` as the zmx session name (which is NOT the
21
+ `<ws>--z-<name>` id lanes-statusd publishes for it, so statusd does not join that sidecar to the zmx lane yet:
22
+ DEF-1437) — 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
+
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
+ 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
+ """
33
+ import json
34
+ import os
35
+ import re
36
+ import select
37
+ import subprocess
38
+ import sys
39
+ import time
40
+
41
+ STATUSES = ('working', 'needs_input', 'needs_approval', 'done', 'errored', 'shell', 'idle') # agent_state.STATUSES
42
+ LANE_ID = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,127}')
43
+ SHELLS = {'sh', 'dash', 'bash', 'zsh', 'ksh', 'fish'}
44
+ STDIN_WAIT = 0.5
45
+ STDIN_MAX = 1 << 20
46
+
47
+
48
+ def options(argv):
49
+ status, event, harness, payload = (argv[0] if argv else ''), '', '', ''
50
+ rest = argv[1:]
51
+ while rest:
52
+ word = rest.pop(0)
53
+ if word == '--event' and rest:
54
+ event = rest.pop(0)
55
+ elif word == '--harness' and rest:
56
+ harness = rest.pop(0)
57
+ elif word.startswith('{'):
58
+ payload = word
59
+ return status, event[:64], harness[:16], payload
60
+
61
+
62
+ def lane_id(root):
63
+ found = os.environ.get('LANE_ID', '')
64
+ if not found and os.environ.get('TMUX') and os.environ.get('TMUX_PANE'):
65
+ sock = os.environ['TMUX'].split(',', 1)[0]
66
+ try:
67
+ done = subprocess.run(['tmux', '-S', sock, 'display', '-p', '-t', os.environ['TMUX_PANE'], '#S'],
68
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=1)
69
+ session = done.stdout.strip() if done.returncode == 0 else ''
70
+ lanes = json.load(open(os.path.join(root, 'registry.json'))).get('lanes') or {}
71
+ except (OSError, ValueError, subprocess.SubprocessError, AttributeError):
72
+ return ''
73
+ name = os.path.basename(sock)
74
+ for key, lane in lanes.items():
75
+ tmux = (lane or {}).get('tmux') or {}
76
+ if session and tmux.get('socket') == name and tmux.get('session') == session:
77
+ found = key
78
+ break
79
+ return found if LANE_ID.fullmatch(found or '') else ''
80
+
81
+
82
+ def stat_fields(pid):
83
+ """Fields after `comm` of /proc/<pid>/stat (state is [0], ppid [1], starttime [19]) and the comm itself."""
84
+ with open(f'/proc/{pid}/stat') as handle:
85
+ text = handle.read()
86
+ comm = text[text.index('(') + 1:text.rindex(')')]
87
+ return comm, text.rsplit(')', 1)[1].split()
88
+
89
+
90
+ def agent():
91
+ pid = os.getppid()
92
+ try:
93
+ comm, fields = stat_fields(pid)
94
+ if comm in SHELLS and int(fields[1]) > 1:
95
+ pid = int(fields[1])
96
+ comm, fields = stat_fields(pid)
97
+ return pid, fields[19]
98
+ except (OSError, ValueError, IndexError):
99
+ return pid, None
100
+
101
+
102
+ def stdin_session():
103
+ try:
104
+ if os.isatty(0):
105
+ return ''
106
+ except OSError:
107
+ return ''
108
+ data, deadline = b'', time.monotonic() + STDIN_WAIT
109
+ try:
110
+ while len(data) < STDIN_MAX:
111
+ left = deadline - time.monotonic()
112
+ if left <= 0 or not select.select([0], [], [], left)[0]:
113
+ break
114
+ chunk = os.read(0, 65536)
115
+ if not chunk:
116
+ break
117
+ data += chunk
118
+ doc = json.loads(data.decode('utf-8', 'replace')) if data.strip() else {}
119
+ except (OSError, ValueError):
120
+ return ''
121
+ return str(doc.get('session_id') or '') if isinstance(doc, dict) else ''
122
+
123
+
124
+ def main(argv):
125
+ status, event, harness, payload = options(argv)
126
+ if status not in STATUSES:
127
+ return
128
+ root = os.environ.get('LANE_ROOT') or os.path.join(os.path.expanduser('~'), '.lz')
129
+ lane = lane_id(root)
130
+ if not lane:
131
+ return
132
+ session = stdin_session()
133
+ if not session and payload:
134
+ try:
135
+ doc = json.loads(payload)
136
+ session = str(doc.get('thread-id') or doc.get('session_id') or '') if isinstance(doc, dict) else ''
137
+ except ValueError:
138
+ session = ''
139
+ session = session or os.environ.get('CLAUDE_SESSION_ID') or os.environ.get('CODEX_THREAD_ID') or ''
140
+ harness = harness or os.environ.get('LANE_HARNESS') or ('claude' if os.environ.get('CLAUDE_CODE_ENTRYPOINT')
141
+ else 'codex')
142
+ pid, start = agent()
143
+ now = int(time.time())
144
+ state = os.path.join(root, 'state', 'hooks') # never state/<id>.json: lane-run's phase record (DEF-1436)
145
+ path = os.path.join(state, f'{lane}.json')
146
+ try:
147
+ old = json.load(open(path))
148
+ except (OSError, ValueError):
149
+ 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}
156
+ os.makedirs(state, exist_ok=True)
157
+ tmp = os.path.join(state, f'.{lane}.json.{os.getpid()}.tmp')
158
+ try:
159
+ with open(tmp, 'w') as handle:
160
+ handle.write(json.dumps(doc, separators=(',', ':')) + '\n')
161
+ os.replace(tmp, path)
162
+ finally:
163
+ if os.path.exists(tmp):
164
+ os.unlink(tmp)
165
+
166
+
167
+ if __name__ == '__main__':
168
+ try:
169
+ sys.stdout = sys.stderr = open(os.devnull, 'w')
170
+ main(sys.argv[1:])
171
+ except BaseException: # noqa: BLE001 - a hook must never fail the agent: every outcome is exit 0
172
+ pass
173
+ os._exit(0)