@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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +37 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +664 -50
  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 +18 -3
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +1 -1
  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 +310 -26
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +87 -13
  17. package/payload/lanes/docs/DESIGN.md +182 -4
  18. package/payload/lanes/docs/INSTALL.md +37 -2
  19. package/payload/lanes/docs/RUNBOOK.md +458 -39
  20. package/payload/lanes/docs/SHARING.md +240 -5
  21. package/payload/lanes/holders/exec.sh +2 -5
  22. package/payload/lanes/holders.lock +13 -0
  23. package/payload/lanes/install/enable-studio-host.sh +14 -0
  24. package/payload/lanes/install/host-install.sh +15 -3
  25. package/payload/lanes/install/services.py +128 -4
  26. package/payload/lanes/install.sh +108 -3
  27. package/payload/lanes/libexec/agent_state.py +32 -0
  28. package/payload/lanes/libexec/cursor_sync.py +10 -2
  29. package/payload/lanes/libexec/economy_poll.py +685 -0
  30. package/payload/lanes/libexec/guard.py +27 -1
  31. package/payload/lanes/libexec/lanes_lib.py +285 -38
  32. package/payload/lanes/libexec/notify.py +317 -0
  33. package/payload/lanes/libexec/registrations.py +1 -1
  34. package/payload/lanes/libexec/session_actions.py +7 -8
  35. package/payload/lanes/libexec/sidebar.py +911 -218
  36. package/payload/lanes/libexec/statusd.py +258 -74
  37. package/payload/lanes/libexec/studio_host.py +1608 -0
  38. package/payload/lanes/libexec/tokens.py +38 -7
  39. package/payload/lanes/libexec/view.sh +13 -3
  40. package/payload/lanes/systemd/lanes-economy.service +20 -0
  41. package/payload/lanes/systemd/lanes-notify.service +19 -0
  42. package/payload/lanes/systemd/lanes-studio-host-enrol.service +11 -0
  43. package/payload/lanes/systemd/lanes-studio-host-enrol.timer +10 -0
  44. package/payload/lanes/systemd/lanes-studio-host.service +37 -0
  45. package/payload/lanes/systemd/lanes-sync.service +3 -0
  46. package/payload/lanes/web/README.md +83 -4
  47. package/payload/lanes/web/index.html +2 -1
  48. package/payload/lanes/web/mobile.css +53 -198
  49. package/payload/lanes/web/mobile.js +87 -55
  50. package/payload/lanes/zellij/config.kdl +67 -16
  51. package/payload/manifest.json +5 -3
  52. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -1,8 +1,10 @@
1
1
  // lanes host view configuration. Generated from the user's coder-launch keybinding scheme
2
2
  // (clear-defaults, Ctrl-Alt prefix: every ordinary key reaches the agent). Edit here; this file is versioned.
3
+ // Ctrl-g is never bound (plan Task 19): it reaches the lane (Claude Code opens its editor on it); the lock is
4
+ // Ctrl-Alt-g, in and out, and the LOCKED ribbon turns on-accent-urgent while it is on (spec §5.2).
3
5
  keybinds clear-defaults=true {
4
6
  normal {
5
- bind "Ctrl g" { SwitchToMode "locked"; }
7
+ bind "Ctrl Alt g" { SwitchToMode "locked"; }
6
8
  bind "Ctrl Alt p" { SwitchToMode "pane"; }
7
9
  bind "Ctrl Alt t" { SwitchToMode "tab"; }
8
10
  bind "Ctrl Alt n" { SwitchToMode "resize"; }
@@ -12,7 +14,7 @@ keybinds clear-defaults=true {
12
14
  bind "Ctrl Alt f" { SwitchToMode "scroll"; }
13
15
  }
14
16
  locked {
15
- bind "Ctrl g" { SwitchToMode "normal"; }
17
+ bind "Ctrl Alt g" { SwitchToMode "normal"; }
16
18
  }
17
19
  pane {
18
20
  bind "left" { MoveFocus "left"; }
@@ -164,19 +166,40 @@ keybinds clear-defaults=true {
164
166
  bind "Ctrl Alt End" { ScrollToBottom; SwitchToMode "normal"; }
165
167
  bind "Ctrl Alt c" { Copy; }
166
168
  bind "Ctrl Alt left" { GoToPreviousTab; }
167
- // lanes: workspaces are tabs (Ctrl-Alt-←/→); lanes live in the sidebar. These reach the sidebar
168
- // from anywhere in the tab: it opens the previous/next lane (or starts a filter) and hands focus back.
169
- //
170
- // WHY THREE MoveFocus "left": a workspace tab is [sidebar | view main | view b | view c] (wide views, at most
171
- // 3 viewports) and WriteChars types into whichever pane holds the focus BY THEN. One "left" reaches the
172
- // sidebar only from `main`; pressed in viewport b or c it stopped on a LANE and the "J"/"K" was typed into
173
- // the agent. Three lefts reach the sidebar from every viewport, and zellij does not wrap at the left edge, so
174
- // the surplus moves are no-ops. tests/view.py proves both (3 lefts land on the sidebar, a 4th stays there, the
175
- // real Ctrl-Alt-Down bytes from viewport 2 put no "J" on any lane). More viewports = more lefts here.
176
- // Known limit: a tab WITHOUT a sidebar (Host, Throttle, the mobile view tab) has nothing to the left, so there
177
- // the character still goes to the pane itself.
178
- bind "Ctrl Alt down" { MoveFocus "left"; MoveFocus "left"; MoveFocus "left"; WriteChars "J"; }
179
- bind "Ctrl Alt up" { MoveFocus "left"; MoveFocus "left"; MoveFocus "left"; WriteChars "K"; }
169
+ // lanes: workspaces are tabs (Ctrl-Alt-←/→); lanes live in the sidebar. Next / previous lane (plan Task 19)
170
+ // types nothing: `lanes next|prev --focused` points the FOCUSED viewport (the first one when the sidebar has
171
+ // the focus) at the neighbouring lane of its board, exactly as Enter in the sidebar does, from a floating pane
172
+ // that closes when it exits. The focus stays where it was. On a tab without a board (Host, Throttle) it does
173
+ // nothing — the old WriteChars bind typed its "J"/"K" into that tab's pane. "lanes" is found on the view
174
+ // server's PATH (bin/lanes clean_env), as for Ctrl-Alt-m below.
175
+ bind "Ctrl Alt down" {
176
+ Run "lanes" "next" "--focused" {
177
+ floating true
178
+ close_on_exit true
179
+ name "Lanes"
180
+ x "0%"
181
+ y "0%"
182
+ width "1%"
183
+ height "1%"
184
+ }
185
+ }
186
+ bind "Ctrl Alt up" {
187
+ Run "lanes" "prev" "--focused" {
188
+ floating true
189
+ close_on_exit true
190
+ name "Lanes"
191
+ x "0%"
192
+ y "0%"
193
+ width "1%"
194
+ height "1%"
195
+ }
196
+ }
197
+ // These two still hand a key to the SIDEBAR (a filter and the upload box live there, so the focus must end
198
+ // on it). WHY THREE MoveFocus "left": a workspace tab is [sidebar | view main | view b | view c] (wide views,
199
+ // at most 3 viewports) and WriteChars types into whichever pane holds the focus BY THEN; three lefts reach
200
+ // the sidebar from every viewport and zellij does not wrap at the left edge (tests/view.py). Known limit: a
201
+ // tab WITHOUT a sidebar (Host, Throttle, the mobile view tab) has nothing to the left, so there the
202
+ // character still goes to the pane itself.
180
203
  bind "Ctrl Alt /" { MoveFocus "left"; MoveFocus "left"; MoveFocus "left"; WriteChars "/"; }
181
204
  // Arm the sidebar's upload box, then drop files anywhere on the window (a drop = a paste into the focused pane).
182
205
  bind "Ctrl Alt u" { MoveFocus "left"; MoveFocus "left"; MoveFocus "left"; WriteChars "U"; }
@@ -188,6 +211,24 @@ keybinds clear-defaults=true {
188
211
  }
189
212
  }
190
213
  }
214
+ // Markdown preview (spec §5.4): the newest markdown file the focused lane named, in a floating pane that closes
215
+ // when the pager quits. Both Run forms parse on Zellij 0.45.1 (measured 2026-10-04, DEF-1534); the multi-line
216
+ // children are the pinned style, and test_ctrl_alt_m_is_the_multi_line_floating_run_block pins them. "lanes"
217
+ // is found on the view server's PATH
218
+ // (bin/lanes clean_env: this checkout's bin/), because this file is read in place, never rendered per host.
219
+ shared_except "locked" {
220
+ bind "Ctrl Alt m" {
221
+ Run "lanes" "preview" "--focused" {
222
+ floating true
223
+ close_on_exit true
224
+ name "Preview"
225
+ x "10%"
226
+ y "5%"
227
+ width "80%"
228
+ height "90%"
229
+ }
230
+ }
231
+ }
191
232
  shared_except "normal" "locked" {
192
233
  bind "Alt left" { MoveFocusOrTab "left"; }
193
234
  bind "Alt down" { MoveFocus "down"; }
@@ -199,7 +240,7 @@ keybinds clear-defaults=true {
199
240
  bind "Alt [" { PreviousSwapLayout; }
200
241
  bind "Alt ]" { NextSwapLayout; }
201
242
  bind "Alt f" { ToggleFloatingPanes; }
202
- bind "Ctrl g" { SwitchToMode "locked"; }
243
+ bind "Ctrl Alt g" { SwitchToMode "locked"; }
203
244
  bind "Alt h" { MoveFocusOrTab "left"; }
204
245
  bind "Alt i" { MoveTab "left"; }
205
246
  bind "Alt j" { MoveFocus "down"; }
@@ -395,6 +436,16 @@ web_client {
395
436
  }
396
437
  }
397
438
  // END lanes tokens
439
+ // Chrome: straight frames to match the sidebar's ─ rules; the session name is noise in a 14-tab bar; simplified_ui
440
+ // removes the compact-bar's Private-Use-Area chevrons — the one glyph the cockpit needed a Nerd Font for. Tabs
441
+ // render as [ Sakio ] ribbons: accent-on-dark for the active one (ribbon_selected), dim-on-raised for the rest.
442
+ ui {
443
+ pane_frames {
444
+ rounded_corners false
445
+ hide_session_name true
446
+ }
447
+ }
448
+ simplified_ui true
398
449
  default_mode "normal"
399
450
  mouse_mode true
400
451
  copy_clipboard "system"
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@vibelord/lanes",
3
- "version": "1.0.1",
4
- "commit": "7eefe03dd9362ca62d05abd2d23cbc958d6af346",
5
- "digest": "sha256:09b4d87fb5687fe5e72140baa68f9518ec96cbf9a3b08c55d7f9e99afb55b295",
3
+ "version": "1.0.3",
4
+ "commit": "77ee54d454b851134f0aed88534762c8b457b34c",
5
+ "digest": "sha256:9d8f4e272d733e6405d471bdab9d950615670f41e181c56c96297fe5ed451ced",
6
6
  "executables": [
7
7
  "bin/lanes",
8
8
  "client/install.sh",
@@ -12,11 +12,13 @@
12
12
  "client/omarchy/discover.sh",
13
13
  "container/lane",
14
14
  "container/lane-run",
15
+ "container/lane-signal",
15
16
  "holders/exec.sh",
16
17
  "holders/ssh.sh",
17
18
  "holders/tmux.sh",
18
19
  "holders/zmx.sh",
19
20
  "install.sh",
21
+ "install/enable-studio-host.sh",
20
22
  "install/enable-web.sh",
21
23
  "install/host-install.sh",
22
24
  "libexec/connection.py",
@@ -1,130 +0,0 @@
1
- """Track pre-persistence Docker shells without pretending their lost Docker connection is reattachable."""
2
- import json
3
- import os
4
- import select
5
- import signal
6
- import sys
7
- import time
8
- from pathlib import Path
9
-
10
- import lanes_lib as L
11
-
12
-
13
- def process(pid):
14
- root = Path(L.proc_root()) / str(pid)
15
- stat = (root / 'stat').read_text().rsplit(') ', 1)[1].split()
16
- return {'pid': int(pid), 'ppid': int(stat[1]), 'sid': int(stat[3]), 'tty': stat[4], 'start': stat[19],
17
- 'uid': root.stat().st_uid, 'cgroup': (root / 'cgroup').read_text(),
18
- 'cwd': os.readlink(root / 'cwd'), 'command': (root / 'comm').read_text().strip()}
19
-
20
-
21
- def observe():
22
- lanes, groups = {}, {}
23
- for path in (L.STATE / 'legacy-sessions').glob('*.json'):
24
- entry = L.read_json(path) or {}
25
- identity = entry.get('legacy') or {}
26
- try:
27
- actual = process(identity['pid'])
28
- if actual['start'] != identity['start']:
29
- continue
30
- state = {'state': 'quiet', 'pid': actual['pid'], 'cwd': actual['cwd'],
31
- 'command': actual['command'], 'legacy': True}
32
- except (FileNotFoundError, ProcessLookupError, KeyError):
33
- continue
34
- except OSError:
35
- state = {'state': 'unknown', 'observed': 'error', 'legacy': True}
36
- lanes[entry['id']] = state
37
- groups.setdefault(entry['workspace'], []).append(entry)
38
- L.atomic_write(L.STATE / 'resolve.dynamic' / entry['id'],
39
- '\n'.join(['exec', entry['container'], 'legacy', entry['id'], entry['name']]) + '\n')
40
- return lanes, groups
41
-
42
-
43
- def close(entry):
44
- identity = entry.get('legacy') or {}
45
- recorded = L.read_json(L.STATE / 'legacy-sessions' / (entry['id'] + '.json')) or {}
46
- if recorded.get('legacy') != identity:
47
- raise L.LanesError('Legacy shell identity changed; nothing was closed')
48
- try:
49
- root = process(identity['pid'])
50
- except (OSError, KeyError):
51
- raise L.LanesError('Legacy shell could not be checked; nothing was closed')
52
- if root['start'] != identity['start'] or root['uid'] != os.getuid():
53
- raise L.LanesError('Legacy shell changed; nothing was closed')
54
- members = {}
55
- for path in Path(L.proc_root()).iterdir():
56
- if not path.name.isdigit():
57
- continue
58
- try:
59
- candidate = process(path.name)
60
- if candidate['uid'] == root['uid'] and candidate['cgroup'] == root['cgroup']:
61
- members[candidate['pid']] = candidate
62
- except OSError:
63
- continue
64
- # The root may have exited and its PID been reused while we scanned /proc.
65
- # Keep the recorded identity authoritative rather than adopting the scan's replacement.
66
- if members.get(root['pid'], {}).get('start') != root['start']:
67
- raise L.LanesError('Legacy shell changed or could not be checked; nothing was closed')
68
- chosen = {root['pid']} | {pid for pid, p in members.items()
69
- if p['sid'] == root['sid'] and p['tty'] == root['tty'] and root['tty'] != '0'}
70
- while True:
71
- children = {pid for pid, p in members.items() if p['ppid'] in chosen}
72
- if children <= chosen:
73
- break
74
- chosen.update(children)
75
- handles, root_verified = [], False
76
- try:
77
- for pid in chosen:
78
- fd = None
79
- try:
80
- fd = os.pidfd_open(pid)
81
- if process(pid)['start'] != members[pid]['start']:
82
- os.close(fd)
83
- continue
84
- handles.append(fd)
85
- if pid == root['pid']:
86
- root_verified = True
87
- except (OSError, KeyError):
88
- if fd is not None:
89
- os.close(fd)
90
- continue
91
- if not root_verified:
92
- raise L.LanesError('Legacy shell could not be checked; nothing was closed')
93
- # pidfds remain attached to these processes even if the kernel recycles their numeric pids.
94
- for fd in handles:
95
- try:
96
- signal.pidfd_send_signal(fd, signal.SIGHUP)
97
- except ProcessLookupError:
98
- pass
99
- remaining, deadline = set(handles), time.monotonic() + 2
100
- while remaining and time.monotonic() < deadline:
101
- ready, _, _ = select.select(list(remaining), [], [], 0.1)
102
- remaining.difference_update(ready)
103
- for fd in remaining:
104
- try:
105
- signal.pidfd_send_signal(fd, signal.SIGKILL)
106
- except ProcessLookupError:
107
- pass
108
- finally:
109
- for fd in handles:
110
- os.close(fd)
111
- (L.STATE / 'legacy-sessions' / (entry['id'] + '.json')).unlink(missing_ok=True)
112
-
113
-
114
- def view(container, lane_id):
115
- L._require(L.IDENT, lane_id, 'legacy shell')
116
- entry = L.read_json(L.STATE / 'legacy-sessions' / (lane_id + '.json')) or {}
117
- if entry.get('container') != container:
118
- raise L.LanesError('Legacy shell no longer exists')
119
- print('\033[2J\033[H\n ' + entry['name'] + '\n', flush=True)
120
- print(' This shell predates persistent terminals. Its original Docker connection cannot be reattached.\n'
121
- ' Its shell and running jobs are preserved. Use × in Unregistered to terminate them.\n'
122
- ' New + creates a shell you can leave and reopen.\n', flush=True)
123
- print(' Container: ' + container + '\n Process: ' + str(entry['legacy']['pid']), flush=True)
124
- while True:
125
- time.sleep(30)
126
-
127
-
128
- if __name__ == '__main__':
129
- if len(sys.argv) == 4 and sys.argv[1] == 'view':
130
- view(sys.argv[2], sys.argv[3])