@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.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/bin/lanes-install.mjs +158 -0
- package/lib/plan.mjs +65 -0
- package/package.json +37 -0
- package/payload/lanes/README.md +152 -0
- package/payload/lanes/VERSION +1 -0
- package/payload/lanes/bin/lanes +1035 -0
- package/payload/lanes/client/ghostty/common.conf +22 -0
- package/payload/lanes/client/ghostty/macos.conf +6 -0
- package/payload/lanes/client/ghostty/omarchy.conf +19 -0
- package/payload/lanes/client/install.sh +16 -0
- package/payload/lanes/client/lanes-launch +315 -0
- package/payload/lanes/client/lanes-upload +147 -0
- package/payload/lanes/client/lanes-window +36 -0
- package/payload/lanes/client/macos-helper.applescript +14 -0
- package/payload/lanes/client/omarchy/discover.sh +56 -0
- package/payload/lanes/client/omarchy/lanes.conf +5 -0
- package/payload/lanes/client/omarchy/lanes.desktop +9 -0
- package/payload/lanes/client/omarchy/lanes.lua +14 -0
- package/payload/lanes/client/ssh.stanza.in +54 -0
- package/payload/lanes/container/env.sh +16 -0
- package/payload/lanes/container/lane +268 -0
- package/payload/lanes/container/lane-run +34 -0
- package/payload/lanes/docs/ARCHITECTURE.md +93 -0
- package/payload/lanes/docs/HOST-GUARD.md +108 -0
- package/payload/lanes/docs/INSTALL.md +192 -0
- package/payload/lanes/docs/RUNBOOK.md +385 -0
- package/payload/lanes/docs/SHARING.md +124 -0
- package/payload/lanes/docs/ZMX-RING0.md +51 -0
- package/payload/lanes/docs/images/desktop.png +0 -0
- package/payload/lanes/docs/images/iphone.png +0 -0
- package/payload/lanes/holders/exec.sh +21 -0
- package/payload/lanes/holders/tmux.sh +111 -0
- package/payload/lanes/holders/zmx.sh +22 -0
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-phone.sh +43 -0
- package/payload/lanes/install/enable-web.sh +16 -0
- package/payload/lanes/install/host-install.sh +72 -0
- package/payload/lanes/install/services.py +61 -0
- package/payload/lanes/install.sh +603 -0
- package/payload/lanes/libexec/cursor_commit.py +159 -0
- package/payload/lanes/libexec/cursor_sync.py +166 -0
- package/payload/lanes/libexec/guard.py +231 -0
- package/payload/lanes/libexec/lanes_lib.py +610 -0
- package/payload/lanes/libexec/legacy_sessions.py +130 -0
- package/payload/lanes/libexec/session_actions.py +276 -0
- package/payload/lanes/libexec/sidebar +8 -0
- package/payload/lanes/libexec/sidebar.py +872 -0
- package/payload/lanes/libexec/statusd.py +764 -0
- package/payload/lanes/libexec/tmpscan.py +921 -0
- package/payload/lanes/libexec/view.sh +179 -0
- package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
- package/payload/lanes/site/README.md +28 -0
- package/payload/lanes/site/beyondwork/known_hosts +1 -0
- package/payload/lanes/site/beyondwork/site.conf +11 -0
- package/payload/lanes/site/site.example.conf +10 -0
- package/payload/lanes/sudo/host-guard.sh +356 -0
- package/payload/lanes/sudo/one-time-root.sh +32 -0
- package/payload/lanes/systemd/lanes-ensure.service +7 -0
- package/payload/lanes/systemd/lanes-ensure.timer +10 -0
- package/payload/lanes/systemd/lanes-gc.service +7 -0
- package/payload/lanes/systemd/lanes-gc.timer +9 -0
- package/payload/lanes/systemd/lanes-guard.service +8 -0
- package/payload/lanes/systemd/lanes-guard.timer +10 -0
- package/payload/lanes/systemd/lanes-mobile.service +23 -0
- package/payload/lanes/systemd/lanes-statusd.service +15 -0
- package/payload/lanes/systemd/lanes-sync.service +10 -0
- package/payload/lanes/systemd/lanes-sync.timer +10 -0
- package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
- package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
- package/payload/lanes/systemd/lanes-web.service +13 -0
- package/payload/lanes/systemd/lanes.slice +11 -0
- package/payload/lanes/web/Caddyfile +37 -0
- package/payload/lanes/web/README.md +35 -0
- package/payload/lanes/web/icon-192.png +0 -0
- package/payload/lanes/web/icon.svg +4 -0
- package/payload/lanes/web/index.html +30 -0
- package/payload/lanes/web/manifest.webmanifest +15 -0
- package/payload/lanes/web/mobile.css +301 -0
- package/payload/lanes/web/mobile.js +367 -0
- package/payload/lanes/workspaces.example/README.md +27 -0
- package/payload/lanes/workspaces.example/catalog.json +7 -0
- package/payload/lanes/workspaces.example/commit-targets.json +8 -0
- package/payload/lanes/workspaces.example/terminals.example.json +19 -0
- package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
- package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
- package/payload/lanes/zellij/config.kdl +360 -0
- 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}')
|