@vibelord/lanes 1.0.1 → 1.0.3
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/package.json +1 -1
- package/payload/lanes/README.md +37 -7
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +664 -50
- package/payload/lanes/client/ghostty/common.conf +3 -0
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
- package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
- package/payload/lanes/client/ghostty/omarchy.conf +3 -2
- package/payload/lanes/client/lanes-launch +18 -3
- package/payload/lanes/client/lanes-window +17 -1
- package/payload/lanes/container/env.sh +1 -1
- package/payload/lanes/container/hooks/claude-settings.json +10 -0
- package/payload/lanes/container/hooks/codex-hooks.json +22 -0
- package/payload/lanes/container/lane +310 -26
- package/payload/lanes/container/lane-signal +173 -0
- package/payload/lanes/docs/ARCHITECTURE.md +87 -13
- package/payload/lanes/docs/DESIGN.md +182 -4
- package/payload/lanes/docs/INSTALL.md +37 -2
- package/payload/lanes/docs/RUNBOOK.md +458 -39
- package/payload/lanes/docs/SHARING.md +240 -5
- package/payload/lanes/holders/exec.sh +2 -5
- package/payload/lanes/holders.lock +13 -0
- package/payload/lanes/install/enable-studio-host.sh +14 -0
- package/payload/lanes/install/host-install.sh +15 -3
- package/payload/lanes/install/services.py +128 -4
- package/payload/lanes/install.sh +108 -3
- package/payload/lanes/libexec/agent_state.py +32 -0
- package/payload/lanes/libexec/cursor_sync.py +10 -2
- package/payload/lanes/libexec/economy_poll.py +685 -0
- package/payload/lanes/libexec/guard.py +27 -1
- package/payload/lanes/libexec/lanes_lib.py +285 -38
- package/payload/lanes/libexec/notify.py +317 -0
- package/payload/lanes/libexec/registrations.py +1 -1
- package/payload/lanes/libexec/session_actions.py +7 -8
- package/payload/lanes/libexec/sidebar.py +911 -218
- package/payload/lanes/libexec/statusd.py +258 -74
- package/payload/lanes/libexec/studio_host.py +1608 -0
- package/payload/lanes/libexec/tokens.py +38 -7
- package/payload/lanes/libexec/view.sh +13 -3
- package/payload/lanes/systemd/lanes-economy.service +20 -0
- package/payload/lanes/systemd/lanes-notify.service +19 -0
- package/payload/lanes/systemd/lanes-studio-host-enrol.service +11 -0
- package/payload/lanes/systemd/lanes-studio-host-enrol.timer +10 -0
- package/payload/lanes/systemd/lanes-studio-host.service +37 -0
- package/payload/lanes/systemd/lanes-sync.service +3 -0
- package/payload/lanes/web/README.md +83 -4
- package/payload/lanes/web/index.html +2 -1
- package/payload/lanes/web/mobile.css +53 -198
- package/payload/lanes/web/mobile.js +87 -55
- package/payload/lanes/zellij/config.kdl +67 -16
- package/payload/manifest.json +5 -3
- package/payload/lanes/libexec/legacy_sessions.py +0 -130
package/payload/lanes/bin/lanes
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
lanes resolve <lane-id> exact target of one lane as JSON (contract for the web harness)
|
|
7
7
|
lanes status one-line-per-workspace health summary
|
|
8
8
|
lanes attach <device> [--client ID] [--mobile] [--viewports N]
|
|
9
|
-
|
|
9
|
+
[--ascii] [--glyphs unicode|ascii|emoji] [--theme dark|light|auto] [--color truecolor|16|none]
|
|
10
|
+
attach this terminal to the device's view session (creates it if needed); the look
|
|
11
|
+
flags are remembered in devices/<device>.json and reach its running sidebars in a tick
|
|
10
12
|
lanes prepare <device> [--mobile|--desktop]
|
|
11
13
|
create a device view without attaching a terminal (eg. Safari on a phone); an explicit
|
|
12
14
|
layout that differs from the running view is refused, never rebuilt
|
|
@@ -34,15 +36,19 @@
|
|
|
34
36
|
attach-only, missing script) with the canonical one whose `tmux new -e` flags give the
|
|
35
37
|
lane LANE_ID/LANE_WS and the lanes/<ws>/<id> trace; --identity adds those flags to a
|
|
36
38
|
working plain `tmux -L S new -ADs N [-c D] CMD` recipe, keeping CMD. The old recipe is
|
|
37
|
-
kept as createPrevious. Only these -e flags give a lane its identity (DEF-
|
|
39
|
+
kept as createPrevious. Only these -e flags give a lane its identity (DEF-1559)
|
|
38
40
|
lanes cursor-commit [ws…] [--push]
|
|
39
41
|
commit the normalized terminals.json on top of each repo's REMOTE branch tip and push it,
|
|
40
42
|
with git plumbing only (no working tree or index is touched). Dry-run without --push.
|
|
41
43
|
lanes hello [device] what the client installer calls to prove the path, the key and the server version
|
|
42
44
|
lanes client-config site facts for clients (KEY=VALUE from ~/.config/lanes/site.conf on the server)
|
|
43
|
-
lanes install-container <name> install the `lane` contract + pinned zmx into a
|
|
45
|
+
lanes install-container <name> install the `lane` contract (lane, lane-run, lane-signal, hooks/) + pinned zmx into a
|
|
46
|
+
container's home volume (additive; installs no agent hook)
|
|
47
|
+
lanes hooks install --agent claude|codex (--container <name> | --home <dir>) [--dry-run | --write]
|
|
48
|
+
merge the lane-signal hooks into that home's ~/.claude/settings.json or ~/.codex/hooks.json
|
|
49
|
+
(+ a top-level Codex notify when none is set); prints the diff, writes only with --write
|
|
50
|
+
(backup kept). Opt-in, per container; Codex still asks you to trust new hooks
|
|
44
51
|
lanes invariant save|check snapshot / verify every live pane's pid and window size
|
|
45
|
-
lanes verify-legacy compare the registry with the old in-container coder-launch layouts
|
|
46
52
|
lanes gc truncate oversized zmx logs, drop stale dynamic resolve files
|
|
47
53
|
lanes sockets [check|repair] lanes that run but cannot be reached: tmux servers whose socket file was deleted
|
|
48
54
|
(someone emptied the container's /tmp); repair = tmux's SIGUSR1, nothing restarts
|
|
@@ -55,7 +61,41 @@
|
|
|
55
61
|
one pass, by hand, that makes the next in-container out-of-memory kill take a build, never
|
|
56
62
|
a lane: raises oom_score_adj of compilers / test runners by exact name (libexec/guard.py).
|
|
57
63
|
No timer runs it: look at --dry-run first. The recurring half is sudo/host-guard.sh
|
|
58
|
-
lanes
|
|
64
|
+
lanes economy --once [--dry-run] [--status-file F --sessions-file F]
|
|
65
|
+
one economy poll (libexec/economy_poll.py): the Throttle daemon's loopback /status and
|
|
66
|
+
/sessions plus today's proxy.log fold, printed as the economy.json v1 projection
|
|
67
|
+
(--dry-run writes neither economy.json nor its cursor). LANES_THROTTLE_CONTROL=none
|
|
68
|
+
contacts no daemon; the --*-file options read fixture documents instead; stderr names
|
|
69
|
+
each source it could not read. LANES_THROTTLE_CONTAINER=NAME reads that container's
|
|
70
|
+
log through its home volume. The recurring poller is lanes-economy.service, which no
|
|
71
|
+
install enables (operator gate, RUNBOOK)
|
|
72
|
+
lanes next|prev --focused [--device D]
|
|
73
|
+
what Ctrl-Alt-Down / Ctrl-Alt-Up run in a floating pane that closes on exit: point the
|
|
74
|
+
focused viewport (the first one when the sidebar has the focus) at the next / previous
|
|
75
|
+
lane of its board in registry order, wrapping, and wake its view.sh (SIGUSR1) — the
|
|
76
|
+
same write as Enter in the sidebar. Reads two zellij listings; moves no focus
|
|
77
|
+
lanes preview --focused [--device D]
|
|
78
|
+
what Ctrl-Alt-m runs in a floating pane: the markdown files the focused lane named in
|
|
79
|
+
its last 300 lines (guarded tmux capture, resolved against its cwd, existing ones only),
|
|
80
|
+
newest first; one is shown at once, several give a numbered menu. glow (pinned in
|
|
81
|
+
holders.lock) in the device's dark/light theme, else less -R
|
|
82
|
+
lanes studio-host status [--json] [--check]
|
|
83
|
+
the co-located Throttle Studio Host (opt-in: install/enable-studio-host.sh): binary
|
|
84
|
+
digest, config mode, enrolment, units. Exit 0 only when it runs enrolled
|
|
85
|
+
lanes studio-host install [--from PATH [--sha256 HEX]]
|
|
86
|
+
select the Host binary: the holders.lock opt-in pin, or a locally built file (both
|
|
87
|
+
sha256-verified); restarts nothing
|
|
88
|
+
lanes studio-host re-key [--yes] [--dry-run]
|
|
89
|
+
after the owner revoked this machine: stop the Host, remove the machine key and its
|
|
90
|
+
dead enrolment, publish a NEW public key for the next deploy. Nothing without --yes
|
|
91
|
+
lanes doctor full health check (every external call is bounded; includes the pressure summary), with
|
|
92
|
+
the lanes per create-recipe class (idempotent / attach-only / null / external /
|
|
93
|
+
retired-path), lane identity, numeric session names, crowded `default` sockets and
|
|
94
|
+
orphan tmux socket files (report only; in a container `lane gc --sockets` removes them),
|
|
95
|
+
and any record left in the retired legacy-sessions/ directory
|
|
96
|
+
lanes device list | forget <name>
|
|
97
|
+
the devices `lanes ensure` recreates view sessions for; forget removes devices/<name>.json
|
|
98
|
+
(a running view session is left alone and is not brought back once it ends)
|
|
59
99
|
"""
|
|
60
100
|
import json
|
|
61
101
|
import os
|
|
@@ -225,11 +265,35 @@ def mobile_workspace_tab(device, workspace, picker):
|
|
|
225
265
|
title = workspace['title'] + (' ☰' if picker else '')
|
|
226
266
|
command = str(L.REPO / 'libexec' / ('sidebar' if picker else 'view.sh'))
|
|
227
267
|
args = f'{q(device)} {q(workspace["id"])}' + ('' if picker else ' "main" "--mobile"')
|
|
268
|
+
# The picker's sidebar pane has NO layout name: Zellij reports a non-empty pane name instead of the pane's own
|
|
269
|
+
# OSC 2 title, and that title (the attention counts) is what the phone reads (DEF-1531).
|
|
270
|
+
name = '' if picker else f' name={q(workspace["title"])}'
|
|
228
271
|
return [f' tab name={q(title)} {{',
|
|
229
|
-
f' pane borderless=true
|
|
272
|
+
f' pane borderless=true{name} command={q(command)} {{',
|
|
230
273
|
f' args {args}', ' }', ' }']
|
|
231
274
|
|
|
232
275
|
|
|
276
|
+
def stale_picker_layouts():
|
|
277
|
+
"""Phone devices whose stored layout still names the picker's sidebar pane (the pre-Task 26 `name="Lanes"`).
|
|
278
|
+
|
|
279
|
+
`upgrade-session` / `ensure` only add missing tabs and rename titles: a picker tab created from an older layout
|
|
280
|
+
keeps its pane name, which Zellij reports instead of the OSC 2 counts, until `lanes rebuild <device>` (DEF-1531).
|
|
281
|
+
`create_session` is the only writer of layouts/<device>.kdl, so the file is the layout the session was built from.
|
|
282
|
+
"""
|
|
283
|
+
sidebar = str(L.REPO / 'libexec' / 'sidebar')
|
|
284
|
+
stale = []
|
|
285
|
+
for meta_file in sorted((L.STATE / 'devices').glob('*.json')):
|
|
286
|
+
if not (L.read_json(meta_file) or {}).get('mobile'):
|
|
287
|
+
continue
|
|
288
|
+
try:
|
|
289
|
+
text = (L.STATE / 'layouts' / f'{meta_file.stem}.kdl').read_text()
|
|
290
|
+
except OSError:
|
|
291
|
+
continue
|
|
292
|
+
if any('name="Lanes"' in line and f'command={q(sidebar)}' in line for line in text.splitlines()):
|
|
293
|
+
stale.append(meta_file.stem)
|
|
294
|
+
return stale
|
|
295
|
+
|
|
296
|
+
|
|
233
297
|
def workspace_tab_kdl(device, workspace, mobile=False, viewports=1, focused=False):
|
|
234
298
|
sidebar, view = str(L.REPO / 'libexec/sidebar'), str(L.REPO / 'libexec/view.sh')
|
|
235
299
|
focus = ' focus=true' if focused else ''
|
|
@@ -325,9 +389,16 @@ def create_session(device, mobile, viewports=1):
|
|
|
325
389
|
L.atomic_write(layout_file, layout_kdl(device, mobile, viewports))
|
|
326
390
|
meta_file = L.STATE / 'devices' / f'{device}.json'
|
|
327
391
|
meta = {'version': 1, 'mobile': mobile, 'viewports': viewports, 'created_at': time.time()}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
392
|
+
old = L.read_json(meta_file) if meta_file.exists() else None
|
|
393
|
+
old = old if isinstance(old, dict) else {}
|
|
394
|
+
if old.get('base'): # learnt at attach (see base_device); `ensure` and `rebuild` must not forget it
|
|
395
|
+
meta['base'] = old['base']
|
|
396
|
+
# The look `lanes attach --ascii|--glyphs|--theme|--color` recorded (record_look) is the operator's choice, not
|
|
397
|
+
# session state: a recreation (ensure after a reboot, rebuild) keeps every valid one, or every sidebar of the
|
|
398
|
+
# device would flip back to the environment defaults within one tick of this rewrite.
|
|
399
|
+
for key, allowed in LOOK_KEYS.values():
|
|
400
|
+
if old.get(key) in allowed:
|
|
401
|
+
meta[key] = old[key]
|
|
331
402
|
L.atomic_write(meta_file, json.dumps(meta) + '\n')
|
|
332
403
|
command = ZELLIJ_BASE + ['--layout', str(layout_file), 'attach', '--create-background', device]
|
|
333
404
|
if shutil.which('systemd-run'):
|
|
@@ -358,6 +429,39 @@ def viewports_arg(args, default=1):
|
|
|
358
429
|
return default
|
|
359
430
|
|
|
360
431
|
|
|
432
|
+
LOOK_KEYS = {'--theme': ('theme', ('dark', 'light', 'auto')), '--color': ('color', ('truecolor', '16', 'none')),
|
|
433
|
+
'--glyphs': ('glyphs', ('unicode', 'ascii', 'emoji'))}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def look_args(args):
|
|
437
|
+
"""The look `lanes attach` was asked for: {'glyphs'|'theme'|'color': value}, validated (spec §2.4)."""
|
|
438
|
+
look = {}
|
|
439
|
+
if '--ascii' in args:
|
|
440
|
+
look['glyphs'] = 'ascii'
|
|
441
|
+
for flag, (key, allowed) in LOOK_KEYS.items():
|
|
442
|
+
if flag in args:
|
|
443
|
+
value = args[args.index(flag) + 1] if flag in args[:-1] else ''
|
|
444
|
+
if value not in allowed:
|
|
445
|
+
raise L.LanesError(f'{flag} must be one of {", ".join(allowed)}')
|
|
446
|
+
look[key] = value
|
|
447
|
+
return look
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def record_look(device, args):
|
|
451
|
+
"""`lanes attach DEVICE --ascii|--glyphs M|--theme T|--color C`: remembered in devices/<device>.json, the file every
|
|
452
|
+
sidebar of that device re-reads when it changes (its reload() stamp), so a RUNNING session follows within one tick.
|
|
453
|
+
Returns what was recorded: empty when nothing was asked — or when the device has no file yet, because `ensure`
|
|
454
|
+
builds a session for every devices/*.json and a look flag must never create that stub on its own."""
|
|
455
|
+
look = look_args(args)
|
|
456
|
+
meta_file = L.STATE / 'devices' / f'{device}.json'
|
|
457
|
+
meta = L.read_json(meta_file) if meta_file.exists() else None
|
|
458
|
+
if not look or not isinstance(meta, dict):
|
|
459
|
+
return {}
|
|
460
|
+
if any(meta.get(key) != value for key, value in look.items()):
|
|
461
|
+
L.atomic_write(meta_file, json.dumps(dict(meta, **look)) + '\n')
|
|
462
|
+
return look
|
|
463
|
+
|
|
464
|
+
|
|
361
465
|
def cmd_layout(args):
|
|
362
466
|
print(layout_kdl(device_name(args[0]), '--mobile' in args, viewports_arg(args)), end='')
|
|
363
467
|
return 0
|
|
@@ -365,6 +469,7 @@ def cmd_layout(args):
|
|
|
365
469
|
|
|
366
470
|
def cmd_attach(args):
|
|
367
471
|
device = device_name(args[0])
|
|
472
|
+
look_args(args) # a bad --theme/--color/--glyphs refuses here, before any session is created
|
|
368
473
|
# Old muscle memory: `coder-launch worlds`. A workspace id is not a device: open the caller's normal
|
|
369
474
|
# device session and bring that workspace's tab to the front.
|
|
370
475
|
tab = None
|
|
@@ -405,6 +510,7 @@ def cmd_attach(args):
|
|
|
405
510
|
meta = L.read_json(meta_file) or {}
|
|
406
511
|
if default and meta and meta.get('base') != base_device(device, default):
|
|
407
512
|
L.atomic_write(meta_file, json.dumps(dict(meta, base=base_device(device, default))) + '\n')
|
|
513
|
+
record_look(device, args)
|
|
408
514
|
# Views idle until a real client (with a real size) has attached once. We exec into the zellij
|
|
409
515
|
# client below, so a detached helper raises the flag shortly after the client has sized the session.
|
|
410
516
|
ready = L.STATE / 'clients' / f'{device}.ready'
|
|
@@ -485,8 +591,13 @@ def cmd_install_container(args):
|
|
|
485
591
|
root = Path(info['home']) / '.lz'
|
|
486
592
|
for sub in ('bin', 's', 'meta', 'state'):
|
|
487
593
|
(root / sub).mkdir(parents=True, exist_ok=True)
|
|
488
|
-
for source in (L.REPO / 'container/lane', L.REPO / 'container/lane-run'):
|
|
594
|
+
for source in (L.REPO / 'container/lane', L.REPO / 'container/lane-run', L.REPO / 'container/lane-signal'):
|
|
489
595
|
shutil.copy2(source, root / 'bin' / source.name)
|
|
596
|
+
# The hook DEFINITIONS travel with the contract for reference; no hook is installed here — that is the opt-in,
|
|
597
|
+
# per-container `lanes hooks install --agent claude|codex --container <name> --write` (RUNBOOK).
|
|
598
|
+
(root / 'hooks').mkdir(exist_ok=True)
|
|
599
|
+
for source in sorted((L.REPO / 'container/hooks').glob('*.json')):
|
|
600
|
+
shutil.copy2(source, root / 'hooks' / source.name)
|
|
490
601
|
env_file = L.REPO / 'container/env.sh' # TMPDIR off the RAM-backed /tmp; only NEW shells and agents read it
|
|
491
602
|
if env_file.exists():
|
|
492
603
|
shutil.copy2(env_file, root / 'env.sh')
|
|
@@ -500,8 +611,139 @@ def cmd_install_container(args):
|
|
|
500
611
|
if link.is_symlink() or link.exists():
|
|
501
612
|
link.unlink()
|
|
502
613
|
link.symlink_to(root)
|
|
503
|
-
print(f'installed {root}/bin/{{lane,lane-run,zmx-*}}
|
|
504
|
-
+ f'; host link {link} -> {root}')
|
|
614
|
+
print(f'installed {root}/bin/{{lane,lane-run,lane-signal,zmx-*}}, {root}/hooks/'
|
|
615
|
+
+ (f' and {root}/env.sh' if env_file.exists() else '') + f'; host link {link} -> {root}')
|
|
616
|
+
return 0
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
HOOK_TARGETS = {'claude': ('claude-settings.json', '.claude/settings.json'),
|
|
620
|
+
'codex': ('codex-hooks.json', '.codex/hooks.json')}
|
|
621
|
+
HOOKS_USAGE = ('usage: lanes hooks install --agent claude|codex (--container <name> | --home <dir>) '
|
|
622
|
+
'[--dry-run | --write]')
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def render_hooks(agent):
|
|
626
|
+
"""The shipped hook definitions with `~/` spelled as the container home: the command reaches lane-signal whether
|
|
627
|
+
or not the harness runs it through a shell that expands `~`."""
|
|
628
|
+
doc = json.loads((L.REPO / 'container/hooks' / HOOK_TARGETS[agent][0]).read_text())
|
|
629
|
+
for groups in doc['hooks'].values():
|
|
630
|
+
for group in groups:
|
|
631
|
+
for hook in group['hooks']:
|
|
632
|
+
hook['command'] = hook['command'].replace('~/', L.CONTAINER_HOME + '/', 1)
|
|
633
|
+
return doc
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def is_lane_signal_group(group):
|
|
637
|
+
hooks = group.get('hooks') if isinstance(group, dict) else None
|
|
638
|
+
return bool(hooks) and all(isinstance(h, dict) and 'lane-signal' in str(h.get('command') or '') for h in hooks)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def merge_hooks(existing, ours):
|
|
642
|
+
"""`existing` (a settings.json / hooks.json document) with every lane-signal group from an earlier install removed
|
|
643
|
+
and `ours` appended per event. Every other key, event and group stays exactly where it was."""
|
|
644
|
+
merged = dict(existing)
|
|
645
|
+
hooks = existing.get('hooks', {})
|
|
646
|
+
if not isinstance(hooks, dict) or not all(isinstance(groups, list) for groups in hooks.values()):
|
|
647
|
+
raise L.LanesError('the existing file has a "hooks" value that is not {event: [groups]} — fix it by hand first')
|
|
648
|
+
out = {}
|
|
649
|
+
for event, groups in hooks.items():
|
|
650
|
+
kept = [group for group in groups if not is_lane_signal_group(group)]
|
|
651
|
+
if kept or event in ours['hooks']:
|
|
652
|
+
out[event] = kept
|
|
653
|
+
for event, groups in ours['hooks'].items():
|
|
654
|
+
out[event] = out.get(event, []) + groups
|
|
655
|
+
merged['hooks'] = out
|
|
656
|
+
return merged
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def codex_notify(text):
|
|
660
|
+
"""config.toml with a top-level `notify` that hands Codex's turn-complete to lane-signal, or `text` unchanged when
|
|
661
|
+
a top-level `notify` already exists (an operator's own notifier is never replaced). The line goes first, so it is
|
|
662
|
+
top level whatever tables follow; a `notify` inside a table is not Codex's notify."""
|
|
663
|
+
for line in text.splitlines():
|
|
664
|
+
stripped = line.strip()
|
|
665
|
+
if stripped.startswith('['):
|
|
666
|
+
break
|
|
667
|
+
if re.match(r'notify\s*=', stripped):
|
|
668
|
+
return text
|
|
669
|
+
argv = [f'{L.CONTAINER_HOME}/.lz/bin/lane-signal', 'done', '--harness', 'codex', '--event', 'notify']
|
|
670
|
+
return ('# lanes: Codex turn-complete -> lane-signal (lanes hooks install)\nnotify = '
|
|
671
|
+
+ json.dumps(argv) + '\n' + text)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def show_change(path, before, after):
|
|
675
|
+
import difflib
|
|
676
|
+
if before == after:
|
|
677
|
+
print(f'{path}: no change')
|
|
678
|
+
return False
|
|
679
|
+
sys.stdout.writelines(difflib.unified_diff(before.splitlines(True), after.splitlines(True),
|
|
680
|
+
f'{path} (now)', f'{path} (with lanes hooks)'))
|
|
681
|
+
return True
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def write_with_backup(path, before, after):
|
|
685
|
+
"""Atomic replace, the previous bytes kept beside it first. `before` is what the diff was computed from."""
|
|
686
|
+
if path.exists():
|
|
687
|
+
backup = path.with_name(f'{path.name}.lanes-bak-{time.strftime("%Y%m%dT%H%M%S")}')
|
|
688
|
+
shutil.copy2(path, backup)
|
|
689
|
+
print(f'backup {backup}')
|
|
690
|
+
elif before:
|
|
691
|
+
raise L.LanesError(f'{path} vanished while the diff was printed — run again')
|
|
692
|
+
L.atomic_write(path, after, mode=(path.stat().st_mode & 0o777) if path.exists() else 0o600)
|
|
693
|
+
print(f'wrote {path}')
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def cmd_hooks(args):
|
|
697
|
+
"""`lanes hooks install`: merge the lane-signal hook definitions into ONE container home's agent config. Dry run
|
|
698
|
+
(the printed diff) unless --write; never touches Codex's hook trust (`[hooks.state]` is Codex's own decision)."""
|
|
699
|
+
if not args or args[0] != 'install':
|
|
700
|
+
raise L.LanesError(HOOKS_USAGE)
|
|
701
|
+
opts, rest = {}, list(args[1:])
|
|
702
|
+
while rest:
|
|
703
|
+
word = rest.pop(0)
|
|
704
|
+
if word in ('--agent', '--container', '--home') and rest:
|
|
705
|
+
opts[word] = rest.pop(0)
|
|
706
|
+
elif word in ('--dry-run', '--write'):
|
|
707
|
+
opts[word] = True
|
|
708
|
+
else:
|
|
709
|
+
raise L.LanesError(HOOKS_USAGE)
|
|
710
|
+
agent = opts.get('--agent')
|
|
711
|
+
if agent not in HOOK_TARGETS or ('--container' in opts) == ('--home' in opts) or \
|
|
712
|
+
('--dry-run' in opts and '--write' in opts):
|
|
713
|
+
raise L.LanesError(HOOKS_USAGE)
|
|
714
|
+
if '--container' in opts:
|
|
715
|
+
home = (L.container_info(opts['--container']) or {}).get('home')
|
|
716
|
+
if not home:
|
|
717
|
+
raise L.LanesError(f"{opts['--container']}: home volume not found")
|
|
718
|
+
home = Path(home)
|
|
719
|
+
else:
|
|
720
|
+
home = Path(opts['--home'])
|
|
721
|
+
if not home.is_dir():
|
|
722
|
+
raise L.LanesError(f'{home}: not a directory')
|
|
723
|
+
target = home / HOOK_TARGETS[agent][1]
|
|
724
|
+
before = target.read_text() if target.exists() else ''
|
|
725
|
+
try:
|
|
726
|
+
existing = json.loads(before) if before.strip() else {}
|
|
727
|
+
except ValueError as error:
|
|
728
|
+
raise L.LanesError(f'{target}: not valid JSON ({error}) — refusing to rewrite it')
|
|
729
|
+
if not isinstance(existing, dict):
|
|
730
|
+
raise L.LanesError(f'{target}: not a JSON object — refusing to rewrite it')
|
|
731
|
+
after = json.dumps(merge_hooks(existing, render_hooks(agent)), indent=2, ensure_ascii=False) + '\n'
|
|
732
|
+
changes = [(target, before, after)] if show_change(target, before, after) else []
|
|
733
|
+
if agent == 'codex':
|
|
734
|
+
config = home / '.codex/config.toml'
|
|
735
|
+
text = config.read_text() if config.exists() else ''
|
|
736
|
+
patched = codex_notify(text)
|
|
737
|
+
if patched == text:
|
|
738
|
+
print(f'{config}: notify already set; left alone (lane-signal still hears Stop)')
|
|
739
|
+
elif show_change(config, text, patched):
|
|
740
|
+
changes.append((config, text, patched))
|
|
741
|
+
print('Codex asks you to review and trust new hooks before it runs them; lanes never writes that trust.')
|
|
742
|
+
if not opts.get('--write'):
|
|
743
|
+
print('dry run: nothing written (add --write to apply)')
|
|
744
|
+
return 0
|
|
745
|
+
for path, old, new in changes:
|
|
746
|
+
write_with_backup(path, old, new)
|
|
505
747
|
return 0
|
|
506
748
|
|
|
507
749
|
|
|
@@ -681,17 +923,17 @@ PANE_FORMAT = '#{session_name}\t#{window_index}.#{pane_index}\t#{pane_pid}\t#{wi
|
|
|
681
923
|
|
|
682
924
|
|
|
683
925
|
def all_panes():
|
|
684
|
-
"""Every pane on every LIVE tmux server that the registry references (
|
|
685
|
-
Ephemeral test-harness sockets come and go on their own and are not lanes.
|
|
686
|
-
Raises when
|
|
687
|
-
compared as panes that are "gone" — on a busy host both are wrong."""
|
|
926
|
+
"""Every pane on every LIVE tmux server that the registry references (found in the containers' cgroups the way
|
|
927
|
+
statusd finds them, read-only). Ephemeral test-harness sockets come and go on their own and are not lanes.
|
|
928
|
+
Raises when a container's cgroup could not be read or a live server did not ANSWER: a partial list would be
|
|
929
|
+
saved as a weaker invariant, or compared as panes that are "gone" — on a busy host both are wrong."""
|
|
688
930
|
sys.path.insert(0, str(L.REPO / 'libexec'))
|
|
689
931
|
import statusd
|
|
690
932
|
unknown = 'the panes are UNKNOWN, not gone — nothing saved or compared; try again'
|
|
691
|
-
live = statusd.live_tmux_sockets()
|
|
692
|
-
if live is None:
|
|
693
|
-
raise L.LanesError(f'ps did not answer: {unknown}')
|
|
694
933
|
containers = L.read_json(L.STATE / 'containers.json') or {}
|
|
934
|
+
live = statusd.live_tmux_sockets(containers)
|
|
935
|
+
if live is None:
|
|
936
|
+
raise L.LanesError(f'the live tmux servers could not be read from the container cgroups: {unknown}')
|
|
695
937
|
referenced = {}
|
|
696
938
|
for workspace in L.load_registry()['workspaces']:
|
|
697
939
|
for lane in workspace['lanes']:
|
|
@@ -735,32 +977,6 @@ def cmd_invariant(args):
|
|
|
735
977
|
return 1 if (gone or changed_pid or (resized and sizes_matter)) else 0
|
|
736
978
|
|
|
737
979
|
|
|
738
|
-
def cmd_verify_legacy(_args):
|
|
739
|
-
registry = L.load_registry()
|
|
740
|
-
containers = registry['containers']
|
|
741
|
-
bad = 0
|
|
742
|
-
for workspace in registry['workspaces']:
|
|
743
|
-
info = containers.get(workspace['container']) or {}
|
|
744
|
-
layout = Path(info.get('home', '')) / '.config/coder-launch/layouts' / f"{workspace['id']}.kdl"
|
|
745
|
-
try:
|
|
746
|
-
text = layout.read_text()
|
|
747
|
-
except OSError:
|
|
748
|
-
print(f"? {workspace['id']:<18} no deployed legacy layout")
|
|
749
|
-
continue
|
|
750
|
-
deployed = {}
|
|
751
|
-
for match in re.finditer(r'tab name=("(?:[^"\\]|\\.)*")[^\n]*\{\s*pane [^\n]*coder-launch/connect"\s*\{\s*args ("(?:[^"\\]|\\.)*") ("(?:[^"\\]|\\.)*")', text):
|
|
752
|
-
name, socket, session = (json.loads(g) for g in match.groups())
|
|
753
|
-
deployed[name] = (socket, session)
|
|
754
|
-
ours = {lane['name']: (lane['tmux']['socket'], lane['tmux']['session'])
|
|
755
|
-
for lane in workspace['lanes'] if lane['holder'] == 'tmux'}
|
|
756
|
-
diffs = [n for n in sorted(set(ours) | set(deployed)) if ours.get(n) != deployed.get(n)]
|
|
757
|
-
bad += len(diffs)
|
|
758
|
-
print(f"{'✓' if not diffs else '✗'} {workspace['id']:<18} registry={len(ours):>3} deployed={len(deployed):>3}")
|
|
759
|
-
for name in diffs:
|
|
760
|
-
print(f' {name!r}: registry={ours.get(name)} deployed={deployed.get(name)}')
|
|
761
|
-
return 1 if bad else 0
|
|
762
|
-
|
|
763
|
-
|
|
764
980
|
def active_workspace(registry, device):
|
|
765
981
|
"""The board the device's view is showing right now (its active zellij tab)."""
|
|
766
982
|
out = zellij(['-s', device, 'action', 'list-tabs', '-j'], 15)[1]
|
|
@@ -1003,7 +1219,15 @@ def cmd_gc(_args):
|
|
|
1003
1219
|
except OSError as error:
|
|
1004
1220
|
print(f'skip {log}: {error}')
|
|
1005
1221
|
status = L.read_json(L.STATE / 'status.json') or {}
|
|
1006
|
-
|
|
1222
|
+
# Every id statusd publishes. statusd already mirrors each id it lists under `unregistered` (tmux ghosts, legacy
|
|
1223
|
+
# shells) and `zmx` into `lanes` (statusd.observe/build_payload; pinned by test_statusd
|
|
1224
|
+
# Cycle.test_every_published_row_id_is_also_a_lanes_key), so `lanes` alone covers them today. The union is
|
|
1225
|
+
# defensive: the sweep does not rest on that invariant if the payload is ever decoupled. The mtime is no
|
|
1226
|
+
# liveness signal — make_entry rewrites an unregistered session's file only when it changes (DEF-1537).
|
|
1227
|
+
known = set(status.get('lanes') or {})
|
|
1228
|
+
for key in ('unregistered', 'zmx'):
|
|
1229
|
+
for entries in (status.get(key) or {}).values():
|
|
1230
|
+
known.update(e['id'] for e in entries or () if isinstance(e, dict) and e.get('id'))
|
|
1007
1231
|
for stale in (L.STATE / 'resolve.dynamic').glob('*'):
|
|
1008
1232
|
if stale.name not in known and time.time() - stale.stat().st_mtime > 3600:
|
|
1009
1233
|
stale.unlink()
|
|
@@ -1019,6 +1243,10 @@ def cmd_guard(args):
|
|
|
1019
1243
|
return libexec_module('guard').run(args)
|
|
1020
1244
|
|
|
1021
1245
|
|
|
1246
|
+
def cmd_studio_host(args):
|
|
1247
|
+
return libexec_module('studio_host').main(args) # verbs, refusals and exit codes are studio_host's
|
|
1248
|
+
|
|
1249
|
+
|
|
1022
1250
|
def libexec_module(name):
|
|
1023
1251
|
try:
|
|
1024
1252
|
return __import__(name)
|
|
@@ -1060,12 +1288,142 @@ def cmd_sockets(args):
|
|
|
1060
1288
|
return 1 if guard.sockets(env, guard.container_names(env), repair=verb == 'repair') else 0
|
|
1061
1289
|
|
|
1062
1290
|
|
|
1291
|
+
def cmd_economy(args):
|
|
1292
|
+
if '--once' not in args:
|
|
1293
|
+
raise L.LanesError('usage: lanes economy --once [--dry-run] [--status-file F --sessions-file F] '
|
|
1294
|
+
'(the recurring poller is lanes-economy.service, opt-in)')
|
|
1295
|
+
return libexec_module('economy_poll').main(args)
|
|
1296
|
+
|
|
1297
|
+
|
|
1063
1298
|
def cmd_pressure(_args):
|
|
1064
1299
|
health = L.compute_health()
|
|
1065
1300
|
print('\n'.join(pressure_lines(health, L.read_json(L.STATE / 'containers.json') or {})))
|
|
1066
1301
|
return 0 if health.get('level') == 'ok' else 1
|
|
1067
1302
|
|
|
1068
1303
|
|
|
1304
|
+
# Past this many lanes on one container's tmux socket `default` the doctor warns: that is the server every plain
|
|
1305
|
+
# `tmux` typed in the container talks to, so one stray `tmux kill-server` (or a crash of that one server) ends them all.
|
|
1306
|
+
DEFAULT_SOCKET_LANES = 4
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def recipe_report(registry):
|
|
1310
|
+
"""(info lines, warnings) of the registry's tmux lanes for `lanes doctor` (plan Task 18): lanes per create-recipe
|
|
1311
|
+
class (lanes_lib.classify_recipe), how many create with their identity (-e LANE_ID, DEF-1559), lanes bound to a
|
|
1312
|
+
numeric session name, and crowded `default` sockets. Reads registry.json only: no tmux, no container."""
|
|
1313
|
+
counts, identity, total = dict.fromkeys(L.RECIPE_CLASSES, 0), 0, 0
|
|
1314
|
+
broken, numeric, on_default = [], [], {}
|
|
1315
|
+
for workspace in registry.get('workspaces') or []:
|
|
1316
|
+
for lane in workspace.get('lanes') or []:
|
|
1317
|
+
if lane.get('holder') != 'tmux':
|
|
1318
|
+
continue
|
|
1319
|
+
total += 1
|
|
1320
|
+
kind = L.classify_recipe(lane.get('create'))
|
|
1321
|
+
counts[kind] += 1
|
|
1322
|
+
identity += 1 if L.recipe_identity(lane.get('create')) else 0
|
|
1323
|
+
if kind in ('attach-only', 'null', 'retired-path'):
|
|
1324
|
+
broken.append(lane['id'])
|
|
1325
|
+
tmux = lane.get('tmux') or {}
|
|
1326
|
+
if str(tmux.get('session') or '').isdigit():
|
|
1327
|
+
numeric.append(lane['id'])
|
|
1328
|
+
if tmux.get('socket') == 'default':
|
|
1329
|
+
on_default.setdefault(lane.get('container') or '?', []).append(lane['id'])
|
|
1330
|
+
|
|
1331
|
+
def some(ids):
|
|
1332
|
+
return ', '.join(ids[:3]) + (' …' if len(ids) > 3 else '')
|
|
1333
|
+
lines = ['create recipes: ' + ' · '.join(f'{k} {counts[k]}' for k in L.RECIPE_CLASSES) + f' (of {total} tmux lanes)',
|
|
1334
|
+
f'lane identity: {identity} of {total} tmux lanes create with LANE_ID / LANE_WS and the lanes/<ws>/<id> trace '
|
|
1335
|
+
'(lanes cursor-sync --identity / --canonicalize add it)']
|
|
1336
|
+
warnings = []
|
|
1337
|
+
if broken:
|
|
1338
|
+
warnings.append(f'{len(broken)} lanes that `lane open` cannot recreate once they end (attach-only, no recipe, or '
|
|
1339
|
+
f'the retired coder-launch path): lanes cursor-sync --canonicalize — {some(broken)}')
|
|
1340
|
+
if numeric:
|
|
1341
|
+
warnings.append(f'{len(numeric)} lanes bound to a numeric tmux session name (tmux numbers unnamed sessions '
|
|
1342
|
+
f'itself, so a recreated one gets another number): {some(numeric)}')
|
|
1343
|
+
for container, ids in sorted(on_default.items()):
|
|
1344
|
+
if len(ids) > DEFAULT_SOCKET_LANES:
|
|
1345
|
+
warnings.append(f'{container}: {len(ids)} lanes on the tmux socket `default` (more than {DEFAULT_SOCKET_LANES}): '
|
|
1346
|
+
f'every plain `tmux` in the container talks to that one server — {some(ids)}')
|
|
1347
|
+
return lines, warnings
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def socket_report(containers, live_by_container):
|
|
1351
|
+
"""Warnings for `lanes doctor`: tmux socket FILES in a container's /tmp/tmux-<uid> that no running tmux server
|
|
1352
|
+
(from the container's cgroup, `live_by_container()`, called only when some container has socket files) owns.
|
|
1353
|
+
REPORT ONLY: the host never unlinks a container's socket (RUNBOOK NEVER rule); `lane gc --sockets` in the
|
|
1354
|
+
container does, after tmux itself is refused on the file (ECONNREFUSED). A container whose servers cannot be read
|
|
1355
|
+
is said to be not checked, never concluded."""
|
|
1356
|
+
found = {}
|
|
1357
|
+
for name, info in sorted(containers.items()):
|
|
1358
|
+
if not (info or {}).get('pid'):
|
|
1359
|
+
continue
|
|
1360
|
+
directory = PROC / str(info['pid']) / 'root/tmp' / f'tmux-{L.CONTAINER_UID}'
|
|
1361
|
+
try:
|
|
1362
|
+
files = sorted(path.name for path in directory.iterdir() if path.is_socket())
|
|
1363
|
+
except OSError:
|
|
1364
|
+
continue
|
|
1365
|
+
if files:
|
|
1366
|
+
found[name] = files
|
|
1367
|
+
if not found:
|
|
1368
|
+
return []
|
|
1369
|
+
live = live_by_container({name: containers[name] for name in found}) or {}
|
|
1370
|
+
warnings = []
|
|
1371
|
+
for name, files in found.items():
|
|
1372
|
+
servers = live.get(name)
|
|
1373
|
+
if servers is None:
|
|
1374
|
+
warnings.append(f'{name}: {len(files)} tmux socket files; its tmux servers could not be read from its cgroup: '
|
|
1375
|
+
'not checked')
|
|
1376
|
+
continue
|
|
1377
|
+
orphans = [f for f in files if f not in servers]
|
|
1378
|
+
if orphans:
|
|
1379
|
+
warnings.append(f'{name}: {len(orphans)} tmux socket files with no server found for them ({" ".join(orphans)}) '
|
|
1380
|
+
'— report only; to remove the ones tmux is refused on, in the container: ~/.lz/bin/lane gc --sockets')
|
|
1381
|
+
return warnings
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def cmd_device(args):
|
|
1385
|
+
"""lanes device list | lanes device forget <name>: the devices/<name>.json files `lanes ensure` (lanes-ensure.timer)
|
|
1386
|
+
recreates a view session from, and `sync`/`upgrade-session` update. Forgetting removes that file only: a running
|
|
1387
|
+
view session is never ended here."""
|
|
1388
|
+
usage = 'usage: lanes device list | lanes device forget <name>'
|
|
1389
|
+
devices = L.STATE / 'devices'
|
|
1390
|
+
if args == ['list']:
|
|
1391
|
+
try:
|
|
1392
|
+
live = live_sessions()
|
|
1393
|
+
except L.LanesError:
|
|
1394
|
+
live = None
|
|
1395
|
+
for meta_file in sorted(devices.glob('*.json')):
|
|
1396
|
+
meta = L.read_json(meta_file) or {}
|
|
1397
|
+
kind = 'phone' if meta.get('mobile') else f"desktop, {int(meta.get('viewports') or 1)} viewports"
|
|
1398
|
+
running = 'unknown (zellij did not answer)' if live is None else (
|
|
1399
|
+
'running' if meta_file.stem in live else 'not running')
|
|
1400
|
+
print(f'{meta_file.stem:<16} {kind:<24} {running}' + (f" base {meta['base']}" if meta.get('base') else ''))
|
|
1401
|
+
return 0
|
|
1402
|
+
if len(args) != 2 or args[0] != 'forget':
|
|
1403
|
+
raise L.LanesError(usage)
|
|
1404
|
+
device = device_name(args[1])
|
|
1405
|
+
meta_file = devices / f'{device}.json'
|
|
1406
|
+
if not meta_file.exists():
|
|
1407
|
+
raise L.LanesError(f'no device {device!r} is known (lanes device list)')
|
|
1408
|
+
meta = L.read_json(meta_file) or {}
|
|
1409
|
+
try:
|
|
1410
|
+
live = device in live_sessions()
|
|
1411
|
+
except L.LanesError:
|
|
1412
|
+
live = None
|
|
1413
|
+
meta_file.unlink()
|
|
1414
|
+
kind = 'phone' if meta.get('mobile') else f"desktop, {int(meta.get('viewports') or 1)} viewports"
|
|
1415
|
+
print(f'forgot {device} ({kind}): `lanes ensure` (lanes-ensure.timer) no longer recreates its view session, and '
|
|
1416
|
+
'`lanes sync` / `upgrade-session` no longer update it')
|
|
1417
|
+
if live:
|
|
1418
|
+
print(f'its view session {device} is still running and was left alone; once it ends, nothing brings it back')
|
|
1419
|
+
elif live is None:
|
|
1420
|
+
print(f'zellij did not answer: whether a view session {device} runs now is unknown (nothing was touched)')
|
|
1421
|
+
else:
|
|
1422
|
+
print(f'no view session {device} is running')
|
|
1423
|
+
print(f'`lanes attach {device}` or `lanes prepare {device}` creates it again')
|
|
1424
|
+
return 0
|
|
1425
|
+
|
|
1426
|
+
|
|
1069
1427
|
def cmd_doctor(_args):
|
|
1070
1428
|
problems = 0
|
|
1071
1429
|
|
|
@@ -1084,7 +1442,9 @@ def cmd_doctor(_args):
|
|
|
1084
1442
|
# spelling; doctor runs on the host, so it stats the file under the container's home volume.
|
|
1085
1443
|
sys.path.insert(0, str(L.REPO / 'libexec'))
|
|
1086
1444
|
import statusd as SD
|
|
1087
|
-
|
|
1445
|
+
# A sync that found nothing to rewrite keeps registry.json and its old `generated_at` (Task 17) and
|
|
1446
|
+
# records itself in registry.synced: the later of the two is when the registry was last known current.
|
|
1447
|
+
generated_at = max(registry.get('generated_at', 0), L.registry_synced_at())
|
|
1088
1448
|
containers = L.read_json(L.STATE / 'containers.json') or {}
|
|
1089
1449
|
stale_sources = []
|
|
1090
1450
|
for src, path in SD.registry_source_paths(registry, containers):
|
|
@@ -1097,6 +1457,20 @@ def cmd_doctor(_args):
|
|
|
1097
1457
|
'run lanes sync — ' + '; '.join(stale_sources[:3]) + (' …' if len(stale_sources) > 3 else ''))
|
|
1098
1458
|
for name, info in containers.items():
|
|
1099
1459
|
check(bool(info.get('running')), f'container {name} running', f"init pid {info.get('pid')}")
|
|
1460
|
+
# Recipes, identity, session names, crowded `default` sockets, orphan socket files: warnings, never a ✗
|
|
1461
|
+
# (they describe the boards' declarations and the containers' /tmp, which doctor reports and never changes).
|
|
1462
|
+
lines, warnings = recipe_report(registry)
|
|
1463
|
+
for line in lines:
|
|
1464
|
+
print(f' {line}')
|
|
1465
|
+
for warning in warnings + socket_report(containers, SD.live_by_container):
|
|
1466
|
+
print(f'⚠ {warning}')
|
|
1467
|
+
# Plan Task 19 retired the pre-persistence Docker-shell tracker. A record still in its directory is inert (never
|
|
1468
|
+
# observed, never closable from the sidebar): name it, so the operator ends that shell by hand and removes it.
|
|
1469
|
+
legacy = sorted(p.stem for p in (L.STATE / 'legacy-sessions').glob('*.json'))
|
|
1470
|
+
if legacy:
|
|
1471
|
+
print(f'⚠ legacy-sessions/ holds {len(legacy)} record{"s" if len(legacy) != 1 else ""} the retired legacy-shell '
|
|
1472
|
+
f'tracker no longer reads: {", ".join(legacy[:5])}{" …" if len(legacy) > 5 else ""} — end those shells '
|
|
1473
|
+
'in their containers by hand, then delete the records')
|
|
1100
1474
|
status = L.read_json(L.STATE / 'status.json') or {}
|
|
1101
1475
|
age = time.time() - status.get('updated_at', 0)
|
|
1102
1476
|
check(age < 30, 'statusd heartbeat', f'{age:.0f}s old')
|
|
@@ -1132,10 +1506,15 @@ def cmd_doctor(_args):
|
|
|
1132
1506
|
zmx_lanes = sum(len(v) for v in (status.get('zmx') or {}).values())
|
|
1133
1507
|
installed = [link.name for link in sorted((Path.home() / '.lz').glob('*')) if (link / 'bin/lane').exists()]
|
|
1134
1508
|
print(f" lane contract installed in: {', '.join(installed) or 'no container yet'}; zmx-held lanes: {zmx_lanes}")
|
|
1509
|
+
print(f' preview renderer: {preview_renderer()}')
|
|
1135
1510
|
try:
|
|
1136
1511
|
print(' view sessions:', ', '.join(sorted(live_sessions())) or 'none')
|
|
1137
1512
|
except (OSError, L.LanesError) as error:
|
|
1138
1513
|
check(False, 'zellij reachable', str(error))
|
|
1514
|
+
stale_pickers = stale_picker_layouts()
|
|
1515
|
+
check(not stale_pickers, 'phone picker panes report their own title (DEF-1531)',
|
|
1516
|
+
'built from a layout that names the picker pane, so the phone shows no counts; upgrade-session does not '
|
|
1517
|
+
're-lay-out a tab — run ' + '; '.join(f'lanes rebuild {d}' for d in stale_pickers))
|
|
1139
1518
|
print('PRESSURE')
|
|
1140
1519
|
health = L.compute_health()
|
|
1141
1520
|
print('\n'.join(pressure_lines(health, L.read_json(L.STATE / 'containers.json') or {})))
|
|
@@ -1179,7 +1558,7 @@ def cmd_rename(args):
|
|
|
1179
1558
|
if not lane:
|
|
1180
1559
|
raise L.LanesError(f'Unknown lane: {lane_id}')
|
|
1181
1560
|
# An adopted session (Add to workspace / `lanes register`) takes its name from registered-sessions.json,
|
|
1182
|
-
# which is the one file renamed here; its id and binding never change (DEF-
|
|
1561
|
+
# which is the one file renamed here; its id and binding never change (DEF-1556).
|
|
1183
1562
|
if lane.get('registered_session'):
|
|
1184
1563
|
import registrations
|
|
1185
1564
|
registrations.rename(lane_id, new_name)
|
|
@@ -1196,10 +1575,245 @@ def cmd_rename(args):
|
|
|
1196
1575
|
return 0
|
|
1197
1576
|
|
|
1198
1577
|
|
|
1578
|
+
# ------------------------------------------------------------------ preview
|
|
1579
|
+
# Ctrl-Alt-m (zellij/config.kdl, spec §5.4) opens a floating pane running `lanes preview --focused`: the markdown file
|
|
1580
|
+
# the focused lane printed last, rendered by glow (else `less -R`). Read-only: two zellij listings, one guarded tmux
|
|
1581
|
+
# capture, file tests through /proc/<init>/root.
|
|
1582
|
+
|
|
1583
|
+
PREVIEW_LINES = 300
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
def pinned_binary(name):
|
|
1587
|
+
"""SHARE/bin/<name>-<version> as holders.lock pins it (install/host-install.sh installs it there), or None."""
|
|
1588
|
+
for line in (L.REPO / 'holders.lock').read_text().splitlines():
|
|
1589
|
+
fields = line.split()
|
|
1590
|
+
if len(fields) >= 4 and not line.startswith('#') and fields[0] == name:
|
|
1591
|
+
return L.SHARE / 'bin' / f'{name}-{fields[1]}'
|
|
1592
|
+
return None
|
|
1593
|
+
|
|
1594
|
+
|
|
1595
|
+
def glow_binary():
|
|
1596
|
+
"""The pinned glow when installed, else a glow on the VIEW SERVER's PATH (clean_env, which the Run pane inherits;
|
|
1597
|
+
the installer skips the download only when that same PATH finds one), else None. Never the caller's PATH: a glow
|
|
1598
|
+
in ~/.local/bin or ~/go/bin is invisible to the pane, so doctor and the pane must agree on one lookup."""
|
|
1599
|
+
pinned = pinned_binary('glow')
|
|
1600
|
+
if pinned and os.access(pinned, os.X_OK):
|
|
1601
|
+
return str(pinned)
|
|
1602
|
+
return shutil.which('glow', path=clean_env()['PATH'])
|
|
1603
|
+
|
|
1604
|
+
|
|
1605
|
+
def preview_renderer():
|
|
1606
|
+
"""What `lanes preview` renders with, as doctor reports it: pinned glow | system glow (<path>) | less -R."""
|
|
1607
|
+
glow = glow_binary()
|
|
1608
|
+
if glow and glow == str(pinned_binary('glow')):
|
|
1609
|
+
return f'pinned glow ({glow})'
|
|
1610
|
+
if glow:
|
|
1611
|
+
return f'system glow ({glow})'
|
|
1612
|
+
return 'less -R (no glow on the view server PATH; ./install.sh --server installs the pinned one)'
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
def focused_view(device):
|
|
1616
|
+
"""(view device, workspace, slot) behind the focused pane of `device`'s active tab. The floating preview pane holds
|
|
1617
|
+
the focus while this runs, so the TILED view pane zellij marks focused decides; a focused sidebar (or none
|
|
1618
|
+
marked) means the tab's first viewport. A tab without a board (Host, tools) is said, never guessed."""
|
|
1619
|
+
import shlex
|
|
1620
|
+
listings = []
|
|
1621
|
+
for action in ('list-tabs', 'list-panes'):
|
|
1622
|
+
rc, out, err = zellij(['-s', device, 'action', action, '-j'] + (['-a'] if action == 'list-panes' else []), 10)
|
|
1623
|
+
if rc != 0:
|
|
1624
|
+
raise L.LanesError(f'zellij did not answer {action} for {device}: {err.strip() or "no answer"}')
|
|
1625
|
+
try:
|
|
1626
|
+
listings.append(json.loads(out))
|
|
1627
|
+
except ValueError:
|
|
1628
|
+
raise L.LanesError(f'zellij answered {action} for {device} with something that is not JSON')
|
|
1629
|
+
tabs, panes = listings
|
|
1630
|
+
active = next((t.get('tab_id') for t in tabs if isinstance(t, dict) and t.get('active')), None)
|
|
1631
|
+
view_sh, sidebar = str(L.REPO / 'libexec/view.sh'), str(L.REPO / 'libexec/sidebar')
|
|
1632
|
+
views, boards = [], []
|
|
1633
|
+
for pane in panes:
|
|
1634
|
+
if not isinstance(pane, dict) or pane.get('is_plugin') or pane.get('is_floating') or pane.get('tab_id') != active:
|
|
1635
|
+
continue
|
|
1636
|
+
try:
|
|
1637
|
+
args = shlex.split(pane.get('terminal_command') or pane.get('pane_command') or '')
|
|
1638
|
+
except ValueError:
|
|
1639
|
+
continue
|
|
1640
|
+
for script in (view_sh, sidebar):
|
|
1641
|
+
tail = args[args.index(script) + 1:] if script in args else []
|
|
1642
|
+
if len(tail) < 2 or not all(L.IDENT.match(part) for part in tail[:2]):
|
|
1643
|
+
continue
|
|
1644
|
+
if script == view_sh:
|
|
1645
|
+
slot = tail[2] if len(tail) > 2 and tail[2] in SLOTS else 'main'
|
|
1646
|
+
views.append((not pane.get('is_focused'), SLOTS.index(slot), (tail[0], tail[1], slot)))
|
|
1647
|
+
else:
|
|
1648
|
+
boards.append((tail[0], tail[1], 'main'))
|
|
1649
|
+
if views:
|
|
1650
|
+
return min(views)[2]
|
|
1651
|
+
if boards:
|
|
1652
|
+
return boards[0]
|
|
1653
|
+
raise L.LanesError('the focused tab shows no board (Host or a tool tab): nothing to preview')
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
def lane_shown(view_device, workspace, slot):
|
|
1657
|
+
"""The lane id a viewport shows: its target file, else (first viewport only) the board's default lane."""
|
|
1658
|
+
for path in (L.STATE / 'views' / view_device / f'{workspace}.{slot}.target',
|
|
1659
|
+
L.STATE / 'defaults' / workspace if slot == 'main' else None):
|
|
1660
|
+
try:
|
|
1661
|
+
lane_id = path.read_text().split('\n')[0].strip() if path else ''
|
|
1662
|
+
except OSError:
|
|
1663
|
+
lane_id = ''
|
|
1664
|
+
if lane_id:
|
|
1665
|
+
return lane_id
|
|
1666
|
+
raise L.LanesError(f'no lane is shown in {workspace} ({slot})')
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
def lane_scrollback(info, lane):
|
|
1670
|
+
"""The last PREVIEW_LINES lines of a tmux lane, wrapped lines joined — through lanes_lib.tmux_probe (-N, scrubbed
|
|
1671
|
+
env, the socket under /proc/<init>/root) with holders/tmux.sh's guards: the `=<session>:` target, and the session
|
|
1672
|
+
that ANSWERS must be the one asked for (an object-id name like `$0` resolves another session, DEF-968)."""
|
|
1673
|
+
socket, session = lane['tmux']['socket'], lane['tmux']['session']
|
|
1674
|
+
target = f'={session}:'
|
|
1675
|
+
outcome, answer = L.tmux_probe(info, socket, ['display-message', '-p', '-t', target, '#{session_name}'])
|
|
1676
|
+
if outcome != 'ok' or answer.rstrip('\n') != session:
|
|
1677
|
+
raise L.LanesError(f"lane {lane['name']} did not answer ({outcome})")
|
|
1678
|
+
outcome, text = L.tmux_probe(info, socket, ['capture-pane', '-p', '-J', '-S', f'-{PREVIEW_LINES}', '-t', target])
|
|
1679
|
+
if outcome != 'ok':
|
|
1680
|
+
raise L.LanesError(f"lane {lane['name']}: no scrollback ({outcome})")
|
|
1681
|
+
return text
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def preview_argv(path, device):
|
|
1685
|
+
"""(argv, env) that shows `path`: glow in the device's theme (`STATE/clients/<device>.theme`, written by the
|
|
1686
|
+
sidebar from the OSC 11 answer; dark when absent), else `less -R`, else None (print it). glow 3.0.0 ignores
|
|
1687
|
+
GLAMOUR_STYLE (measured 2026-10-04: dark and light rendered identical bytes), so `-s` carries the style too."""
|
|
1688
|
+
try:
|
|
1689
|
+
style = (L.STATE / 'clients' / f'{device}.theme').read_text().strip()
|
|
1690
|
+
except OSError:
|
|
1691
|
+
style = ''
|
|
1692
|
+
style = style if style in ('dark', 'light') else 'dark'
|
|
1693
|
+
env = dict(os.environ, GLAMOUR_STYLE=style)
|
|
1694
|
+
glow = glow_binary()
|
|
1695
|
+
if glow:
|
|
1696
|
+
return [glow, '-s', style, '-p', path], env
|
|
1697
|
+
less = shutil.which('less')
|
|
1698
|
+
return ([less, '-R', path] if less else None), env
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
def preview_choose(paths):
|
|
1702
|
+
"""One path: it. Several: a numbered menu, newest first; Enter = 1, q (or anything unusable) = None."""
|
|
1703
|
+
if len(paths) == 1:
|
|
1704
|
+
return paths[0]
|
|
1705
|
+
newest = list(reversed(paths))
|
|
1706
|
+
print('Markdown files this lane printed (newest first):')
|
|
1707
|
+
for number, path in enumerate(newest, 1):
|
|
1708
|
+
print(f' {number} {path}')
|
|
1709
|
+
print('Number [1], q to close: ', end='', flush=True)
|
|
1710
|
+
answer = sys.stdin.readline().strip()
|
|
1711
|
+
if not answer:
|
|
1712
|
+
return newest[0]
|
|
1713
|
+
return newest[int(answer) - 1] if answer.isdigit() and 1 <= int(answer) <= len(newest) else None
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
def preview_hold():
|
|
1717
|
+
"""close_on_exit closes the pane the moment this exits: let a message be read first (only on a terminal)."""
|
|
1718
|
+
if sys.stdin.isatty():
|
|
1719
|
+
print('\nPress Enter to close.', end='', flush=True)
|
|
1720
|
+
sys.stdin.readline()
|
|
1721
|
+
|
|
1722
|
+
|
|
1723
|
+
def cmd_preview(args):
|
|
1724
|
+
if '--focused' not in args:
|
|
1725
|
+
print('usage: lanes preview --focused [--device D] (the Ctrl-Alt-m floating pane runs this)')
|
|
1726
|
+
return 2
|
|
1727
|
+
device = args[args.index('--device') + 1] if '--device' in args else os.environ.get('ZELLIJ_SESSION_NAME', '')
|
|
1728
|
+
try:
|
|
1729
|
+
device = device_name(device)
|
|
1730
|
+
view_device, workspace, slot = focused_view(device)
|
|
1731
|
+
registry = L.load_registry()
|
|
1732
|
+
board, lane = L.find_lane(registry, lane_shown(view_device, workspace, slot))
|
|
1733
|
+
if lane.get('holder') != 'tmux':
|
|
1734
|
+
raise L.LanesError(f"{lane['name']} is a {lane.get('holder')} lane: preview reads tmux lanes only")
|
|
1735
|
+
info = (L.read_json(L.STATE / 'containers.json') or {}).get(lane['container']) or {}
|
|
1736
|
+
text = lane_scrollback(info, lane)
|
|
1737
|
+
status = ((L.read_json(L.STATE / 'status.json') or {}).get('lanes') or {}).get(lane['id']) or {}
|
|
1738
|
+
cwd = status.get('cwd') or lane.get('folder') or board.get('cwd') or ''
|
|
1739
|
+
root = PROC / str(info.get('pid') or 0) / 'root'
|
|
1740
|
+
found = [p for p in L.markdown_candidates(text, cwd) if info.get('pid') and (root / p.lstrip('/')).is_file()]
|
|
1741
|
+
except L.LanesError as error:
|
|
1742
|
+
print(f'lanes preview: {error}')
|
|
1743
|
+
preview_hold()
|
|
1744
|
+
return 1
|
|
1745
|
+
if not found:
|
|
1746
|
+
print(f"No markdown file named in the last {PREVIEW_LINES} lines of {lane['name']} ({cwd or 'no cwd'}).")
|
|
1747
|
+
preview_hold()
|
|
1748
|
+
return 0
|
|
1749
|
+
chosen = preview_choose(found)
|
|
1750
|
+
if not chosen:
|
|
1751
|
+
return 0
|
|
1752
|
+
host = str(root / chosen.lstrip('/'))
|
|
1753
|
+
argv, env = preview_argv(host, view_device)
|
|
1754
|
+
if argv:
|
|
1755
|
+
os.execve(argv[0], argv, env)
|
|
1756
|
+
return 0 # only reached when execve is a test double
|
|
1757
|
+
sys.stdout.write(Path(host).read_text(errors='replace'))
|
|
1758
|
+
preview_hold()
|
|
1759
|
+
return 0
|
|
1760
|
+
|
|
1761
|
+
|
|
1762
|
+
# ------------------------------------------------------------------ next / prev
|
|
1763
|
+
# Ctrl-Alt-Down / Ctrl-Alt-Up (zellij/config.kdl, plan Task 19) run `lanes next|prev --focused` in a floating pane that
|
|
1764
|
+
# closes when it exits. They used to walk the focus left three times and type J/K into whatever pane that reached
|
|
1765
|
+
# (the sidebar, or — on a tab without one — the lane itself). Now nothing is typed and no focus moves: the viewport
|
|
1766
|
+
# zellij marks focused is pointed at the neighbouring lane exactly as the sidebar's open_lane points it
|
|
1767
|
+
# (lanes_lib.show_lane), and a lane with an agent is marked seen on the device as open_lane marks it.
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def step_lane(args, step):
|
|
1771
|
+
verb = 'next' if step > 0 else 'prev'
|
|
1772
|
+
if '--focused' not in args:
|
|
1773
|
+
print(f'usage: lanes {verb} --focused [--device D] (Ctrl-Alt-{"Down" if step > 0 else "Up"} runs this)')
|
|
1774
|
+
return 2
|
|
1775
|
+
device = args[args.index('--device') + 1] if '--device' in args else os.environ.get('ZELLIJ_SESSION_NAME', '')
|
|
1776
|
+
try:
|
|
1777
|
+
device = device_name(device)
|
|
1778
|
+
view_device, workspace, slot = focused_view(device)
|
|
1779
|
+
registry = L.load_registry()
|
|
1780
|
+
board = next((w for w in registry['workspaces'] if w['id'] == workspace), None)
|
|
1781
|
+
ids = [lane['id'] for lane in (board or {}).get('lanes', [])]
|
|
1782
|
+
if not ids:
|
|
1783
|
+
raise L.LanesError(f'{workspace} has no lanes to step through')
|
|
1784
|
+
try:
|
|
1785
|
+
shown = lane_shown(view_device, workspace, slot)
|
|
1786
|
+
except L.LanesError:
|
|
1787
|
+
shown = ''
|
|
1788
|
+
# A lane outside the list (an unregistered session, nothing shown yet): next starts at the first, prev at
|
|
1789
|
+
# the last.
|
|
1790
|
+
index = ids.index(shown) if shown in ids else (-1 if step > 0 else len(ids))
|
|
1791
|
+
lane_id = ids[(index + step) % len(ids)]
|
|
1792
|
+
except L.LanesError as error:
|
|
1793
|
+
print(f'lanes {verb}: {error}')
|
|
1794
|
+
return 1
|
|
1795
|
+
status = (L.read_json(L.STATE / 'status.json') or {}).get('lanes') or {}
|
|
1796
|
+
agent = (status.get(lane_id) or {}).get('agent') if isinstance(status, dict) else None
|
|
1797
|
+
if isinstance(agent, dict) and agent:
|
|
1798
|
+
L.record_seen(L.STATE / 'clients' / f'{view_device}.seen.json', lane_id, agent.get('since'), set(status))
|
|
1799
|
+
if not L.show_lane(str(L.STATE / 'views' / view_device), workspace, slot, lane_id):
|
|
1800
|
+
print('view not running yet')
|
|
1801
|
+
return 0
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
def cmd_next(args):
|
|
1805
|
+
return step_lane(args, +1)
|
|
1806
|
+
|
|
1807
|
+
|
|
1808
|
+
def cmd_prev(args):
|
|
1809
|
+
return step_lane(args, -1)
|
|
1810
|
+
|
|
1811
|
+
|
|
1199
1812
|
COMMANDS = {'rebuilt': cmd_rebuilt, 'hello': cmd_hello, 'client-config': cmd_client_config, 'cursor-commit': cmd_cursor_commit, 'upgrade-session': cmd_upgrade_session, 'upload-requests': cmd_upload_requests, 'upload-result': cmd_upload_result, 'upload': cmd_upload, 'workspaces': cmd_workspaces, 'cursor-sync': cmd_cursor_sync, 'reattach': cmd_reattach, 'gc': cmd_gc, 'install-container': cmd_install_container, 'open': cmd_open, 'sync': cmd_sync, 'ls': cmd_ls, 'resolve': cmd_resolve, 'status': cmd_status, 'layout': cmd_layout,
|
|
1200
1813
|
'prepare': cmd_prepare, 'web': cmd_web, 'attach': cmd_attach, 'rebuild': cmd_rebuild, 'ensure': cmd_ensure, 'invariant': cmd_invariant,
|
|
1201
|
-
'
|
|
1202
|
-
'guard': cmd_guard, 'rename': cmd_rename, 'register': cmd_register
|
|
1814
|
+
'doctor': cmd_doctor, 'pressure': cmd_pressure, 'sockets': cmd_sockets, 'tmp': cmd_tmp,
|
|
1815
|
+
'guard': cmd_guard, 'rename': cmd_rename, 'register': cmd_register, 'hooks': cmd_hooks, 'economy': cmd_economy,
|
|
1816
|
+
'preview': cmd_preview, 'device': cmd_device, 'next': cmd_next, 'prev': cmd_prev, 'studio-host': cmd_studio_host}
|
|
1203
1817
|
|
|
1204
1818
|
if __name__ == '__main__':
|
|
1205
1819
|
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
|