leos-agent 10.2.0 → 10.7.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.
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env python3
2
+ """dispatch_log: the append-only record of every subagent dispatch the guard saw.
3
+
4
+ WHY A LOG AT ALL. dispatch_guard.py decides one call at a time and can never see
5
+ a fan-out, so the signals that need a second dispatch to interpret -- was a block
6
+ followed by a compliant re-dispatch, was a small brief one of five siblings --
7
+ are recorded here and resolved at read time. Judgment that lives in the reader
8
+ costs nothing on the hot path and can be revised without a Codex /hooks
9
+ re-approval.
10
+
11
+ NEVER PROMPT TEXT. This file sits in a home directory forever and would otherwise
12
+ accumulate briefs about whatever Leo works on. Prompts and working directories
13
+ are stored as truncated SHA-256, which is enough to notice the same brief
14
+ re-dispatched after a block and useless to anyone reading the file. Raw text only
15
+ under LEOS_AGENT_DISPATCH_LOG_PROMPTS=1, truncated, and documented as debug-only.
16
+
17
+ The file is ${LEOS_AGENT_LOCAL_PATH:-$HOME/.leos-agent-local}/dispatch.jsonl,
18
+ beside routing.json and the handoffs -- data lives with the data, never inside
19
+ the plugin, so an upgrade or an uninstall cannot take it.
20
+
21
+ dispatch_log.py report [--limit N] [--json] what the guard has been seeing
22
+ dispatch_log.py path the log's path
23
+
24
+ Exit codes: 0 ok, 2 on bad usage.
25
+ """
26
+ import argparse
27
+ import collections
28
+ import hashlib
29
+ import json
30
+ import os
31
+ import sys
32
+ import time
33
+
34
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
35
+ # Same data root and the same lock discipline as every other machine-local file.
36
+ # _locked takes any path (it locks a sibling .lock), which is why a .jsonl can
37
+ # use it even though state_file() would force a .json suffix.
38
+ from state import _data_root, _locked # noqa: E402
39
+
40
+ LOG_NAME = "dispatch.jsonl"
41
+
42
+ # One megabyte, one generation. ~230 bytes per record is roughly 4,500 dispatches
43
+ # per file and 9,000 retained -- months of history, bounded at 2 MiB forever, with
44
+ # no cron job and nothing to configure.
45
+ MAX_BYTES = 1 << 20
46
+
47
+ RECORD_VERSION = 1
48
+
49
+
50
+ def path():
51
+ return os.path.join(_data_root(), LOG_NAME)
52
+
53
+
54
+ def digest(text):
55
+ """A short, irreversible stand-in for text we refuse to store."""
56
+ if not text:
57
+ return None
58
+ return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()[:12]
59
+
60
+
61
+ def _keep_prompts():
62
+ return os.environ.get("LEOS_AGENT_DISPATCH_LOG_PROMPTS") == "1"
63
+
64
+
65
+ def record(dispatch, decision, reason, harness, session=None, cwd=None, trivial=0):
66
+ """The on-disk shape for one dispatch. Pure; writes nothing."""
67
+ entry = {
68
+ "v": RECORD_VERSION,
69
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
70
+ "harness": harness,
71
+ "session": digest(session),
72
+ # Parallel dispatches from one assistant message land in the same
73
+ # two-second bucket, which is how `report` tells a fan-out from a
74
+ # sequence of lone spawns without the hot path ever reading the log.
75
+ "burst": "%s:%d" % (digest(session) or "-", int(time.time() // 2)),
76
+ "project": digest(cwd),
77
+ "decision": decision,
78
+ "reason": reason,
79
+ }
80
+ if dispatch is not None:
81
+ entry.update({
82
+ "tool": dispatch.tool,
83
+ "agent": dispatch.agent,
84
+ "model": dispatch.model,
85
+ "trivial": trivial,
86
+ "prompt_bytes": dispatch.prompt_bytes,
87
+ "prompt_lines": dispatch.prompt_lines,
88
+ "paths": dispatch.path_count,
89
+ "prompt": dispatch.prompt_hash,
90
+ })
91
+ if _keep_prompts():
92
+ entry["prompt_text"] = dispatch.prompt_head
93
+ return entry
94
+
95
+
96
+ def append(entry):
97
+ """Append one record. Rotates at MAX_BYTES. Raises only on real I/O trouble.
98
+
99
+ Callers must treat a failure here as cosmetic: the guard's decision is made
100
+ before this is called and emitted after it, so a read-only home or a full
101
+ disk costs a log line, never a dispatch.
102
+ """
103
+ target = path()
104
+ with _locked(target):
105
+ try:
106
+ if os.path.getsize(target) > MAX_BYTES:
107
+ os.replace(target, target + ".1")
108
+ except OSError:
109
+ pass # absent, or unstattable; either way there is nothing to rotate
110
+ # 0600 explicitly: open("a") yields 0644 under a default umask, which is
111
+ # the wrong mode for a file that indexes Leo's projects even in hashes.
112
+ fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
113
+ with os.fdopen(fd, "a") as fh:
114
+ fh.write(json.dumps(entry, sort_keys=True) + "\n")
115
+
116
+
117
+ def read(limit=None, target=None):
118
+ """Records, oldest first. Tolerates a truncated final line from a crash."""
119
+ out = []
120
+ for candidate in ((target,) if target else (path() + ".1", path())):
121
+ try:
122
+ with open(candidate, encoding="utf-8", errors="replace") as fh:
123
+ for line in fh:
124
+ line = line.strip()
125
+ if not line:
126
+ continue
127
+ try:
128
+ entry = json.loads(line)
129
+ except ValueError:
130
+ continue # a half-written last line, or a foreign line
131
+ if isinstance(entry, dict):
132
+ out.append(entry)
133
+ except FileNotFoundError:
134
+ continue
135
+ except OSError as exc:
136
+ sys.exit("dispatch_log: %s: %s" % (candidate, exc.strerror or exc))
137
+ return out[-limit:] if limit else out
138
+
139
+
140
+ def summarise(entries):
141
+ """The read-time judgment: burst collapse, tiers, and block conversion."""
142
+ # Only dispatches that actually ran count toward a burst. A block and the
143
+ # re-dispatch it forced land in the same two-second bucket, and counting
144
+ # both would let every blocked retry pose as a fan-out of two.
145
+ bursts = collections.Counter(
146
+ e.get("burst") for e in entries if e.get("decision") not in ("block", "error")
147
+ )
148
+
149
+ tiers = collections.Counter()
150
+ for entry in entries:
151
+ agent = entry.get("agent") or ""
152
+ if agent.startswith("leo-"):
153
+ tiers[agent] += 1
154
+ elif entry.get("model"):
155
+ tiers["explicit model"] += 1
156
+ elif entry.get("decision") == "block":
157
+ tiers["blocked"] += 1
158
+ else:
159
+ tiers["inherited"] += 1
160
+
161
+ # A trivial-looking brief that was one of several in the same burst is a
162
+ # fan-out, which the policy wants. Only a lone small spawn is a finding --
163
+ # and only one that actually ran: a blocked dispatch spent nothing, so it
164
+ # cannot also be an over-delegation.
165
+ trivial = [
166
+ e for e in entries
167
+ if e.get("trivial", 0) >= 2
168
+ and e.get("decision") not in ("block", "error")
169
+ and bursts.get(e.get("burst"), 0) < 2
170
+ ]
171
+
172
+ # Did a block actually change anything? A blocked brief whose hash comes back
173
+ # naming a tier is the guard working; one that never returns was abandoned.
174
+ blocked = {e.get("prompt") for e in entries if e.get("decision") == "block"}
175
+ blocked.discard(None)
176
+ converted = {
177
+ e.get("prompt") for e in entries
178
+ if e.get("prompt") in blocked and e.get("decision") != "block"
179
+ }
180
+
181
+ return {
182
+ "records": len(entries),
183
+ "window": [entries[0].get("ts"), entries[-1].get("ts")] if entries else [],
184
+ "harnesses": dict(collections.Counter(e.get("harness") for e in entries)),
185
+ "decisions": dict(collections.Counter(e.get("decision") for e in entries)),
186
+ "tiers": dict(tiers),
187
+ "errors": sum(1 for e in entries if e.get("decision") == "error"),
188
+ "blocked": len(blocked),
189
+ "converted": len(converted),
190
+ "trivial_lone_spawns": len(trivial),
191
+ "agents": dict(collections.Counter(
192
+ "%s @ %s" % (e.get("agent") or "-", e.get("model") or "inherited")
193
+ for e in entries
194
+ )),
195
+ }
196
+
197
+
198
+ def render(summary):
199
+ lines = []
200
+ # Errors lead. Conflating "the guard crashed" with "the guard allowed" is how
201
+ # a dead guard goes unnoticed for months, so a nonzero count is the headline.
202
+ if summary["errors"]:
203
+ lines.append("!! %d guard error(s) -- the guard failed open this many times" % summary["errors"])
204
+ if not summary["records"]:
205
+ lines.append("no dispatches recorded yet (%s)" % path())
206
+ return "\n".join(lines)
207
+
208
+ lines.append("%d dispatch(es) %s .. %s" % (summary["records"], summary["window"][0], summary["window"][1]))
209
+ lines.append(" harnesses " + ", ".join("%s %d" % kv for kv in sorted(summary["harnesses"].items())))
210
+ lines.append(" tiers " + ", ".join("%s %d" % kv for kv in sorted(summary["tiers"].items())))
211
+ if summary["blocked"]:
212
+ lines.append(" blocks %d, of which %d re-dispatched with a tier named" % (
213
+ summary["blocked"], summary["converted"]))
214
+ lines.append(" lone small spawns %d (fan-outs excluded)" % summary["trivial_lone_spawns"])
215
+ lines.append(" agent @ model:")
216
+ for name, count in sorted(summary["agents"].items(), key=lambda kv: (-kv[1], kv[0])):
217
+ lines.append(" %-44s %d" % (name, count))
218
+ return "\n".join(lines)
219
+
220
+
221
+ def main(argv=None):
222
+ parser = argparse.ArgumentParser(prog="dispatch_log.py", description=__doc__.splitlines()[0])
223
+ sub = parser.add_subparsers(dest="command", required=True)
224
+ report = sub.add_parser("report", help="what the guard has been seeing")
225
+ report.add_argument("--limit", type=int, default=None, help="only the most recent N records")
226
+ report.add_argument("--json", action="store_true", help="machine-readable summary")
227
+ sub.add_parser("path", help="the log file's path")
228
+
229
+ args = parser.parse_args(argv)
230
+ if args.command == "path":
231
+ print(path())
232
+ return 0
233
+
234
+ summary = summarise(read(limit=args.limit))
235
+ print(json.dumps(summary, indent=1, sort_keys=True) if args.json else render(summary))
236
+ return 0
237
+
238
+
239
+ if __name__ == "__main__":
240
+ sys.exit(main())
@@ -48,6 +48,11 @@ SNAP_MAX_DISTANCE = 10 # beyond this from the requested line, drop instead of s
48
48
 
49
49
  MARKER = "<!-- leos-agent:review-pr -->"
50
50
 
51
+ # The reviewer's procedure caps a review at 15 comments; this is the script's
52
+ # own backstop well above it, so a reviewer talked past its cap by a hostile
53
+ # diff still cannot blanket a pull request.
54
+ MAX_STAGE_COMMENTS = 50
55
+
51
56
 
52
57
  def _mark(body):
53
58
  """Tag a comment body as tool-created, so clear-pending can tell it apart
@@ -462,6 +467,13 @@ def cmd_stage(a):
462
467
  if not comments:
463
468
  print(json.dumps({"staged": 0, "note": "no comments provided; nothing created"}))
464
469
  return
470
+ if len(comments) > MAX_STAGE_COMMENTS:
471
+ print(
472
+ f"input error: {len(comments)} comments exceeds the cap of {MAX_STAGE_COMMENTS}; "
473
+ "a review this wide should be narrowed, not staged",
474
+ file=sys.stderr,
475
+ )
476
+ sys.exit(2)
465
477
 
466
478
  files = fetch_files(a.repo, a.pr)
467
479
  staged, snapped, dropped = validate_comments(comments, build_maps(files))
@@ -18,7 +18,8 @@ it. Files land at <root>/handoffs/<name>.md.
18
18
 
19
19
  Nothing is ever pruned automatically; `rm` is the only way a handoff goes away.
20
20
  `list` shows only handoffs written in or under the current directory unless
21
- --all is passed. Exit codes: 0 ok, non-zero on error.
21
+ --all is passed; when that leaves nothing it falls back to showing all of them
22
+ rather than sending the caller away to re-run. Exit codes: 0 ok, non-zero on error.
22
23
  """
23
24
  import datetime as dt
24
25
  import os
@@ -32,8 +33,13 @@ SLUG = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
32
33
 
33
34
 
34
35
  def handoff_dir():
35
- path = os.path.join(_data_root(), "handoffs")
36
- os.makedirs(path, exist_ok=True)
36
+ # 0700 on creation, matching state.py: handoffs carry project context that
37
+ # is nobody else's business on a shared machine. The root is created first
38
+ # so it gets the mode too — makedirs applies mode to the leaf only.
39
+ root = _data_root()
40
+ os.makedirs(root, mode=0o700, exist_ok=True)
41
+ path = os.path.join(root, "handoffs")
42
+ os.makedirs(path, mode=0o700, exist_ok=True)
37
43
  return path
38
44
 
39
45
 
@@ -60,15 +66,17 @@ def frontmatter(path):
60
66
 
61
67
 
62
68
  def title_of(path):
69
+ """The first `# ` heading — after the frontmatter when there is one, from
70
+ the top of the file when there is not."""
63
71
  try:
64
72
  with open(path) as fh:
65
- seen_open = False
66
- for line in fh:
67
- if line.strip() == "---":
68
- if seen_open:
73
+ first = fh.readline()
74
+ if first.strip() == "---":
75
+ for line in fh:
76
+ if line.strip() == "---":
69
77
  break
70
- seen_open = True
71
- continue
78
+ elif first.startswith("# "):
79
+ return first[2:].strip()
72
80
  for line in fh:
73
81
  if line.startswith("# "):
74
82
  return line[2:].strip()
@@ -128,6 +136,9 @@ def cmd_new(argv):
128
136
  suffix += 1
129
137
  print(name)
130
138
  print(file_for(name))
139
+ # The `created:` value, ready to copy verbatim — a model asked to invent
140
+ # "now" gets it wrong often enough to matter.
141
+ print(dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
131
142
 
132
143
 
133
144
  def cmd_path(argv):
@@ -145,20 +156,32 @@ def cmd_list(argv):
145
156
  except (IndexError, ValueError):
146
157
  sys.exit("handoff: --limit needs a number")
147
158
  here = os.path.realpath(os.getcwd())
148
- rows = []
149
- for name, path, meta, _ in entries():
150
- cwd = os.path.realpath(meta.get("cwd", "")) if meta.get("cwd") else ""
151
- if not show_all and cwd:
152
- related = here == cwd or here.startswith(cwd + os.sep) or cwd.startswith(here + os.sep)
153
- if not related:
154
- continue
155
- rows.append((name, age_of(meta.get("created", "")), meta.get("repo", meta.get("cwd", "?")), title_of(path)))
156
- if len(rows) >= limit:
157
- break
159
+
160
+ def collect(unfiltered):
161
+ rows = []
162
+ for name, path, meta, _ in entries():
163
+ cwd = os.path.realpath(meta.get("cwd", "")) if meta.get("cwd") else ""
164
+ if not unfiltered and cwd:
165
+ related = here == cwd or here.startswith(cwd + os.sep) or cwd.startswith(here + os.sep)
166
+ if not related:
167
+ continue
168
+ rows.append((name, age_of(meta.get("created", "")), meta.get("repo", meta.get("cwd", "?")), title_of(path)))
169
+ if len(rows) >= limit:
170
+ break
171
+ return rows
172
+
173
+ rows = collect(show_all)
174
+ note = ""
175
+ if not rows and not show_all:
176
+ # Falling back beats printing "try --all": the caller is usually a model
177
+ # one round trip from giving up and searching the filesystem instead.
178
+ rows = collect(True)
179
+ note = "none written in or under this directory; showing all"
158
180
  if not rows:
159
- scope = "" if show_all else " for this directory (try --all)"
160
- print(f"no handoffs{scope}")
181
+ print("no handoffs")
161
182
  return
183
+ if note:
184
+ print(note)
162
185
  width = max(len(row[0]) for row in rows)
163
186
  for name, age, repo, title in rows:
164
187
  print(f"{name.ljust(width)} {age.rjust(4)} {repo} {title}")
@@ -23,7 +23,14 @@ import sys
23
23
  import tempfile
24
24
  from pathlib import Path
25
25
 
26
- HARNESSES = ("claude", "codex", "cursor", "hermes", "pi", "opencode")
26
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
27
+ import routing # noqa: E402 owns the harness list and the machine-local model config
28
+
29
+ HARNESSES = routing.HARNESSES
30
+
31
+ # The routing region inside the payload, replaced per harness at install time.
32
+ ROUTING_OPEN = "<!-- leos-agent:routing -->"
33
+ ROUTING_CLOSE = "<!-- /leos-agent:routing -->"
27
34
 
28
35
  OPEN_RE = re.compile(r"^<leos-agent\b[^>]*>[ \t]*$", re.MULTILINE)
29
36
  CLOSE_RE = re.compile(r"^</leos-agent>[ \t]*$", re.MULTILINE)
@@ -36,10 +43,18 @@ CODEX_SOFT_CAP = 28 * 1024
36
43
  # copies apart from a file the user happens to have put at the same path.
37
44
  PROVENANCE = "leos-agent"
38
45
 
46
+ # Skill and command text refers to scripts as <plugin-root>/scripts/… and the
47
+ # model resolves the root once. OpenCode copies live apart from the scripts,
48
+ # and OpenCode sets no resolution env var, so their copies are installed with
49
+ # the token already replaced by this machine's absolute plugin root. Installing
50
+ # alternately from a checkout and a cache re-bakes the root each time — last
51
+ # install wins, and --check reports a stale root as out of date.
52
+ PLUGIN_ROOT_TOKEN = "<plugin-root>"
53
+
39
54
  # OpenCode plugins cannot register skills or commands from JS, so these are
40
55
  # copied to disk instead. check.py asserts every file they name carries
41
56
  # PROVENANCE, without which the installer would refuse to upgrade its own copy.
42
- OPENCODE_SKILLS = ("doctor", "review-pr", "handoff", "handon")
57
+ OPENCODE_SKILLS = ("doctor", "review-pr", "handoff", "handon", "tune-routing")
43
58
  OPENCODE_COMMANDS = ("review-pr", "handoff", "handon")
44
59
 
45
60
  # Codex plugins cannot package custom agent definitions directly, so these are
@@ -100,19 +115,71 @@ def read_version(root):
100
115
  sys.exit(f"leo-install: {manifest} has no version field")
101
116
 
102
117
 
103
- def payload_body(root):
104
- """The canonical payload: rules/preferences.md with its frontmatter stripped."""
118
+ def render_routing(body, harness, config):
119
+ """Replace the routing region with the stanza for this machine's config.
120
+
121
+ The payload ships with a default inside the region, so an un-rendered read of
122
+ rules/preferences.md -- Cursor's plugin-delivered rule, a human opening the
123
+ file -- still says something true. Rendering only ever narrows it to the one
124
+ harness being installed, which is why the installed payload is smaller than
125
+ the file on disk rather than larger.
126
+ """
127
+ start = body.find(ROUTING_OPEN)
128
+ end = body.find(ROUTING_CLOSE)
129
+ if start < 0 or end < start:
130
+ sys.exit(f"leo-install: rules/preferences.md is missing its {ROUTING_OPEN} region")
131
+ return body[:start] + routing.stanza(harness, config) + body[end + len(ROUTING_CLOSE):]
132
+
133
+
134
+ def payload_body(root, harness=None, config=None):
135
+ """The canonical payload: rules/preferences.md with its frontmatter stripped.
136
+
137
+ With a harness, the routing region is rendered for it; without one the region
138
+ keeps its shipped default, markers and all.
139
+ """
105
140
  text = (root / "rules" / "preferences.md").read_text(encoding="utf-8")
106
141
  body = re.sub(r"(?s)\A---\n.*?\n---\n", "", text, count=1).strip()
107
142
  if not body:
108
143
  sys.exit("leo-install: rules/preferences.md has no body below its frontmatter")
109
144
  if OPEN_RE.search(body) or CLOSE_RE.search(body):
110
145
  sys.exit("leo-install: rules/preferences.md contains a <leos-agent> marker; it must not")
146
+ if harness:
147
+ body = render_routing(body, harness, config if config is not None else routing.load())
111
148
  return body
112
149
 
113
150
 
114
- def build_block(root):
115
- return f'<leos-agent version="{read_version(root)}">\n{payload_body(root)}\n</leos-agent>\n'
151
+ def build_block(root, harness=None, config=None):
152
+ return f'<leos-agent version="{read_version(root)}">\n{payload_body(root, harness, config)}\n</leos-agent>\n'
153
+
154
+
155
+ def render_codex_agent(text, agent_name, config):
156
+ """Substitute a Codex profile's model, leaving the shipped default when unset."""
157
+ entry = routing.profile(config, "codex", agent_name.split("-", 1)[1])
158
+ if not entry:
159
+ return text
160
+ text = re.sub(r'(?m)^model = ".*"$', f'model = "{entry["model"]}"', text, count=1)
161
+ if entry["effort"]:
162
+ text = re.sub(
163
+ r'(?m)^model_reasoning_effort = ".*"$',
164
+ f'model_reasoning_effort = "{entry["effort"]}"',
165
+ text,
166
+ count=1,
167
+ )
168
+ return text
169
+
170
+
171
+ def cursor_routing_rule(harness, config):
172
+ """Cursor reads its rules straight out of the plugin, so the per-machine half
173
+ has to arrive as its own always-applied rule file."""
174
+ return (
175
+ "---\n"
176
+ "description: leos-agent model routing for this machine.\n"
177
+ "alwaysApply: true\n"
178
+ "---\n"
179
+ "This supersedes the model-routing dispatch line in Leo's agent operating\n"
180
+ "preferences:\n\n"
181
+ f"{routing.stanza(harness, config)}\n"
182
+ )
116
183
 
117
184
 
118
185
  def scan_markers(text):
@@ -124,16 +191,19 @@ def scan_markers(text):
124
191
  into a permanent "two blocks" error, so fenced regions are skipped.
125
192
  """
126
193
  opens, closes = [], []
127
- fence = None
194
+ fence = None # (char, run length) of the currently open fence
128
195
  offset = 0
129
196
  for line in text.splitlines(keepends=True):
130
197
  stripped = line.lstrip()
131
- marker = stripped[:3]
132
- if marker in ("```", "~~~"):
133
- token = marker
198
+ run = re.match(r"(`{3,}|~{3,})", stripped)
199
+ if run:
200
+ token = run.group(1)
134
201
  if fence is None:
135
- fence = token
136
- elif fence == token:
202
+ fence = (token[0], len(token))
203
+ # CommonMark: only a run of the same character at least as long as
204
+ # the opener closes a fence; a shorter run is fence content, so a
205
+ # ``` line inside a ```` example must not end the example.
206
+ elif fence[0] == token[0] and len(token) >= fence[1]:
137
207
  fence = None
138
208
  elif fence is None:
139
209
  if OPEN_RE.match(line.rstrip("\n")):
@@ -283,11 +353,16 @@ def install_markdown(path, block, args, label, create=True):
283
353
  return write_if_changed(path, inject(current, block), current, existed, crlf, args, label)
284
354
 
285
355
 
286
- def install_file_copy(src, dest, args, label, owned_parent=False):
287
- """Install a payload file the harness's plugin system cannot deliver itself."""
356
+ def install_file_copy(src, dest, args, label, owned_parent=False, payload=None):
357
+ """Install a payload file the harness's plugin system cannot deliver itself.
358
+
359
+ `payload` overrides the source text for files rendered from the machine's
360
+ routing config rather than copied verbatim.
361
+ """
288
362
  dest = dest.expanduser()
289
363
  existed = dest.is_file()
290
- payload = src.read_text(encoding="utf-8")
364
+ if payload is None:
365
+ payload = src.read_text(encoding="utf-8")
291
366
  current = dest.read_text(encoding="utf-8") if existed else ""
292
367
 
293
368
  # Never clobber or delete a same-named file this tool did not put there.
@@ -307,8 +382,27 @@ def install_file_copy(src, dest, args, label, owned_parent=False):
307
382
  return write_if_changed(dest, payload, current, existed, False, args, label)
308
383
 
309
384
 
385
+ def opencode_payload(src, root, rename_install=False):
386
+ """An OpenCode copy's content: the plugin root baked in, optionally renamed.
387
+
388
+ OpenCode reads the copies out of ~/.config/opencode, far from the scripts
389
+ they invoke, and sets none of the resolution env vars — so the placeholder
390
+ is resolved here, at install time, where the root is known for certain.
391
+ The install skill is additionally renamed to match the leo-install/
392
+ directory it is copied into, keeping directory and frontmatter in
393
+ agreement whichever one OpenCode keys on.
394
+ """
395
+ text = src.read_text(encoding="utf-8").replace(PLUGIN_ROOT_TOKEN, str(root))
396
+ if rename_install:
397
+ text = re.sub(r"(?m)^name:\s*install\s*$", "name: leo-install", text, count=1)
398
+ return text
399
+
400
+
310
401
  def run(harness, root, args):
311
- block = build_block(root)
402
+ # Read once per run: rendering has to be a pure function of (version, config)
403
+ # or a second install would not come back "unchanged".
404
+ config = routing.load()
405
+ block = build_block(root, harness, config)
312
406
  home = Path.home()
313
407
  targets = []
314
408
 
@@ -328,22 +422,43 @@ def run(harness, root, args):
328
422
  home / ".codex" / "agents" / f"{n}.toml",
329
423
  args,
330
424
  l,
425
+ payload=render_codex_agent(
426
+ (root / "payload" / "codex-agents" / f"{n}.toml").read_text(encoding="utf-8"),
427
+ n,
428
+ config,
429
+ ),
331
430
  ),
332
431
  )
333
432
  )
334
433
 
335
434
  elif harness == "cursor":
336
- targets.append(
337
- (
338
- "cursor",
339
- lambda: Result(
340
- "cursor",
341
- "skipped",
342
- "Cursor has no on-disk global rules file; the plugin's alwaysApply rule delivers the payload natively",
343
- ),
344
- )
435
+ # The payload itself arrives natively through the plugin's alwaysApply
436
+ # rule, which is the file in the plugin directory -- so there is nothing
437
+ # per-machine in it. Only the routing stanza needs installing, and only
438
+ # as its own rule -- and only when something is actually configured: an
439
+ # unconfigured rule would restate the payload's default, an always-loaded
440
+ # no-op that costs context on every turn.
441
+ label = "~/.cursor/rules/leos-agent-routing.mdc"
442
+ dest = home / ".cursor" / "rules" / "leos-agent-routing.mdc"
443
+ configured = bool(
444
+ routing.profile(config, "cursor", "runner") or routing.profile(config, "cursor", "executor")
345
445
  )
346
446
 
447
+ def cursor_rule_target(l=label):
448
+ if args.uninstall or configured:
449
+ return install_file_copy(None, dest, args, l, payload=cursor_routing_rule(harness, config))
450
+ # Unconfigured install: write nothing, and take back a stale rule a
451
+ # previous config left behind -- but only one that is provably ours.
452
+ if not dest.is_file():
453
+ return Result(l, "skipped", "no routing configured for cursor")
454
+ if PROVENANCE not in dest.read_text(encoding="utf-8"):
455
+ return Result(l, "skipped", "no routing configured; leaving the unrelated file at this path")
456
+ if args.writes:
457
+ dest.unlink()
458
+ return Result(l, "removed", "no routing configured; stale rule removed")
459
+
460
+ targets.append((label, cursor_rule_target))
461
+
347
462
  elif harness == "hermes":
348
463
  # Never create SOUL.md: Hermes writes its own starter identity file on
349
464
  # first run, and pre-empting that would fight the bootstrap.
@@ -374,6 +489,9 @@ def run(harness, root, args):
374
489
  args,
375
490
  skill_label,
376
491
  owned_parent=True,
492
+ payload=opencode_payload(
493
+ root / "skills" / "install" / "SKILL.md", root, rename_install=True
494
+ ),
377
495
  ),
378
496
  )
379
497
  )
@@ -394,6 +512,7 @@ def run(harness, root, args):
394
512
  args,
395
513
  l,
396
514
  owned_parent=True,
515
+ payload=opencode_payload(s, root),
397
516
  ),
398
517
  )
399
518
  )
@@ -407,6 +526,7 @@ def run(harness, root, args):
407
526
  args,
408
527
  l,
409
528
  owned_parent=True,
529
+ payload=opencode_payload(root / "skills" / n / "SKILL.md", root),
410
530
  ),
411
531
  )
412
532
  )
@@ -420,6 +540,7 @@ def run(harness, root, args):
420
540
  cfg / "commands" / f"{n}.md",
421
541
  args,
422
542
  l,
543
+ payload=opencode_payload(root / "commands" / f"{n}.md", root),
423
544
  ),
424
545
  )
425
546
  )