@vibelord/lanes 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/bin/lanes-install.mjs +158 -0
  4. package/lib/plan.mjs +65 -0
  5. package/package.json +37 -0
  6. package/payload/lanes/README.md +152 -0
  7. package/payload/lanes/VERSION +1 -0
  8. package/payload/lanes/bin/lanes +1035 -0
  9. package/payload/lanes/client/ghostty/common.conf +22 -0
  10. package/payload/lanes/client/ghostty/macos.conf +6 -0
  11. package/payload/lanes/client/ghostty/omarchy.conf +19 -0
  12. package/payload/lanes/client/install.sh +16 -0
  13. package/payload/lanes/client/lanes-launch +315 -0
  14. package/payload/lanes/client/lanes-upload +147 -0
  15. package/payload/lanes/client/lanes-window +36 -0
  16. package/payload/lanes/client/macos-helper.applescript +14 -0
  17. package/payload/lanes/client/omarchy/discover.sh +56 -0
  18. package/payload/lanes/client/omarchy/lanes.conf +5 -0
  19. package/payload/lanes/client/omarchy/lanes.desktop +9 -0
  20. package/payload/lanes/client/omarchy/lanes.lua +14 -0
  21. package/payload/lanes/client/ssh.stanza.in +54 -0
  22. package/payload/lanes/container/env.sh +16 -0
  23. package/payload/lanes/container/lane +268 -0
  24. package/payload/lanes/container/lane-run +34 -0
  25. package/payload/lanes/docs/ARCHITECTURE.md +93 -0
  26. package/payload/lanes/docs/HOST-GUARD.md +108 -0
  27. package/payload/lanes/docs/INSTALL.md +192 -0
  28. package/payload/lanes/docs/RUNBOOK.md +385 -0
  29. package/payload/lanes/docs/SHARING.md +124 -0
  30. package/payload/lanes/docs/ZMX-RING0.md +51 -0
  31. package/payload/lanes/docs/images/desktop.png +0 -0
  32. package/payload/lanes/docs/images/iphone.png +0 -0
  33. package/payload/lanes/holders/exec.sh +21 -0
  34. package/payload/lanes/holders/tmux.sh +111 -0
  35. package/payload/lanes/holders/zmx.sh +22 -0
  36. package/payload/lanes/holders.lock +5 -0
  37. package/payload/lanes/install/enable-phone.sh +43 -0
  38. package/payload/lanes/install/enable-web.sh +16 -0
  39. package/payload/lanes/install/host-install.sh +72 -0
  40. package/payload/lanes/install/services.py +61 -0
  41. package/payload/lanes/install.sh +603 -0
  42. package/payload/lanes/libexec/cursor_commit.py +159 -0
  43. package/payload/lanes/libexec/cursor_sync.py +166 -0
  44. package/payload/lanes/libexec/guard.py +231 -0
  45. package/payload/lanes/libexec/lanes_lib.py +610 -0
  46. package/payload/lanes/libexec/legacy_sessions.py +130 -0
  47. package/payload/lanes/libexec/session_actions.py +276 -0
  48. package/payload/lanes/libexec/sidebar +8 -0
  49. package/payload/lanes/libexec/sidebar.py +872 -0
  50. package/payload/lanes/libexec/statusd.py +764 -0
  51. package/payload/lanes/libexec/tmpscan.py +921 -0
  52. package/payload/lanes/libexec/view.sh +179 -0
  53. package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
  54. package/payload/lanes/site/README.md +28 -0
  55. package/payload/lanes/site/beyondwork/known_hosts +1 -0
  56. package/payload/lanes/site/beyondwork/site.conf +11 -0
  57. package/payload/lanes/site/site.example.conf +10 -0
  58. package/payload/lanes/sudo/host-guard.sh +356 -0
  59. package/payload/lanes/sudo/one-time-root.sh +32 -0
  60. package/payload/lanes/systemd/lanes-ensure.service +7 -0
  61. package/payload/lanes/systemd/lanes-ensure.timer +10 -0
  62. package/payload/lanes/systemd/lanes-gc.service +7 -0
  63. package/payload/lanes/systemd/lanes-gc.timer +9 -0
  64. package/payload/lanes/systemd/lanes-guard.service +8 -0
  65. package/payload/lanes/systemd/lanes-guard.timer +10 -0
  66. package/payload/lanes/systemd/lanes-mobile.service +23 -0
  67. package/payload/lanes/systemd/lanes-statusd.service +15 -0
  68. package/payload/lanes/systemd/lanes-sync.service +10 -0
  69. package/payload/lanes/systemd/lanes-sync.timer +10 -0
  70. package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
  71. package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
  72. package/payload/lanes/systemd/lanes-web.service +13 -0
  73. package/payload/lanes/systemd/lanes.slice +11 -0
  74. package/payload/lanes/web/Caddyfile +37 -0
  75. package/payload/lanes/web/README.md +35 -0
  76. package/payload/lanes/web/icon-192.png +0 -0
  77. package/payload/lanes/web/icon.svg +4 -0
  78. package/payload/lanes/web/index.html +30 -0
  79. package/payload/lanes/web/manifest.webmanifest +15 -0
  80. package/payload/lanes/web/mobile.css +301 -0
  81. package/payload/lanes/web/mobile.js +367 -0
  82. package/payload/lanes/workspaces.example/README.md +27 -0
  83. package/payload/lanes/workspaces.example/catalog.json +7 -0
  84. package/payload/lanes/workspaces.example/commit-targets.json +8 -0
  85. package/payload/lanes/workspaces.example/terminals.example.json +19 -0
  86. package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
  87. package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
  88. package/payload/lanes/zellij/config.kdl +360 -0
  89. package/payload/manifest.json +31 -0
@@ -0,0 +1,159 @@
1
+ """lanes cursor-commit — commit the `lanes cursor-sync` normalization of `.vscode/terminals.json` on top of each
2
+ repository's REMOTE branch tip and push it, without touching any working tree or index.
3
+
4
+ Why not `git commit && git push` in the repo roots: the roots are long-lived, dirty checkouts (hundreds of modified
5
+ files) that sit far behind their remotes, so a normal push is rejected and a pull is out of the question. Instead,
6
+ inside the container, per repository: fetch the target branch -> build the new tree from the remote tip with ONE
7
+ path replaced (temporary index, `git update-index --cacheinfo`, `git write-tree`) -> `git commit-tree` -> push that
8
+ exact commit to the branch (a fast-forward by construction; a concurrent push simply makes it fail, never force).
9
+
10
+ Content rule: if the remote's blob equals the root's HEAD blob, the root's current (normalized) file is committed;
11
+ otherwise the REMOTE's version is normalized by terminal name, so upstream edits are never overwritten by a stale root.
12
+
13
+ Dry-run by default; `--push` performs the pushes. A branch that refuses direct pushes (repository rules) gets the
14
+ commit on a `lanes/terminals-lane-open` branch and a pull request against the target branch instead.
15
+ """
16
+ import json
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ import cursor_sync
22
+ import lanes_lib as L
23
+
24
+ MESSAGE = """chore(terminals): open lanes through the uniform `lane open` entry point
25
+
26
+ Each terminal's command becomes `exec /home/coder/.lz/bin/lane open <lane-id>`: attach to the lane; if it does
27
+ not exist, run its recorded create recipe; never nest inside tmux; holder-agnostic. The original commands and the
28
+ exact tmux bindings are kept in a top-level `lanes` block. Names, icons, colors, order and the `throttle` block
29
+ are unchanged.
30
+
31
+ Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
32
+ """
33
+ FILE = '.vscode/terminals.json'
34
+ PUSH = '''
35
+ slug=$(git remote get-url origin | sed -E 's#(git@github.com:|https://([^@]*@)?github.com/)##; s#\\.git$##')
36
+ hooks=$(git config core.hooksPath || true); hooks=${hooks:-$(cd "$(git rev-parse --git-path hooks)" && pwd)}
37
+ push_dir=$PWD
38
+ if [ -x "$hooks/pre-push" ]; then
39
+ # The repository gates pushes. Its gate inspects the CHECKOUT it runs in, and the root checkout is a stale, dirty
40
+ # tree: push from a clean temporary worktree of exactly this commit, so the gate checks what is being pushed.
41
+ push_dir=/home/coder/.lz/commit-staging/wt-BOARD
42
+ git worktree remove --force "$push_dir" >/dev/null 2>&1 || rm -rf "$push_dir"; git worktree prune
43
+ git worktree add -q --detach "$push_dir" $commit
44
+ echo "pre-push gate present: pushing from a clean worktree of the commit"
45
+ # In a linked worktree `.git` is a file, so Go's VCS stamping walks up to the next `.git` DIRECTORY (here a broken
46
+ # /home/coder/.git) and every `go list` dies with "error obtaining VCS status". Stamping is irrelevant to a gate run.
47
+ if command -v go >/dev/null 2>&1; then GOFLAGS="$(go env GOFLAGS) -buildvcs=false"; export GOFLAGS; fi
48
+ fi
49
+ do_push() { (cd "$push_dir" && timeout -k 10 3300 git push "$@" 2>&1); }
50
+ open_pr() {
51
+ if out=$(do_push -f origin $commit:refs/heads/lanes/terminals-lane-open); then
52
+ gh pr create --repo "$slug" --base BRANCH --head lanes/terminals-lane-open \
53
+ --title "chore(terminals): open lanes through the uniform lane open entry point" \
54
+ --body-file /home/coder/.lz/commit-staging/BOARD.pr 2>&1 </dev/null | tail -1
55
+ else echo "PUSH FAILED (rc=$?)"; echo "$out" | tail -15; fi
56
+ }
57
+ # (gh prints the error body on stdout when the rules API is unavailable for the repository: trust the exit status)
58
+ needs_pr=$(gh api "repos/$slug/rules/branches/BRANCH" --jq '[.[].type]|index("pull_request")' 2>/dev/null </dev/null) || needs_pr=
59
+ case "$needs_pr" in
60
+ ''|null)
61
+ if out=$(do_push origin $commit:refs/heads/BRANCH); then echo "PUSHED to BRANCH"
62
+ elif echo "$out" | grep -q -i -E "rule violations|protected branch|GH006|GH013"; then echo "$out" | tail -2; open_pr
63
+ else echo "PUSH FAILED"; echo "$out" | tail -15; fi ;;
64
+ *) echo "BRANCH requires pull requests (repository rules)"; open_pr ;;
65
+ esac
66
+ [ "$push_dir" = "$PWD" ] || { git worktree remove --force "$push_dir" >/dev/null 2>&1; git worktree prune; }
67
+ '''
68
+ def site():
69
+ """SITE data: which repository/branch each board's registry is committed to, and the commit author.
70
+ <workspaces dir>/commit-targets.json = {"author": {"name", "email"}, "targets": {board: {"container", "folder",
71
+ "branch", "options": ["remote-only"]}}, "skip": {board: reason}}. `remote-only`: never publish lanes that exist
72
+ only in the root checkout (normalize the committed version instead)."""
73
+ path = L.workspaces_dir() / 'commit-targets.json'
74
+ if not path.exists():
75
+ raise L.LanesError(f'{path} not found: list the boards to commit there (see workspaces.example/)')
76
+ return L.read_site_json(path)
77
+
78
+
79
+ def in_container(container, script, check=True, timeout=600):
80
+ result = subprocess.run(['docker', 'exec', '-i', '-u', 'coder', container, 'bash', '-ls'], input=script,
81
+ capture_output=True, text=True, timeout=timeout)
82
+ if check and result.returncode:
83
+ raise L.LanesError(f'{container}: {result.stderr.strip()[-400:]}')
84
+ return result
85
+
86
+
87
+ def run(args):
88
+ push = '--push' in args
89
+ only = [a for a in args if not a.startswith('--')]
90
+ registry = L.load_registry()
91
+ data = site()
92
+ author = (data['author']['name'], data['author']['email'])
93
+ for board, target in data['targets'].items():
94
+ if only and board not in only:
95
+ continue
96
+ container, folder, branch, options = target['container'], target['folder'], target['branch'], target.get('options', [])
97
+ workspace = next(w for w in registry['workspaces'] if w['id'] == board)
98
+ home = Path(registry['containers'][container]['home'])
99
+ root_file = home / folder[len('/home/coder/'):] / FILE
100
+ facts = in_container(container, f'''
101
+ cd {folder} && git fetch -q origin {branch} || exit 9
102
+ echo "REMOTE $(git rev-parse -q --verify origin/{branch}:{FILE} || echo absent)"
103
+ echo "HEAD $(git rev-parse -q --verify HEAD:{FILE} || echo absent)"
104
+ echo "TIP $(git rev-parse origin/{branch})"
105
+ ''').stdout
106
+ info = dict(line.split(' ', 1) for line in facts.strip().splitlines())
107
+ remote_text = '' if info['REMOTE'] == 'absent' else in_container(container, f'cd {folder} && git show origin/{branch}:{FILE}').stdout
108
+ if (info['REMOTE'] == info['HEAD'] and 'remote-only' not in options) or info['REMOTE'] == 'absent':
109
+ desired, how = root_file.read_text(), 'root file (remote blob == root HEAD blob)' if info['REMOTE'] != 'absent' else 'root file (new on remote)'
110
+ else:
111
+ lanes = {(lane.get('registry_name') or lane['name']): lane for lane in workspace['lanes']}
112
+ desired, how = cursor_sync.normalized_text(remote_text, lanes), "remote version normalized (remote differs from the stale root)"
113
+ wanted = [t.get('name') for t in json.loads(desired).get('terminals', [])]
114
+ had = [t.get('name') for t in json.loads(remote_text).get('terminals', [])] if remote_text else []
115
+ added = [n for n in wanted if n not in had]
116
+ message = MESSAGE
117
+ if remote_text and 'remote-only' not in options and (added or info['REMOTE'] == info['HEAD']):
118
+ note = ('This also records the board as it runs today' +
119
+ (': lanes added locally since the last commit: ' + ', '.join(added) + '.' if added else
120
+ ' (bindings edited locally since the last commit).'))
121
+ if added or desired.count('"command"') and json.loads(remote_text) != json.loads(root_file.read_text()):
122
+ message = MESSAGE.replace('\nCo-Authored-By', note + '\n\nCo-Authored-By')
123
+ elif not remote_text:
124
+ message = MESSAGE.replace('\nCo-Authored-By', 'The file was not tracked before: this adds the board registry.\n\nCo-Authored-By')
125
+ staging = home / '.lz' / 'commit-staging'
126
+ staging.mkdir(parents=True, exist_ok=True)
127
+ (staging / f'{board}.json').write_text(desired)
128
+ (staging / f'{board}.msg').write_text(message)
129
+ (staging / f'{board}.pr').write_text(
130
+ 'Cursor / VS Code terminals now reach their lane through one uniform, attach-first entry point.\n\n' +
131
+ message.split('\n', 2)[2].replace('Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>\n', '') +
132
+ '\nOne file changed: `.vscode/terminals.json`. No code, build or UI changes.\n\n'
133
+ '🤖 Generated with [Claude Code](https://claude.com/claude-code)\n')
134
+ script = f'''
135
+ set -e
136
+ cd {folder}
137
+ export GIT_INDEX_FILE=/home/coder/.lz/commit-staging/{board}.index
138
+ rm -f "$GIT_INDEX_FILE"
139
+ git read-tree origin/{branch}
140
+ blob=$(git hash-object -w /home/coder/.lz/commit-staging/{board}.json)
141
+ git update-index --add --cacheinfo 100644,$blob,{FILE}
142
+ tree=$(git write-tree)
143
+ rm -f "$GIT_INDEX_FILE"
144
+ unset GIT_INDEX_FILE # must not leak into `git worktree add` / the pre-push gate (a worktree without its own index looks all-untracked)
145
+ if [ "$tree" = "$(git rev-parse origin/{branch}^{{tree}})" ]; then echo "NOCHANGE"; exit 0; fi
146
+ commit=$(GIT_AUTHOR_NAME="{author[0]}" GIT_AUTHOR_EMAIL="{author[1]}" GIT_COMMITTER_NAME="{author[0]}" GIT_COMMITTER_EMAIL="{author[1]}" \\
147
+ git commit-tree $tree -p origin/{branch} -F /home/coder/.lz/commit-staging/{board}.msg)
148
+ echo "COMMIT $commit"
149
+ git diff --stat origin/{branch} $commit | tail -2
150
+ {PUSH.replace('BRANCH', branch).replace('BOARD', board) if push else 'echo "DRY-RUN: not pushed"'}
151
+ '''
152
+ try:
153
+ # a repository's pre-push gate (lint + tests) may run for a long while; the in-container `timeout` ends first
154
+ result = in_container(container, script, check=False, timeout=3600)
155
+ out = (result.stdout + result.stderr).strip().replace('\n', '\n ')
156
+ except subprocess.TimeoutExpired:
157
+ out = 'TIMED OUT after 3600 s — not pushed'
158
+ print(f'== {board} -> {branch} [{how}]\n {out}', flush=True)
159
+ return 0
@@ -0,0 +1,166 @@
1
+ """lanes cursor-sync — make Cursor / VS Code (Terminals Manager) open exactly the lanes of the lanes view.
2
+
3
+ The repository's `.vscode/terminals.json` stays the source of truth for WHICH lanes exist and how they look
4
+ (name, icon, color, description, order). This tool only normalizes HOW a terminal reaches its lane:
5
+
6
+ * every terminal's `command` becomes the uniform, holder-agnostic entry point
7
+ exec /home/coder/.lz/bin/lane open <lane-id>
8
+ (attach; if the lane does not exist, run its recorded create recipe; never nest; ignore-size while the lanes
9
+ view shows the lane);
10
+ * the ORIGINAL command and the exact binding are preserved next to it in a top-level `lanes` block, which the
11
+ extension and Throttle ignore: lanes.terminals[<name>] = {id, holder, tmux{socketName,sessionName}, create}.
12
+
13
+ Text is edited minimally (command strings replaced in place, the `lanes` block inserted before the final brace) so
14
+ git diffs stay small. Dry-run by default: prints a unified diff. `--write` applies (with a timestamped backup).
15
+ Boards whose registry lives outside a repository are additionally PROJECTED into the file Cursor
16
+ reads for that folder, named by `"cursor": "<path>"` on the repository entry of the workspace manifest.
17
+ """
18
+ import difflib
19
+ import json
20
+ import re
21
+ import time
22
+ from pathlib import Path
23
+
24
+ import lanes_lib as L
25
+
26
+ ENTRY = 'exec /home/coder/.lz/bin/lane open '
27
+
28
+
29
+ def _indent_of(text):
30
+ match = re.search(r'\n([ \t]+)"', text)
31
+ return match.group(1) if match else ' '
32
+
33
+
34
+ def normalized_text(text, lanes_by_name):
35
+ """Return the file text with uniform commands + the `lanes` block. Minimal textual edits when possible."""
36
+ data = json.loads(text)
37
+ block = {'version': 1, 'terminals': {}}
38
+ commands = {}
39
+ for term in data.get('terminals', []):
40
+ lane = lanes_by_name.get(term.get('name'))
41
+ if not lane:
42
+ continue
43
+ previous = ((data.get('lanes') or {}).get('terminals') or {}).get(term['name']) or {}
44
+ create = previous.get('create') if L.LANE_OPEN in (term.get('command') or '') else term.get('command')
45
+ entry = {'id': lane['id'], 'holder': lane['holder']}
46
+ if lane.get('tmux'):
47
+ entry['tmux'] = {'socketName': lane['tmux']['socket'], 'sessionName': lane['tmux']['session']}
48
+ if create:
49
+ entry['create'] = create
50
+ block['terminals'][term['name']] = entry
51
+ commands[term['name']] = ENTRY + lane['id']
52
+ # Nothing to do: leave the file byte-identical (never reformat somebody else's JSON).
53
+ if (data.get('lanes') == block and
54
+ all(term.get('command') == commands.get(term.get('name'), term.get('command')) for term in data.get('terminals', []))):
55
+ return text
56
+ # Minimal textual edits: swap command string literals in place; append (or replace) the trailing `lanes` block.
57
+ out, minimal = text, True
58
+ for term in data.get('terminals', []):
59
+ target = commands.get(term.get('name'))
60
+ current = term.get('command')
61
+ if target is None or current == target:
62
+ continue
63
+ literal = '"command": ' + json.dumps(current, ensure_ascii=False)
64
+ escaped = '"command": ' + json.dumps(current) # the file may use \uXXXX escapes
65
+ found = literal if out.count(literal) == 1 else escaped if out.count(escaped) == 1 else None
66
+ if current is None or found is None:
67
+ minimal = False
68
+ break
69
+ out = out.replace(found, '"command": ' + json.dumps(target, ensure_ascii=False))
70
+ if minimal and 'lanes' in data:
71
+ # Our block is the last key we appended: cut it off textually, otherwise fall back to re-serializing.
72
+ start = out.rfind('"lanes":')
73
+ head = out[:start].rstrip()
74
+ if start > 0 and head.endswith(',') and list(data.keys())[-1] == 'lanes':
75
+ out = head[:-1].rstrip() + '\n}' + ('\n' if text.endswith('\n') else '')
76
+ try:
77
+ json.loads(out)
78
+ except ValueError:
79
+ minimal = False
80
+ else:
81
+ minimal = False
82
+ if minimal:
83
+ indent = _indent_of(text)
84
+ rendered = json.dumps(block, ensure_ascii=False, indent=len(indent) if indent.strip() == '' and indent else 2)
85
+ rendered = ('\n' + indent).join(rendered.splitlines())
86
+ end = out.rstrip().rfind('}')
87
+ body = out[:end].rstrip()
88
+ out = body + (',' if not body.endswith('{') else '') + '\n' + indent + '"lanes": ' + rendered + '\n}' + \
89
+ ('\n' if text.endswith('\n') else '')
90
+ if json.loads(out).get('lanes') == block:
91
+ return out
92
+ for term in data.get('terminals', []):
93
+ if term.get('name') in commands:
94
+ term['command'] = commands[term['name']]
95
+ data['lanes'] = block
96
+ return json.dumps(data, ensure_ascii=False, indent=2) + ('\n' if text.endswith('\n') else '')
97
+
98
+
99
+ def projected_text(source_text, source_path):
100
+ """A Cursor-facing copy of an out-of-repo board registry: the same document (terminals, uniform commands, the
101
+ throttle and lanes binding blocks other tools may read) plus a note saying where the original lives."""
102
+ data = json.loads(source_text)
103
+ note = {'//': f'Generated by `lanes cursor-sync` from {source_path}. Edit that registry, not this file.'}
104
+ return json.dumps({**note, **data}, ensure_ascii=False, indent=2) + '\n'
105
+
106
+
107
+ def plan(registry, only=()):
108
+ """Yield (workspace id, host path, container path, old text, new text, note)."""
109
+ catalog = L.read_site_json(L.workspaces_dir() / 'catalog.json')['workspaces']
110
+ containers = registry['containers']
111
+ for item in catalog:
112
+ if only and item['id'] not in only:
113
+ continue
114
+ workspace = next((w for w in registry['workspaces'] if w['id'] == item['id']), None)
115
+ manifest = L.read_site_json(L.workspaces_dir() / item['manifest'])
116
+ if not workspace or workspace.get('errors'):
117
+ yield item['id'], None, None, '', '', 'skipped: registry errors — fix them first'
118
+ continue
119
+ for repo in manifest.get('repositories') or []:
120
+ info = containers.get(repo['host']) or {}
121
+ lanes = {(lane.get('registry_name') or lane['name']): lane for lane in workspace['lanes'] if lane.get('repo') == repo['id']}
122
+ prefix = repo.get('prefix') or ''
123
+ lanes = {name[len(prefix):] if prefix and name.startswith(prefix) else name: lane for name, lane in lanes.items()}
124
+ host_file = L.host_path(info, repo['registry'])
125
+ old = host_file.read_text()
126
+ new = normalized_text(old, lanes)
127
+ yield item['id'], host_file, repo['registry'], old, new, 'registry'
128
+ if repo.get('cursor'):
129
+ target = L.host_path(info, repo['cursor'])
130
+ current = target.read_text() if target.exists() else ''
131
+ yield item['id'], target, repo['cursor'], current, projected_text(new, repo['registry']), 'projection for Cursor (git-tracked file)'
132
+
133
+
134
+ def run(args):
135
+ write = '--write' in args
136
+ only = [a for a in args if not a.startswith('--')]
137
+ registry = L.load_registry()
138
+ changed = 0
139
+ for workspace, host_file, container_path, old, new, note in plan(registry, only):
140
+ if host_file is None:
141
+ print(f'== {workspace}: {note}')
142
+ continue
143
+ if old == new:
144
+ print(f'== {workspace}: {container_path} — already normalized ({note})')
145
+ continue
146
+ changed += 1
147
+ diff = list(difflib.unified_diff(old.splitlines(), new.splitlines(), 'a' + container_path, 'b' + container_path, lineterm='', n=1))
148
+ adds = sum(1 for line in diff if line.startswith('+') and not line.startswith('+++'))
149
+ dels = sum(1 for line in diff if line.startswith('-') and not line.startswith('---'))
150
+ print(f'== {workspace}: {container_path} — {note}: +{adds} -{dels}')
151
+ if '--diff' in args or not write:
152
+ limit = None if '--diff' in args else 14
153
+ for line in diff[:limit]:
154
+ print(' ' + line[:200])
155
+ if limit and len(diff) > limit:
156
+ print(f' … {len(diff) - limit} more diff lines (use --diff)')
157
+ if write:
158
+ backup_dir = L.STATE / 'cursor-sync-backups' / time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())
159
+ backup_dir.mkdir(parents=True, exist_ok=True)
160
+ (backup_dir / container_path.strip('/').replace('/', '__')).write_text(old)
161
+ mode = host_file.stat().st_mode & 0o777 if host_file.exists() else 0o644
162
+ L.atomic_write(host_file, new, mode=mode)
163
+ print(f' written (backup in {backup_dir})')
164
+ if not write and changed:
165
+ print(f'\n{changed} file(s) would change. Apply with: lanes cursor-sync <workspace> --write')
166
+ return 0
@@ -0,0 +1,231 @@
1
+ """lanes guard — make the next in-container out-of-memory kill take a BUILD, never a lane.
2
+
3
+ A container that hits its memory limit gets the kernel's OOM killer, which picks the task with the highest badness
4
+ inside that cgroup: usually the biggest one, and the biggest one is usually an agent. This pass walks the container
5
+ cgroups' pids and RAISES oom_score_adj to 800 on compilers, linkers and test runners — matched by EXACT comm, from an
6
+ allow-list — so that one of them is the victim instead. A build can be re-run; a lane cannot.
7
+
8
+ * Plain /proc reads and writes. No subprocess, no docker, no signal. Raising one's own uid's oom_score_adj needs
9
+ no privilege (lowering it would: that half is sudo/host-guard.sh).
10
+ * It only ever RAISES, and only on the BUILD list. Everything else is left alone; PROTECT (and the container's
11
+ init pid) is spelled out anyway, so that a comm added to BUILD by mistake can still never hit a lane holder.
12
+ * Children inherit the value at fork: raising `cargo` once covers the rustc processes it starts afterwards.
13
+
14
+ One pass per invocation (the 60 s timer is systemd's job; --once is accepted and means exactly that).
15
+ --dry-run prints what it would do. Optional arguments: container names (default: every recorded container).
16
+
17
+ This module is also where tmpscan.py gets the container's pids from (Env, container_pid, cgroup_pids).
18
+ """
19
+ import os
20
+ import signal
21
+ import re
22
+ import sys
23
+ import time
24
+ from pathlib import Path
25
+
26
+ import lanes_lib as L
27
+
28
+ OOM_ADJ = 800
29
+ # comm is what the kernel reports (max 15 bytes): the binary's basename, or the script's for a #! script — so
30
+ # `pytest` is the pytest entry point, while `python3 -m pytest` is `python3` and stays protected.
31
+ BUILD = frozenset('cargo rustc cc1 cc1plus ld ld.lld lld mold clang clang++ gcc g++ zig bazel java golangci-lint '
32
+ 'compile link asm cgo vet rust-analyzer esbuild pytest py.test'.split())
33
+ PROTECT = frozenset(['tmux: server', 'tmux', 'claude', 'codex', 'node', 'zellij', 'bash', 'zsh', 'sh', 'fish'])
34
+ # A docker container's cgroup as the HOST spells it: …/docker-<id>.scope (systemd driver) or /docker/<id> (cgroupfs).
35
+ # Not the substring 'docker': /system.slice/docker.service is the daemon. What follows the container's own group (a
36
+ # container that runs systemd keeps its init in …scope/init.scope) is cut off: the walk must cover ALL of it.
37
+ DOCKER_CGROUP = re.compile(r'^((?:/[^/]+)*?/docker-[0-9a-f]+\.scope|/docker/[0-9a-f]+)(?:/|$)')
38
+
39
+
40
+ def protected(comm):
41
+ return comm in PROTECT or comm.startswith('python')
42
+
43
+
44
+ class Env:
45
+ """Every handle on the outside world in one place, so the unit tests can aim at a synthetic tree.
46
+ LANES_STATE is read here at call time (L.STATE is frozen at import, too early for a test process)."""
47
+
48
+ def __init__(self, proc=None, cgroup=None, state=None, now=None, clock=None, uid=None):
49
+ self.proc = str(proc or os.environ.get('LANES_PROC_ROOT') or '/proc')
50
+ self.cgroup = str(cgroup or os.environ.get('LANES_CGROUP_ROOT') or '/sys/fs/cgroup')
51
+ self.state = Path(state or os.environ.get('LANES_STATE') or L.STATE)
52
+ self.now = now or time.time # wall clock: ages of files
53
+ self.clock = clock or time.monotonic # budgets
54
+ self.uid = L.CONTAINER_UID if uid is None else uid # the container user: the only owner tmpscan removes from
55
+
56
+
57
+ def container_names(env):
58
+ recorded = L.read_json(env.state / 'containers.json', {}) or {}
59
+ return sorted(recorded) or sorted(path.stem for path in (env.state / 'containers').glob('*.pid'))
60
+
61
+
62
+ def container_pid(env, name):
63
+ """The init pid statusd/sync recorded for the container (never docker inspect: this runs on a thrashing host)."""
64
+ if not L.IDENT.match(name):
65
+ raise L.LanesError(f'Invalid container name: {name}')
66
+ try:
67
+ pid = int((env.state / 'containers' / f'{name}.pid').read_text().split()[0])
68
+ except (OSError, ValueError, IndexError):
69
+ raise L.LanesError(f'{name}: no init pid recorded — run `lanes sync`')
70
+ if pid <= 1: # 0 = not running; 1 would be the HOST's init
71
+ raise L.LanesError(f'{name}: container is not running')
72
+ return pid
73
+
74
+
75
+ def _ns_init(env, pid):
76
+ """Is `pid` pid 1 of a pid namespace of its own? NSpid spells a pid in every namespace it is seen from, outermost
77
+ first: a container's init reads 'NSpid: 4242 1', a host process 'NSpid: 4242', a container's child '… 4250 17'."""
78
+ try:
79
+ for line in Path(env.proc, str(pid), 'status').read_text().splitlines():
80
+ if line.startswith('NSpid:'):
81
+ seen = line.split()[1:]
82
+ return len(seen) >= 2 and seen[-1] == '1'
83
+ except OSError:
84
+ pass
85
+ return False
86
+
87
+
88
+ def cgroup_dir(env, pid):
89
+ """Host directory of the container's cgroup-v2 group — and the proof that the RECORDED pid still is a docker
90
+ container's init. The pid file outlives the container, and while docker does not answer it is kept for as long
91
+ as /proc/<pid> exists: a recycled pid must never make us walk (or write to) the host's tasks, or resolve
92
+ /proc/<pid>/root/tmp in the host's mount namespace. The same two tests as ns_init in sudo/host-guard.sh."""
93
+ try:
94
+ text = Path(env.proc, str(pid), 'cgroup').read_text()
95
+ except OSError as error:
96
+ raise L.LanesError(f'pid {pid}: cannot read its cgroup ({error.strerror})')
97
+ for line in text.splitlines():
98
+ if line.startswith('0::'):
99
+ match = DOCKER_CGROUP.match(line[3:].strip())
100
+ if not match or '..' in match.group(1).split('/'):
101
+ break
102
+ if not _ns_init(env, pid):
103
+ raise L.LanesError(f'pid {pid} is not the init of a container (recycled pid?): refusing — run `lanes sync`')
104
+ return os.path.join(env.cgroup, *match.group(1).strip('/').split('/'))
105
+ raise L.LanesError(f'pid {pid} is not in a docker cgroup (v2): refusing')
106
+
107
+
108
+ def cgroup_pids(env, pid):
109
+ """Every process of the container: cgroup.procs of its cgroup and of all child groups (HOST pids)."""
110
+ pids = set()
111
+ for directory, _dirs, files in os.walk(cgroup_dir(env, pid)):
112
+ if 'cgroup.procs' in files:
113
+ try:
114
+ pids.update(int(token) for token in Path(directory, 'cgroup.procs').read_text().split())
115
+ except (OSError, ValueError):
116
+ pass # a child group that went away mid-walk
117
+ return sorted(pids)
118
+
119
+
120
+ def tmux_servers(env, pid):
121
+ """{socket name: host pid} of the tmux SERVER processes of the container (comm 'tmux: server', name from -L)."""
122
+ found = {}
123
+ for process in cgroup_pids(env, pid):
124
+ try: # a busy container starts and ends processes all the time: one that is gone by now is not a server
125
+ if _read(env, process, 'comm') != 'tmux: server':
126
+ continue
127
+ words = (_read(env, process, 'cmdline') or '').split('\0')
128
+ except OSError:
129
+ continue
130
+ for index, word in enumerate(words):
131
+ if word == '-L' and index + 1 < len(words) and L.IDENT.match(words[index + 1]):
132
+ found[words[index + 1]] = process
133
+ break
134
+ else:
135
+ if '-S' not in words:
136
+ found.setdefault('default', process) # a plain `tmux`: the server of the socket named default
137
+ return found
138
+
139
+
140
+ def sockets(env, names, repair=False, out=print, settle=2.0):
141
+ """`lanes sockets [check|repair]`: tmux servers whose socket FILE is gone (someone emptied the container's /tmp:
142
+ 2026-09-20 every lane was alive and unreachable). repair = tmux's own remedy: recreate the directory, then
143
+ SIGUSR1 — "recreate the server socket", tmux(1) — to exactly the servers whose socket is missing. Nothing is
144
+ restarted; panes and agents do not notice. Returns the number of sockets still missing."""
145
+ missing_total = 0
146
+ for name in names:
147
+ try:
148
+ pid = container_pid(env, name)
149
+ cgroup_dir(env, pid)
150
+ except L.LanesError as error:
151
+ out(f'sockets: {name}: {error}')
152
+ continue
153
+ directory = f'{env.proc}/{pid}/root/tmp/tmux-{env.uid}'
154
+ servers = tmux_servers(env, pid)
155
+ missing = {sock: process for sock, process in servers.items() if not os.path.exists(f'{directory}/{sock}')}
156
+ out(f'sockets: {name}: {len(servers)} tmux servers, {len(missing)} without a socket file'
157
+ + (': ' + ' '.join(sorted(missing)) if missing else ''))
158
+ if missing and repair:
159
+ if not os.path.isdir(directory):
160
+ os.mkdir(directory, 0o700)
161
+ os.chmod(directory, 0o700)
162
+ out(f'sockets: {name}: recreated {directory}')
163
+ for sock, process in sorted(missing.items()):
164
+ try:
165
+ if _read(env, process, 'comm') == 'tmux: server': # still that process, still a tmux server
166
+ os.kill(process, signal.SIGUSR1)
167
+ except OSError:
168
+ pass # it ended meanwhile: nothing to repair
169
+ time.sleep(settle)
170
+ missing = {sock: process for sock, process in missing.items() if not os.path.exists(f'{directory}/{sock}')}
171
+ out(f'sockets: {name}: after repair {len(missing)} still missing' + (': ' + ' '.join(sorted(missing)) if missing else ''))
172
+ missing_total += len(missing)
173
+ return missing_total
174
+
175
+
176
+ def _read(env, pid, name):
177
+ return Path(env.proc, str(pid), name).read_text().rstrip('\n')
178
+
179
+
180
+ def guard(env, names, dry_run=False, out=print):
181
+ seen = {'build': 0, 'raised': 0, 'protected': 0, 'errors': 0}
182
+ for name in names:
183
+ try:
184
+ init = container_pid(env, name)
185
+ pids = cgroup_pids(env, init)
186
+ except L.LanesError as error:
187
+ out(f'guard: {error}')
188
+ seen['errors'] += 1
189
+ continue
190
+ for pid in pids:
191
+ try:
192
+ comm = _read(env, pid, 'comm')
193
+ if pid == init or protected(comm):
194
+ seen['protected'] += 1
195
+ continue
196
+ if comm not in BUILD:
197
+ continue
198
+ seen['build'] += 1
199
+ current = int(_read(env, pid, 'oom_score_adj'))
200
+ if current >= OOM_ADJ:
201
+ continue
202
+ if not dry_run:
203
+ Path(env.proc, str(pid), 'oom_score_adj').write_text(f'{OOM_ADJ}\n')
204
+ seen['raised'] += 1
205
+ out(f"guard: {name}: {'would raise' if dry_run else 'raised'} {pid} {comm} {current} -> {OOM_ADJ}")
206
+ except FileNotFoundError:
207
+ pass # it exited: builds are short-lived
208
+ except (OSError, ValueError) as error:
209
+ seen['errors'] += 1
210
+ out(f'guard: {name}: pid {pid}: {error}')
211
+ out('guard: ' + ' '.join(f'{key}={value}' for key, value in seen.items()) + (' (dry-run)' if dry_run else ''))
212
+ return seen
213
+
214
+
215
+ def run(args):
216
+ unknown = [arg for arg in args if arg.startswith('--') and arg not in ('--dry-run', '--once')]
217
+ if unknown:
218
+ raise L.LanesError('usage: lanes guard [--once] [--dry-run] [container…]')
219
+ env = Env()
220
+ names = [arg for arg in args if not arg.startswith('--')] or container_names(env)
221
+ # Exit 0 even with errors (they are printed and counted): a stopped container must not turn the every-minute
222
+ # timer unit into a permanently failed one.
223
+ guard(env, names, dry_run='--dry-run' in args)
224
+ return 0
225
+
226
+
227
+ if __name__ == '__main__':
228
+ try:
229
+ sys.exit(run(sys.argv[1:]))
230
+ except L.LanesError as error:
231
+ sys.exit(f'lanes guard: {error}')